diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_room_insert_remove.go | 54 | ||||
| -rw-r--r-- | internal/behavior/types.go | 3 | ||||
| -rw-r--r-- | internal/casino/plinko.go | 7 | ||||
| -rw-r--r-- | internal/game/act_combine.go | 11 | ||||
| -rw-r--r-- | internal/game/act_fletch.go | 52 | ||||
| -rw-r--r-- | internal/game/cmd_fletch.go | 78 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 59 | ||||
| -rw-r--r-- | internal/game/correct_audit_fixes_test.go | 20 | ||||
| -rw-r--r-- | internal/game/correct_combine_skill_test.go | 2 | ||||
| -rw-r--r-- | internal/game/hardening_craft_combine_fletch_test.go | 4 | ||||
| -rw-r--r-- | internal/game/production_combine_regression_test.go | 2 | ||||
| -rw-r--r-- | internal/game/production_engine.go | 58 | ||||
| -rw-r--r-- | internal/game/production_ingredients_test.go | 4 | ||||
| -rw-r--r-- | internal/game/production_menu.go | 10 | ||||
| -rw-r--r-- | internal/persistence/txn.go | 14 | ||||
| -rw-r--r-- | internal/persistence/txn_test.go | 20 | ||||
| -rw-r--r-- | internal/validate/checks.go | 22 | ||||
| -rw-r--r-- | internal/world/inbound.go | 66 |
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 +} |
