diff options
Diffstat (limited to 'internal/validate/checks.go')
| -rw-r--r-- | internal/validate/checks.go | 151 |
1 files changed, 151 insertions, 0 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 5eb4288..eb24721 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -13,6 +13,7 @@ import ( "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/item" "thehouseoficarus/internal/world" ) @@ -351,6 +352,56 @@ func validateMobAttackTypes(id string, types []string) []Issue { return issues } +// validateMobCombatStats enforces the numbers a fighting mob needs: a positive +// attack speed (engine.ToTicks clips 0 to 1, so a missing speed makes the mob +// attack every tick) and a positive max hit for each declared attack type (a +// missing/zero max hit turns that attack into a silent no-op). Task worksites +// (kind: task) are intentionally inert and exempt — e.g. +// data/mobs/examples/solar_panel_frame.yaml ships max_melee_hit: 0. +func validateMobCombatStats(id string, c *world.MobCombat) []Issue { + var issues []Issue + if c.Kind == "task" { + return issues + } + stats := c.Stats + if stats.Speed <= 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: stats.speed must be > 0 (0 is clipped to 1 tick, so the mob would attack every tick)", id), + }) + } + for _, t := range c.AttackTypes { + switch { + case combat.IsMeleeType(t): + if stats.MaxMeleeHit <= 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: stats.max_melee_hit must be > 0 for attack type %q", id, t), + }) + } + case t == combat.AttackRanged: + if stats.MaxRangedHit <= 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: stats.max_ranged_hit must be > 0 for attack type %q", id, t), + }) + } + case t == combat.AttackScience: + if stats.MaxScienceHit <= 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: stats.max_science_hit must be > 0 for attack type %q", id, t), + }) + } + } + } + return issues +} + func validateMobs(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() @@ -400,6 +451,10 @@ func validateMobs(s Source) []Issue { issues = append(issues, validateMobAttackTypes(id, def.Combat.AttackTypes)...) } + if def.Combat != nil { + issues = append(issues, validateMobCombatStats(id, def.Combat)...) + } + if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] { issues = append(issues, Issue{ Level: "ERROR", @@ -682,6 +737,17 @@ func validateCraftBlocks(s Source) []Issue { return issues } + // CraftDef.Tool names a tool *type* ("knife", "pickaxe", "fire"), not an + // item id: every runtime consumer matches it against ItemDef.ToolType() via + // hasToolType. Types such as "pickaxe" or "big_net" have no same-named item + // file, so membership is checked against the types the item defs define. + toolTypes := make(map[string]bool) + for _, def := range allItems { + if t := def.ToolType(); t != "" { + toolTypes[t] = true + } + } + for _, item := range allItems { if len(item.Craft) == 0 { continue @@ -695,6 +761,18 @@ func validateCraftBlocks(s Source) []Issue { } for _, e := range c.Ingredients { + // Byproducts pair with the alternatives by index + // (collectByproducts reads Byproducts[j] for Items[j]), so a + // short or reordered list silently returns the wrong + // container. Require an exact pairing. + if len(e.Byproducts) != 0 && len(e.Byproducts) != len(e.Items) { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (craft: %s): ingredient entry has %d byproduct(s) for %d alternative(s) — byproducts pair with items by index and must be empty or match in length", + item.ID, craftLabel, len(e.Byproducts), len(e.Items)), + }) + } for _, ingredientID := range e.Items { if ingredientID != "" && !itemIDs[ingredientID] { issues = append(issues, Issue{ @@ -705,6 +783,28 @@ func validateCraftBlocks(s Source) []Issue { }) } } + // Byproducts are delivered into inventory (or dropped) after + // consumption, so a typo here has no runtime error to catch it: + // it silently creates a phantom slot with no item definition. + for _, byproductID := range e.Byproducts { + if byproductID != "" && !itemIDs[byproductID] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (craft: %s): returns nonexistent byproduct %q", + item.ID, craftLabel, byproductID), + }) + } + } + } + + if c.Tool != "" && !toolTypes[c.Tool] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Item %q (craft: %s): requires nonexistent tool %q", + item.ID, craftLabel, c.Tool), + }) } if c.Fail != "" && !itemIDs[c.Fail] { @@ -716,6 +816,45 @@ func validateCraftBlocks(s Source) []Issue { }) } + // A fail product or fail_message is only reachable when the recipe + // can actually fail. With no success formula the engine hardcodes a + // 100% success chance (see production_engine.go), so either field is + // dead data that would never show or drop. Kept as WARN (not ERROR) + // because a handful of shipped recipes author one without the other. + if !craftFailureReachable(c) { + if c.Fail != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("Item %q (craft: %s): fail product %q is unreachable — success is always 100%% (add a success formula)", + item.ID, craftLabel, c.Fail), + }) + } + if c.FailMessage != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("Item %q (craft: %s): fail_message is unreachable — success is always 100%% (add a success formula)", + item.ID, craftLabel), + }) + } + } + + // A recipe that can fail but declares no fail: product destroys the + // materials it consumes: the failure branch removes the ingredients + // and returns nothing. Every shipped failable recipe hands back one + // of its own ingredients (the bars, the pottery, the cooked fish), + // so flag new ones rather than let the loss be silent. WARN, not + // ERROR: a recipe may legitimately choose to destroy materials. + if c.Fail == "" && (craftFailureReachable(c) || c.FailMessage != "") { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("Item %q (craft: %s): failure destroys materials with no fail product", + item.ID, craftLabel), + }) + } + for _, sid := range c.Station { if sid != "" && !objIDs[sid] { issues = append(issues, Issue{ @@ -732,6 +871,18 @@ func validateCraftBlocks(s Source) []Issue { return issues } +// craftFailureReachable reports whether a recipe can ever fail. With no +// success formula the engine treats the success chance as 1.0, so a declared +// fail product or fail_message would never be produced or shown. A formula is +// reachable when the chance at the required level (base, clamped to cap) is +// below 1.0. +func craftFailureReachable(c *item.CraftDef) bool { + if c.Success == nil { + return false + } + return behavior.SuccessChance(behavior.SuccessFormula(*c.Success), c.Level, c.Level) < 1 +} + func validateDropTables(s Source) []Issue { var issues []Issue dropIDs := dropTableIDSet(s.DataDir) |
