aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_room_insert_remove.go54
-rw-r--r--internal/behavior/types.go3
-rw-r--r--internal/casino/plinko.go7
-rw-r--r--internal/game/act_combine.go11
-rw-r--r--internal/game/act_fletch.go52
-rw-r--r--internal/game/cmd_fletch.go78
-rw-r--r--internal/game/cmd_room_remove.go59
-rw-r--r--internal/game/correct_audit_fixes_test.go20
-rw-r--r--internal/game/correct_combine_skill_test.go2
-rw-r--r--internal/game/hardening_craft_combine_fletch_test.go4
-rw-r--r--internal/game/production_combine_regression_test.go2
-rw-r--r--internal/game/production_engine.go58
-rw-r--r--internal/game/production_ingredients_test.go4
-rw-r--r--internal/game/production_menu.go10
-rw-r--r--internal/persistence/txn.go14
-rw-r--r--internal/persistence/txn_test.go20
-rw-r--r--internal/validate/checks.go22
-rw-r--r--internal/world/inbound.go66
18 files changed, 303 insertions, 183 deletions
diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go
index 5f03b72..36688dc 100644
--- a/internal/admin/api_room_insert_remove.go
+++ b/internal/admin/api_room_insert_remove.go
@@ -5,11 +5,9 @@ import (
"net/http"
"os"
"path/filepath"
- "regexp"
"strconv"
"strings"
- "gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/world"
@@ -261,7 +259,7 @@ func (s *AdminServer) handleRoomRemove(w http.ResponseWriter, r *http.Request) {
return
}
// Guard 5: no extra inbound edges to B.
- if extra := s.countExtraInbound(bID, aID, cID, dir, oppositeDir); extra > 0 {
+ if extra := world.CountExtraInbound(s.dataDir, bID, aID, cID, dir, oppositeDir); extra > 0 {
writeJSONError(w, fmt.Sprintf("cannot remove: %d other exit(s) point into %s (it was modified since insertion)", extra, bLabel), http.StatusBadRequest)
return
}
@@ -418,51 +416,5 @@ func (s *AdminServer) formatGridConflicts(cs []world.GridConflict, mode string)
return "cannot " + verb + ": " + strings.Join(parts, "; ")
}
-// countExtraInbound mirrors Game.countExtraInbound for the admin API: it
-// counts exits in rooms other than A and C that point into B, plus any A/C
-// exits to B other than the expected (A→dir→B, C→oppositeDir→B). It walks
-// data/rooms/ and matches \b<id>\b against raw file contents to skip rooms
-// that don't mention bID at all, then unmarshals only the matching files.
-func (s *AdminServer) countExtraInbound(bID, aID, cID int, dir, oppositeDir world.ExitDir) int {
- roomsDir := filepath.Join(s.dataDir, "rooms")
- re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
- var extra int
- _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
- return nil
- }
- data, rerr := os.ReadFile(path)
- if rerr != nil {
- return nil
- }
- if !re.MatchString(string(data)) {
- return nil
- }
- idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
- rid, serr := strconv.Atoi(idStr)
- if serr != nil || rid == bID {
- return nil
- }
- var room world.Room
- if yerr := yaml.Unmarshal(data, &room); yerr != nil {
- return nil
- }
- if room.Exits == nil {
- return nil
- }
- for ed, exitDef := range room.Exits {
- if exitDef.Room != bID {
- continue
- }
- if rid == aID && ed == dir {
- continue
- }
- if rid == cID && ed == oppositeDir {
- continue
- }
- extra++
- }
- return nil
- })
- return extra
-}
+// countExtraInbound was moved to world.CountExtraInbound so the in-game and
+// admin removal paths share one implementation.
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index ad10ef4..ee93022 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -82,6 +82,7 @@ type GatherData struct {
type ProductionData struct {
ItemID string
+ CraftIndex int
Phase int
TicksPerCycle float64
StartMessage string
@@ -166,6 +167,7 @@ type FarmData struct {
type CombineData struct {
ItemID string
+ CraftIndex int
Phase int
StepIndex int
Remaining int
@@ -176,6 +178,7 @@ type CombineData struct {
type FletchData struct {
ItemID string
+ CraftIndex int
Phase int
TicksPerCycle float64
Remaining int
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
index d50cf6f..867978a 100644
--- a/internal/casino/plinko.go
+++ b/internal/casino/plinko.go
@@ -171,7 +171,12 @@ func plinkoHouseEdge(c TableConfig, _ string) HouseEdge {
}
func plinkoRules(c TableConfig) string {
- return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Target RTP %.4g%%. One-decimal payouts are balanced to track that target.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP)
+ rtp := c.Plinko.Normalize().RTP
+ returns := make([]string, len(plinkoRisks))
+ for i, name := range plinkoRisks {
+ returns[i] = fmt.Sprintf("%s %.3f%%", name, 100*plinkoReturn(i, rtp))
+ }
+ return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Target RTP %.4g%%. One-decimal payouts are balanced to track that target; actual returns are %s (higher-risk tables round up by up to ~0.25pp).\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", rtp, strings.Join(returns, ", "))
}
type PlinkoSnapshot struct {
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index 701aaec..d7aed04 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -8,7 +8,7 @@ import (
"thehouseoficarus/internal/player"
)
-func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, count int) {
+func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, count int, craftIndex int) {
g.cancelAction(p)
g.cancelBgAction(p)
@@ -33,6 +33,7 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
TargetName: outputName,
Data: &behavior.CombineData{
ItemID: item.ID,
+ CraftIndex: craftIndex,
Phase: 0,
StepIndex: 0,
Remaining: count,
@@ -72,7 +73,11 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
g.cancelAction(p)
return
}
- craft := item.FirstCraft()
+ idx := d.CraftIndex
+ if idx < 0 || idx >= len(item.Craft) {
+ idx = 0
+ }
+ craft := &item.Craft[idx]
switch d.Phase {
case 0:
@@ -127,7 +132,7 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
// Byproducts must be collected BEFORE consumption: the resolver maps
// each ingredient entry to the alternative actually being consumed
// (bucket vs jug of water), which requires the items still present.
- byproducts := g.collectByproducts(p, item)
+ byproducts := g.collectByproducts(p, craft)
hasItem := craftConsumedHasItem(craft, p.CountItem, p.HasItem)
if !consumeIngredients(craft, p.CountItem, p.RemoveItem) {
diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go
index d4adff0..ba55516 100644
--- a/internal/game/act_fletch.go
+++ b/internal/game/act_fletch.go
@@ -15,16 +15,41 @@ func (g *Game) startFletchAction(sess *net.Session, p *player.Player, def *item.
g.setPlayerFlag(p, "last_fletch", def.ID)
g.saveCharacter(p)
- if def.FirstCraft().TicksPerCycle <= 0 {
- g.doInstantFletch(sess, p, def, count)
+ idx := g.usableFletchCraftIndex(p, def)
+ if idx < 0 {
+ sess.WriteLine("You don't have the materials for that.")
+ return
+ }
+ if def.Craft[idx].TicksPerCycle <= 0 {
+ g.doInstantFletch(sess, p, def, idx, count)
return
}
- g.startTimedFletch(sess, p, def, count)
+ g.startTimedFletch(sess, p, def, idx, count)
}
-func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.ItemDef, count int) {
- c := def.FirstCraft()
+// usableFletchCraftIndex selects the first fletching recipe the player both
+// satisfies and has the tools for. Availability scans every recipe, so
+// execution must use the same one instead of always Craft[0].
+func (g *Game) usableFletchCraftIndex(p *player.Player, def *item.ItemDef) int {
+ for i := range def.Craft {
+ c := &def.Craft[i]
+ if p.Level(player.Fletching) < c.Level {
+ continue
+ }
+ if !craftHasAllItemsQty(c, p.CountItem) {
+ continue
+ }
+ if !g.hasFletchTools(p, def) {
+ continue
+ }
+ return i
+ }
+ return -1
+}
+
+func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.ItemDef, craftIdx, count int) {
+ c := &def.Craft[craftIdx]
outputQty := c.OutputQty
if outputQty <= 0 {
outputQty = 1
@@ -47,7 +72,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
break
}
- byproducts := g.collectByproducts(p, def)
+ byproducts := g.collectByproducts(p, c)
if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
break
@@ -101,8 +126,8 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
sess.WriteLine(msg)
}
-func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, def *item.ItemDef, count int) {
- craft := def.FirstCraft()
+func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, def *item.ItemDef, craftIdx, count int) {
+ craft := &def.Craft[craftIdx]
startMsg := g.resolveCraftMessage(sess, craft.StartMessage, "You begin fletching %i1.", craft, def.ID, nil, p.HasItem)
@@ -121,6 +146,7 @@ func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, def *item.I
TargetName: def.Name,
Data: &behavior.FletchData{
ItemID: def.ID,
+ CraftIndex: craftIdx,
Phase: 0,
TicksPerCycle: craft.TicksPerCycle,
Remaining: count,
@@ -145,7 +171,11 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
g.cancelBgAction(p)
return
}
- c := def.FirstCraft()
+ idx := data.CraftIndex
+ if idx < 0 || idx >= len(def.Craft) {
+ idx = 0
+ }
+ c := &def.Craft[idx]
if phase == 0 {
data.Phase = 1
@@ -174,7 +204,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
}
skill := player.SkillName(c.EffectiveSkill())
- byproducts := g.collectByproducts(p, def)
+ byproducts := g.collectByproducts(p, c)
hasItem := craftConsumedHasItem(c, p.CountItem, p.HasItem)
if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
@@ -222,7 +252,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
}
}
- if !g.canContinueProduction(p, def) {
+ if !g.canContinueProduction(p, def, c) {
sess.WriteLine("You've run out of fletching materials.")
g.cancelBgAction(p)
return
diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go
index af27897..9c147c3 100644
--- a/internal/game/cmd_fletch.go
+++ b/internal/game/cmd_fletch.go
@@ -189,51 +189,57 @@ func (g *Game) findFletchItems(itemAID, itemBID string) []*item.ItemDef {
var matched []*item.ItemDef
for _, item := range items {
- c := item.FirstCraft()
-
- if c.Tool != "" {
- if c.Tool == toolTypeA && craftMatchesEntry(c, itemBID) {
- matched = append(matched, item)
- continue
- }
- if c.Tool == toolTypeB && craftMatchesEntry(c, itemAID) {
+ for i := range item.Craft {
+ if fletchCraftMatchesInputs(&item.Craft[i], item, itemAID, itemBID, toolTypeA, toolTypeB, isToolA, isToolB) {
matched = append(matched, item)
- continue
+ break
}
}
+ }
+ return matched
+}
- aMatch := false
- bMatch := false
- for _, e := range c.Ingredients {
- for _, id := range e.Items {
- if id == itemAID {
- aMatch = true
- }
- if id == itemBID {
- bMatch = true
- }
- }
+// fletchCraftMatchesInputs reports whether one fletching recipe consumes the
+// given two inputs (or a tool plus one input). findFletchItems must scan every
+// recipe, not just the first, or multi-recipe items are unreachable.
+func fletchCraftMatchesInputs(c *item.CraftDef, item *item.ItemDef, itemAID, itemBID, toolTypeA, toolTypeB string, isToolA, isToolB bool) bool {
+ if c.Tool != "" {
+ if c.Tool == toolTypeA && craftMatchesEntry(c, itemBID) {
+ return true
}
-
- if aMatch && bMatch {
- matched = append(matched, item)
- continue
+ if c.Tool == toolTypeB && craftMatchesEntry(c, itemAID) {
+ return true
}
+ }
- if isToolA && bMatch {
- if (fletchNeedsKnife(item) && toolTypeA == "knife") ||
- (fletchNeedsChisel(item) && toolTypeA == "chisel") {
- matched = append(matched, item)
- continue
+ aMatch := false
+ bMatch := false
+ for _, e := range c.Ingredients {
+ for _, id := range e.Items {
+ if id == itemAID {
+ aMatch = true
}
- }
- if isToolB && aMatch {
- if (fletchNeedsKnife(item) && toolTypeB == "knife") ||
- (fletchNeedsChisel(item) && toolTypeB == "chisel") {
- matched = append(matched, item)
- continue
+ if id == itemBID {
+ bMatch = true
}
}
}
- return matched
+
+ if aMatch && bMatch {
+ return true
+ }
+
+ if isToolA && bMatch {
+ if (fletchNeedsKnife(item) && toolTypeA == "knife") ||
+ (fletchNeedsChisel(item) && toolTypeA == "chisel") {
+ return true
+ }
+ }
+ if isToolB && aMatch {
+ if (fletchNeedsKnife(item) && toolTypeB == "knife") ||
+ (fletchNeedsChisel(item) && toolTypeB == "chisel") {
+ return true
+ }
+ }
+ return false
}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index 79b84d8..c84e747 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -2,11 +2,6 @@ package game
import (
"fmt"
- "os"
- "path/filepath"
- "regexp"
- "strconv"
- "strings"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/net"
@@ -125,7 +120,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
// Guard 5: no other room points into B. The only allowed inbound edges are
// A→dir→B and C→oppositeDir→B.
- if extra := g.countExtraInbound(bID, aID, cID, dir, oppositeDir); extra > 0 {
+ if extra := world.CountExtraInbound(g.DataDir, bID, aID, cID, dir, oppositeDir); extra > 0 {
sess.WriteLine(fmt.Sprintf(
"Cannot remove: %d other exit(s) point into %s (it was modified since insertion).",
extra, bName))
@@ -244,53 +239,5 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
g.doLook(sess)
}
-// countExtraInbound returns the number of exits in rooms OTHER than A and C
-// that point into B, plus any A/C exits to B other than the expected
-// (A→dir→B, C→oppositeDir→B). A nonzero result means B has been modified since
-// it was inserted and the remove would silently orphan edges. It walks
-// data/rooms/ and matches \b<id>\b against raw file contents to skip rooms
-// that don't mention bID at all, then unmarshals the matching files.
-func (g *Game) countExtraInbound(bID, aID, cID int, dir, oppositeDir world.ExitDir) int {
- roomsDir := filepath.Join(g.DataDir, "rooms")
- re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
- var extra int
- _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
- return nil
- }
- data, rerr := os.ReadFile(path)
- if rerr != nil {
- return nil
- }
- if !re.MatchString(string(data)) {
- return nil
- }
- idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
- rid, serr := strconv.Atoi(idStr)
- if serr != nil || rid == bID {
- return nil
- }
- var room world.Room
- if yerr := yaml.Unmarshal(data, &room); yerr != nil {
- return nil
- }
- if room.Exits == nil {
- return nil
- }
- for ed, exitDef := range room.Exits {
- if exitDef.Room != bID {
- continue
- }
- // Allowed: A→dir→B and C→oppositeDir→B.
- if rid == aID && ed == dir {
- continue
- }
- if rid == cID && ed == oppositeDir {
- continue
- }
- extra++
- }
- return nil
- })
- return extra
-}
+// countExtraInbound was moved to world.CountExtraInbound so the in-game and
+// admin removal paths share one implementation.
diff --git a/internal/game/correct_audit_fixes_test.go b/internal/game/correct_audit_fixes_test.go
index 681843c..6177eac 100644
--- a/internal/game/correct_audit_fixes_test.go
+++ b/internal/game/correct_audit_fixes_test.go
@@ -7,6 +7,7 @@ import (
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
"thehouseoficarus/internal/prediction"
)
@@ -106,6 +107,25 @@ func TestPickupCreditsHonorsRequestedQuantity(t *testing.T) {
// Regression: the player-file classifier must canonicalize repeated "/" and
// "data/" prefixes so a raw-editor path cannot dodge the online check.
+// Regression: availability scans every recipe, but execution used Craft[0], so
+// a player satisfying a later recipe (e.g. oak arrow shafts) got a start-then-
+// abort. usableCraftIndex must pick the satisfied recipe.
+func TestUsableCraftIndexSelectsSatisfiedRecipe(t *testing.T) {
+ g := &Game{}
+ p := player.New("alice")
+ def := &item.ItemDef{
+ ID: "arrow_shaft",
+ Craft: item.CraftList{
+ {Type: "fletching", Level: 1, Ingredients: []item.IngredientEntry{{Items: []string{"logs"}, Quantity: 1}}},
+ {Type: "fletching", Level: 1, Ingredients: []item.IngredientEntry{{Items: []string{"oak_logs"}, Quantity: 1}}},
+ },
+ }
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "oak_logs", Quantity: 5})
+ if idx := g.usableCraftIndex(p, def); idx != 1 {
+ t.Fatalf("usableCraftIndex = %d, want 1 (the satisfied oak recipe)", idx)
+ }
+}
+
func TestPlayerDataFileNameCanonicalizesPrefixes(t *testing.T) {
for _, in := range []string{
"players/characters/alice.yaml",
diff --git a/internal/game/correct_combine_skill_test.go b/internal/game/correct_combine_skill_test.go
index d6de229..7d9c725 100644
--- a/internal/game/correct_combine_skill_test.go
+++ b/internal/game/correct_combine_skill_test.go
@@ -68,7 +68,7 @@ func TestCombineXPGoesToCraftingNotAPhantomSkill(t *testing.T) {
h.g.ItemStore.Reload(h.dir)
p.SetInvSlot(0, &player.InventorySlot{ItemID: "correct_combine_part", Quantity: 1})
def := mustLoadItem(t, h.g, "correct_combine_out")
- h.g.startCombine(sess, p, def, def.FirstCraft(), 1)
+ h.g.startCombine(sess, p, def, def.FirstCraft(), 1, 0)
if p.Action == nil {
t.Fatal("combine did not start")
}
diff --git a/internal/game/hardening_craft_combine_fletch_test.go b/internal/game/hardening_craft_combine_fletch_test.go
index f3f6832..5b5d1c1 100644
--- a/internal/game/hardening_craft_combine_fletch_test.go
+++ b/internal/game/hardening_craft_combine_fletch_test.go
@@ -124,7 +124,7 @@ func TestCombineHonorsSuccessXPAndByproducts(t *testing.T) {
sess.SetState(net.StateGame)
def := mustLoadItem(t, g, "test_combine_out")
- g.startCombine(sess, p, def, def.FirstCraft(), 1)
+ g.startCombine(sess, p, def, def.FirstCraft(), 1, 0)
if p.Action == nil {
t.Fatal("combine did not start")
}
@@ -165,7 +165,7 @@ func TestCombineCreatesFailOnFailure(t *testing.T) {
sess.SetState(net.StateGame)
def := mustLoadItem(t, g, "test_combine_fail_out")
- g.startCombine(sess, p, def, def.FirstCraft(), 1)
+ g.startCombine(sess, p, def, def.FirstCraft(), 1, 0)
g.advanceCombine(sess, p)
g.advanceCombine(sess, p)
g.advanceCombine(sess, p)
diff --git a/internal/game/production_combine_regression_test.go b/internal/game/production_combine_regression_test.go
index c617565..80e0651 100644
--- a/internal/game/production_combine_regression_test.go
+++ b/internal/game/production_combine_regression_test.go
@@ -92,7 +92,7 @@ func TestCombineAlternativesConsumeVerifiedSource(t *testing.T) {
sess := &net.Session{Conn: conn, Player: p}
sess.SetState(net.StateGame)
- h.g.startCombine(sess, p, dough, dough.FirstCraft(), 1)
+ h.g.startCombine(sess, p, dough, dough.FirstCraft(), 1, 0)
if p.Action == nil {
t.Error("combine action did not start")
return
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index f80daa2..4717fbf 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -72,7 +72,7 @@ func (g *Game) resolveCraftMessage(sess *net.Session, itemMsg, defaultMsg string
return g.resolveCraftVar(sess, msg, craft, outputID, byproducts, hasItem)
}
-func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, actionType behavior.ActionType, displayVerb, startMsg, endMsg string, count int) {
+func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.ItemDef, craft *item.CraftDef, actionType behavior.ActionType, displayVerb, startMsg, endMsg string, count int, craftIndex int) {
g.cancelAction(p)
g.cancelBgAction(p)
@@ -110,6 +110,7 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
TargetName: outputName,
Data: &behavior.ProductionData{
ItemID: item.ID,
+ CraftIndex: craftIndex,
Phase: 0,
TicksPerCycle: wait,
StartMessage: startMsg,
@@ -122,11 +123,42 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
g.broadcastAction(sess, "%s starts %s.", p.Name, displayVerb)
}
+// usableCraftIndex returns the index of the first recipe of item the player
+// satisfies (level and ingredients), or -1. Availability checks scan every
+// recipe, so execution must select the same one instead of always Craft[0].
+func (g *Game) usableCraftIndex(p *player.Player, item *item.ItemDef) int {
+ for i := range item.Craft {
+ c := &item.Craft[i]
+ skill := c.EffectiveSkill()
+ if skill != "" && p.Level(player.SkillName(skill)) < c.Level {
+ continue
+ }
+ if !craftHasAllItemsQty(c, p.CountItem) {
+ continue
+ }
+ return i
+ }
+ return -1
+}
+
+// usableCraft is usableCraftIndex with a first-recipe fallback for callers
+// that only need a display recipe.
+func (g *Game) usableCraft(p *player.Player, item *item.ItemDef) *item.CraftDef {
+ if idx := g.usableCraftIndex(p, item); idx >= 0 {
+ return &item.Craft[idx]
+ }
+ return item.FirstCraft()
+}
+
func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item *item.ItemDef, count int) {
- craft := item.FirstCraft()
- if craft == nil {
+ idx := g.usableCraftIndex(p, item)
+ if idx < 0 {
+ idx = 0
+ }
+ if idx >= len(item.Craft) {
return
}
+ craft := &item.Craft[idx]
// Normalize the empty craft type to "combine" here as well as in the
// craft index: an omitted `type:` is combine, but the index's normalized
@@ -137,7 +169,7 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item
craftType = "combine"
}
if craftType == "combine" {
- g.startCombine(sess, p, item, craft, count)
+ g.startCombine(sess, p, item, craft, count, idx)
return
}
@@ -172,7 +204,7 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item
endMsg := g.resolveCraftMessage(sess, craft.EndMessage, info.EndMessage, craft, item.ID, nil, p.HasItem)
- g.startProduction(sess, p, item, craft, info.ActionType, info.DisplayVerb, startMsg, endMsg, count)
+ g.startProduction(sess, p, item, craft, info.ActionType, info.DisplayVerb, startMsg, endMsg, count, idx)
}
func (g *Game) loadCraftItem(itemID string) *item.ItemDef {
@@ -201,7 +233,11 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
g.cancelAction(p)
return
}
- craft := item.FirstCraft()
+ idx := d.CraftIndex
+ if idx < 0 || idx >= len(item.Craft) {
+ idx = 0
+ }
+ craft := &item.Craft[idx]
if phase == 0 {
if startMsg != "" {
@@ -250,7 +286,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
// Byproducts must be collected BEFORE consumption: the resolver
// maps each ingredient entry to the alternative actually being
// consumed, which requires the items still present.
- byproducts := g.collectByproducts(p, item)
+ byproducts := g.collectByproducts(p, craft)
if !consumeIngredients(craft, p.CountItem, p.RemoveItem) {
// Cannot happen after the availability gate above, but never
@@ -323,7 +359,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
}
}
- if g.canContinueProduction(p, item) {
+ if g.canContinueProduction(p, item, craft) {
p.Action.WaitLeft = engine.ToTicks(wait)
return
}
@@ -334,8 +370,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
g.cancelAction(p)
}
-func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string {
- craft := item.FirstCraft()
+func (g *Game) collectByproducts(p *player.Player, craft *item.CraftDef) []string {
if craft == nil {
return nil
}
@@ -433,8 +468,7 @@ func (g *Game) placeCraftByproducts(p *player.Player, byproducts []string) {
}
}
-func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool {
- craft := item.FirstCraft()
+func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef, craft *item.CraftDef) bool {
if craft == nil {
return false
}
diff --git a/internal/game/production_ingredients_test.go b/internal/game/production_ingredients_test.go
index d799df8..5206346 100644
--- a/internal/game/production_ingredients_test.go
+++ b/internal/game/production_ingredients_test.go
@@ -111,14 +111,14 @@ func TestCollectByproductsMatchesConsumedAlternative(t *testing.T) {
// Only the jug is present: the empty jug returns.
p := invOf(t, map[string]int{"jug_of_water": 1})
- if got := g.collectByproducts(p, &item.ItemDef{Craft: item.CraftList{*craft}}); len(got) != 1 || got[0] != "empty_jug" {
+ if got := g.collectByproducts(p, craft); len(got) != 1 || got[0] != "empty_jug" {
t.Errorf("byproducts = %v, want [empty_jug]", got)
}
// Both present: consumption picks the FIRST alternative (the bucket),
// so the byproduct must be the empty bucket.
p = invOf(t, map[string]int{"bucket_of_water": 1, "jug_of_water": 1})
- if got := g.collectByproducts(p, &item.ItemDef{Craft: item.CraftList{*craft}}); len(got) != 1 || got[0] != "empty_bucket" {
+ if got := g.collectByproducts(p, craft); len(got) != 1 || got[0] != "empty_bucket" {
t.Errorf("byproducts = %v, want [empty_bucket]", got)
}
}
diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go
index 9b9f8e6..5d712da 100644
--- a/internal/game/production_menu.go
+++ b/internal/game/production_menu.go
@@ -101,7 +101,7 @@ func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) {
}
func (g *Game) formatMaterials(sess *net.Session, item *item.ItemDef) string {
- craft := item.FirstCraft()
+ craft := g.usableCraft(sess.Player, item)
if craft == nil {
return ""
}
@@ -125,7 +125,7 @@ func (g *Game) formatMaterials(sess *net.Session, item *item.ItemDef) string {
func (g *Game) showProductionTable(sess *net.Session, p *player.Player, items []*item.ItemDef, title, skill, lastFlagKey, promptVerb string, background bool) {
sort.Slice(items, func(i, j int) bool {
- return items[i].FirstCraft().Level < items[j].FirstCraft().Level
+ return g.usableCraft(p, items[i]).Level < g.usableCraft(p, items[j]).Level
})
mode := g.colorMode(sess)
@@ -138,7 +138,7 @@ func (g *Game) showProductionTable(sess *net.Session, p *player.Player, items []
}
for i, item := range items {
- craft := item.FirstCraft()
+ craft := g.usableCraft(p, item)
outDef, _ := g.ItemStore.Load(item.ID)
productName := item.ID
if outDef != nil {
@@ -230,7 +230,7 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) {
}
sort.Slice(items, func(i, j int) bool {
- return items[i].FirstCraft().Level < items[j].FirstCraft().Level
+ return g.usableCraft(p, items[i]).Level < g.usableCraft(p, items[j]).Level
})
lastItemID, _ := p.Flags[lastFlagKey].(string)
@@ -247,7 +247,7 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) {
return
}
- craft := sel.Item.FirstCraft()
+ craft := g.usableCraft(p, sel.Item)
skillLevel := p.Level(player.SkillName(skill))
if skillLevel < craft.Level {
sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", craft.Level, skill))
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go
index a12e227..988af23 100644
--- a/internal/persistence/txn.go
+++ b/internal/persistence/txn.go
@@ -34,6 +34,10 @@ import (
// as a committed transaction that the next startup will roll back.
const journalRemoveAttempts = 3
+// journalVersion is the current journal schema version. Recover refuses a
+// version it does not understand rather than misreading a newer journal.
+const journalVersion = 1
+
type fileOp struct {
// Replace / Delete / Move data
Path string `json:"path"` // file this record is keyed by (its before-image lives here)
@@ -57,7 +61,10 @@ const (
)
type journalEntry struct {
- Ops []fileOp `json:"ops"`
+ // Version guards against a downgrade silently misreading a newer journal.
+ // 0 is the legacy unversioned format; unknown future versions are refused.
+ Version int `json:"version"`
+ Ops []fileOp `json:"ops"`
}
type Transaction struct {
@@ -244,7 +251,7 @@ func (t *Transaction) MarshalJournal() ([]byte, error) {
for _, p := range t.order {
ops = append(ops, *t.ops[p])
}
- return json.Marshal(journalEntry{Ops: ops})
+ return json.Marshal(journalEntry{Version: journalVersion, Ops: ops})
}
// Commit writes the journal (durable), applies all operations, and removes the
@@ -432,6 +439,9 @@ func Recover(journalDir string) error {
}
continue
}
+ if entry.Version != 0 && entry.Version != journalVersion {
+ return fmt.Errorf("journal %s: unsupported version %d", path, entry.Version)
+ }
t := &Transaction{ops: make(map[string]*fileOp), journalDir: journalDir}
for i := range entry.Ops {
op := entry.Ops[i]
diff --git a/internal/persistence/txn_test.go b/internal/persistence/txn_test.go
index be19c7c..e1cd05d 100644
--- a/internal/persistence/txn_test.go
+++ b/internal/persistence/txn_test.go
@@ -280,3 +280,23 @@ func TestRecoverKeepsJournalWithUnknownOpKind(t *testing.T) {
t.Fatalf("journal with unknown kind was removed: %v", err)
}
}
+
+// Regression: a journal written by a newer version must be refused, not
+// silently misread or deleted.
+func TestRecoverRefusesUnknownJournalVersion(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(journalDir, "txn-future.json")
+ if err := os.WriteFile(path, []byte(`{"version":99,"ops":[]}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Recover(journalDir); err == nil {
+ t.Fatal("Recover accepted an unknown journal version")
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("journal with an unknown version was removed: %v", err)
+ }
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 2030225..8fe8e72 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -547,6 +547,17 @@ func validateMobs(s Source) []Issue {
id, d.Table),
})
}
+ if d.Weight <= 0 {
+ label := d.ItemID
+ if label == "" {
+ label = "<table:" + d.Table + ">"
+ }
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("Mob %q: loot %q has weight %d; it can never be selected", id, label, d.Weight),
+ })
+ }
}
if def.Steal != nil {
@@ -567,6 +578,17 @@ func validateMobs(s Source) []Issue {
id, d.Table),
})
}
+ if d.Weight <= 0 {
+ label := d.ItemID
+ if label == "" {
+ label = "<table:" + d.Table + ">"
+ }
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("Mob %q: steal drop %q has weight %d; it can never be selected", id, label, d.Weight),
+ })
+ }
}
}
diff --git a/internal/world/inbound.go b/internal/world/inbound.go
new file mode 100644
index 0000000..0fbef04
--- /dev/null
+++ b/internal/world/inbound.go
@@ -0,0 +1,66 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// CountExtraInbound returns the number of exits in rooms OTHER than A and C
+// that point into B, plus any A/C exits to B other than the expected
+// (A→dir→B, C→oppositeDir→B). A nonzero result means B has been modified
+// since it was inserted and a remove would silently orphan edges. It walks
+// data/rooms and matches \b<id>\b against raw file contents to skip rooms
+// that don't mention bID at all, then unmarshals only the matching files.
+//
+// Shared by the in-game room-remove command and the admin API so the two
+// removal paths cannot drift.
+func CountExtraInbound(dataDir string, bID, aID, cID int, dir, oppositeDir ExitDir) int {
+ roomsDir := filepath.Join(dataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
+ var extra int
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if !re.MatchString(string(data)) {
+ return nil
+ }
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ rid, serr := strconv.Atoi(idStr)
+ if serr != nil || rid == bID {
+ return nil
+ }
+ var room Room
+ if yerr := yaml.Unmarshal(data, &room); yerr != nil {
+ return nil
+ }
+ if room.Exits == nil {
+ return nil
+ }
+ for ed, exitDef := range room.Exits {
+ if exitDef.Room != bID {
+ continue
+ }
+ // Allowed: A→dir→B and C→oppositeDir→B.
+ if rid == aID && ed == dir {
+ continue
+ }
+ if rid == cID && ed == oppositeDir {
+ continue
+ }
+ extra++
+ }
+ return nil
+ })
+ return extra
+}