aboutsummaryrefslogtreecommitdiff
path: root/internal/validate/checks.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/validate/checks.go')
-rw-r--r--internal/validate/checks.go151
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)