From c14c2e403c75a913e1a6d962fb6fe64f013adae5 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 1 Jul 2026 22:30:31 -0400 Subject: fix: combat formulas and mob attack types (mobs switch attack styles if safespotting) --- internal/validate/checks.go | 59 ++++++++++++++++++++++++++++++-- internal/validate/grid_test.go | 14 ++++---- internal/validate/mob_attacktype_test.go | 31 +++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 internal/validate/mob_attacktype_test.go (limited to 'internal/validate') diff --git a/internal/validate/checks.go b/internal/validate/checks.go index a8f973f..ccf996c 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -7,6 +7,7 @@ import ( "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" "thehouseoficarus/internal/world" ) @@ -292,6 +293,57 @@ func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue { return issues } +// validateMobAttackTypes enforces the mob attack_types spec: a non-empty list +// containing exactly one melee type (stab/slash/crush) plus at most one each of +// the optional ranged/science types, with no invalid or duplicate entries. +func validateMobAttackTypes(id string, types []string) []Issue { + var issues []Issue + + if len(types) == 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: missing attack_types (must be a list with exactly one of stab/slash/crush, optionally plus ranged/science)", id), + }) + return issues + } + + meleeCount := 0 + seen := map[string]bool{} + for _, t := range types { + if !combat.IsValidAttackType(t) { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: invalid attack_types entry %q (must be stab/slash/crush/ranged/science)", id, t), + }) + continue + } + if seen[t] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: duplicate attack_types entry %q", id, t), + }) + continue + } + seen[t] = true + if combat.IsMeleeType(t) { + meleeCount++ + } + } + + if meleeCount != 1 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: attack_types must contain exactly one melee type (stab/slash/crush), found %d", id, meleeCount), + }) + } + + return issues +} + func validateMobs(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() @@ -336,6 +388,10 @@ func validateMobs(s Source) []Issue { }) } + if def.Combat != nil && def.Combat.Kind != "task" { + issues = append(issues, validateMobAttackTypes(id, def.Combat.AttackTypes)...) + } + if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] { issues = append(issues, Issue{ Level: "ERROR", @@ -427,7 +483,6 @@ func validateMobs(s Source) []Issue { func validateHazards(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() - validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true} for id := range s.World.HazardIndex() { def, err := s.World.LoadHazard(id) @@ -439,7 +494,7 @@ func validateHazards(s Source) []Issue { }) continue } - if def.AttackType != "" && !validTypes[def.AttackType] { + if def.AttackType != "" && !combat.IsValidAttackType(def.AttackType) { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go index 716cec1..e40c50b 100644 --- a/internal/validate/grid_test.go +++ b/internal/validate/grid_test.go @@ -141,13 +141,13 @@ func TestGrid3DTwistUpDown(t *testing.T) { // path2: 1 east→A(1,0,0) up→B(1,0,1) east→C(2,0,1) south→4 wants (2,1,1) // 4 already at (1,1,1) from path1 → twist. rooms := map[int]string{ - 1: "exits:\n up: 2\n east: 6\n", - 2: "exits:\n south: 3\n down: 1\n", - 3: "exits:\n east: 4\n north: 2\n", - 4: "name: four\n", - 6: "exits:\n up: 7\n west: 1\n", - 7: "exits:\n east: 8\n down: 6\n", - 8: "exits:\n south: 4\n west: 7\n", + 1: "exits:\n up: 2\n east: 6\n", + 2: "exits:\n south: 3\n down: 1\n", + 3: "exits:\n east: 4\n north: 2\n", + 4: "name: four\n", + 6: "exits:\n up: 7\n west: 1\n", + 7: "exits:\n east: 8\n down: 6\n", + 8: "exits:\n south: 4\n west: 7\n", } issues := runGridCheck(t, rooms, 1) if !containsMsg(issues, "Grid twist") { diff --git a/internal/validate/mob_attacktype_test.go b/internal/validate/mob_attacktype_test.go new file mode 100644 index 0000000..1464d3f --- /dev/null +++ b/internal/validate/mob_attacktype_test.go @@ -0,0 +1,31 @@ +package validate + +import "testing" + +func TestValidateMobAttackTypes(t *testing.T) { + cases := []struct { + name string + types []string + wantError bool + }{ + {"single melee", []string{"crush"}, false}, + {"melee plus ranged", []string{"stab", "ranged"}, false}, + {"melee plus both", []string{"slash", "ranged", "science"}, false}, + {"empty", nil, true}, + {"no melee", []string{"ranged"}, true}, + {"two melee", []string{"stab", "crush"}, true}, + {"invalid entry", []string{"crush", "magic"}, true}, + {"duplicate", []string{"crush", "ranged", "ranged"}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + issues := validateMobAttackTypes("test_mob", c.types) + if c.wantError && len(issues) == 0 { + t.Errorf("validateMobAttackTypes(%v): expected error, got none", c.types) + } + if !c.wantError && len(issues) != 0 { + t.Errorf("validateMobAttackTypes(%v): expected no error, got %v", c.types, issues) + } + }) + } +} -- cgit v1.2.3