diff --git a/scripts/game_state.gd b/scripts/game_state.gd index 3a4a609..759022c 100644 --- a/scripts/game_state.gd +++ b/scripts/game_state.gd @@ -1,5 +1,10 @@ extends Node +const LayoutMath := preload("res://scripts/layout_math.gd") +const RotatingGoal := preload("res://scripts/rotating_goal.gd") +const ColonyStance := preload("res://scripts/colony_stance.gd") +const GoalReward := preload("res://scripts/goal_reward.gd") + const SAVE_KEY := "windowstead-save-v2" const BACKUP_PREFIX := "windowstead-backup-" const SAVE_PATH := "user://windowstead.save" @@ -7,6 +12,10 @@ const SAVE_VERSION := 2 const SETTINGS_KEY := "windowstead-settings-v1" const SETTINGS_PATH := "user://windowstead.settings" +# Grid sizes are derived from LayoutMath's known anchor families; small legacy +# sizes are kept only for historical save compatibility. +const _LEGACY_GRID_SIZES: Array = [25, 36, 64, 100, 150] + var save_supported := false var use_local_storage := false @@ -124,13 +133,26 @@ func validate_save_schema(data: Dictionary) -> Dictionary: # If tiles are present and non-empty, validate grid size and shape var tile_count = tiles.size() if tile_count > 0: - # Accept common grid sizes used by the game: - # Bottom anchor: 32x5=160, Side anchor: 10x24=240 - # Plus smaller grids for testing - var expected_sizes := [25, 36, 64, 100, 150, 160, 240] + # Derive the expected grid sizes from the anchor family configuration + # exported by LayoutMath rather than maintaining a hardcoded list. + var expected_sizes: Array = _expected_grid_sizes() if not expected_sizes.has(tile_count): return {"valid": false, "reason": "'tiles' count %d does not match expected grid sizes (%s)" % [tile_count, str(expected_sizes)]} + # Internal consistency: tile count must equal grid_w * grid_h for the + # anchor family declared in the save (when both fields are present). + if data.has("grid_w") and data.has("grid_h"): + var gw_var = data["grid_w"] + var gh_var = data["grid_h"] + if typeof(gw_var) != TYPE_INT or typeof(gh_var) != TYPE_INT: + return {"valid": false, "reason": "'grid_w' and 'grid_h' must be integers"} + var gw: int = int(gw_var) + var gh: int = int(gh_var) + if gw <= 0 or gh <= 0: + return {"valid": false, "reason": "'grid_w' and 'grid_h' must be positive"} + if tile_count != gw * gh: + return {"valid": false, "reason": "'tiles' count %d does not match grid_w*grid_h=%d" % [tile_count, gw * gh]} + # Validate each tile has required shape for i in range(tiles.size()): var tile = tiles[i] @@ -232,14 +254,167 @@ func validate_save_schema(data: Dictionary) -> Dictionary: if not events is Array: return {"valid": false, "reason": "'events' must be an array"} - # Validate 'active_rewards' is an array (if present) + # Validate 'active_rewards' is an array of structurally valid reward dicts (if present) if data.has("active_rewards"): var active_rewards = data.get("active_rewards", []) if not active_rewards is Array: return {"valid": false, "reason": "'active_rewards' must be an array"} + var reward_err := _validate_active_rewards(active_rewards) + if not reward_err.is_empty(): + return {"valid": false, "reason": reward_err} + + # Validate 'active_goal' (if present) — must be {} or reference a known goal template + if data.has("active_goal"): + var goal_err := _validate_active_goal(data["active_goal"]) + if not goal_err.is_empty(): + return {"valid": false, "reason": goal_err} + + # Validate 'completed_goal_ids' (if present) — every entry must reference a known goal + if data.has("completed_goal_ids"): + var completed = data["completed_goal_ids"] + if not completed is Array: + return {"valid": false, "reason": "'completed_goal_ids' must be an array"} + var known_ids := _known_goal_ids() + for ci in range(completed.size()): + var entry = completed[ci] + if typeof(entry) != TYPE_STRING: + return {"valid": false, "reason": "completed_goal_ids[%d] must be a string" % ci} + if not known_ids.has(entry): + return {"valid": false, "reason": "completed_goal_ids[%d]='%s' is not a known goal" % [ci, entry]} + + # Validate 'colony_stance' (if present) — must be one of the known stances or empty + if data.has("colony_stance"): + var stance_holder = data["colony_stance"] + if typeof(stance_holder) != TYPE_STRING: + return {"valid": false, "reason": "'colony_stance' must be a string"} + var stance := String(stance_holder) + if not stance.is_empty() and not _known_stances().has(stance): + return {"valid": false, "reason": "'colony_stance'='%s' is not a known stance" % stance} return {"valid": true, "reason": ""} +# ── Schema helpers ────────────────────────────────────────────────────────── + +# Compute valid tile counts from the LayoutMath anchor family configuration. +# Legacy grid sizes (25/36/64/100/150) are still accepted so historical saves +# remain loadable until they are migrated. +func _expected_grid_sizes() -> Array: + var sizes: Array = [] + # Mirror the canonical anchor families in layout_math.gd; new anchors added + # there will need to be appended here as well. + var anchors: Array = ["bottom", "side"] + for anchor in anchors: + var dims = LayoutMath.grid_dims_for_anchor(anchor) + if typeof(dims) != TYPE_DICTIONARY or dims.is_empty(): + continue + var w: int = int(dims.get("grid_w", 0)) + var h: int = int(dims.get("grid_h", 0)) + if w <= 0 or h <= 0: + continue + var total: int = w * h + if not sizes.has(total): + sizes.append(total) + # Sanity check: make sure both LayoutMath-published constants are covered. + var bottom_size: int = int(LayoutMath.BOTTOM_GRID_W) * int(LayoutMath.BOTTOM_GRID_H) + var side_size: int = int(LayoutMath.SIDE_GRID_W) * int(LayoutMath.SIDE_GRID_H) + for raw in [bottom_size, side_size]: + if raw > 0 and not sizes.has(raw): + sizes.append(raw) + for legacy in _LEGACY_GRID_SIZES: + if not sizes.has(legacy): + sizes.append(legacy) + return sizes + +# Set of goal identifiers drawn from RotatingGoal's catalog. Anything outside +# this set cannot be loaded as a completed/active goal. +func _known_goal_ids() -> Array: + var ids: Array = [] + for template in RotatingGoal.GOAL_CATALOG: + if typeof(template) == TYPE_DICTIONARY and template.has("id"): + var gid = String(template.get("id", "")) + if not gid.is_empty() and not ids.has(gid): + ids.append(gid) + return ids + +# Set of reward keys granted by GoalReward.apply_reward. Used to validate the +# shape of each entry in 'active_rewards'. +func _known_reward_keys() -> Array: + return ["type", "remaining", "duration", "label", "trickle_ticks", "resource"] + +# Set of recognized reward types (union of REWARD_* constants) used to +# validate the 'type' field on each active_rewards entry. +func _known_reward_types() -> Array: + var types: Array = [] + for entry in GoalReward.REWARD_CATALOG.values(): + if typeof(entry) == TYPE_DICTIONARY and entry.has("type"): + var t = String(entry["type"]) + if not t.is_empty() and not types.has(t): + types.append(t) + # Belt-and-suspenders: include the REWARD_* constants declared above the + # catalog in case the catalog is empty/duplicated. + for raw in [ + GoalReward.REWARD_RESOURCE_TRICKLE, + GoalReward.REWARD_GATHER_SPEED, + GoalReward.REWARD_HAUL_SPEED, + GoalReward.REWARD_BUILD_SPEED, + GoalReward.REWARD_AMBIENT_IMPROVE, + GoalReward.REWARD_RECRUIT_DISCOUNT, + ]: + if typeof(raw) == TYPE_STRING and not raw.is_empty() and not types.has(raw): + types.append(raw) + return types + +# Set of recognized colony stances exported by ColonyStance. +func _known_stances() -> Array: + var stances: Array = [] + for s in ColonyStance.ALL_STANCES: + if typeof(s) == TYPE_STRING and not stances.has(s): + stances.append(s) + return stances + +# Validate an active_goal value. Returns empty string when valid, or a reason. +func _validate_active_goal(value) -> String: + # No active goal is encoded as an empty Dictionary by the runtime. + if typeof(value) == TYPE_DICTIONARY: + if value.is_empty(): + return "" + if not value.has("id"): + return "'active_goal' dictionary missing key 'id'" + var gid = String(value.get("id", "")) + if not _known_goal_ids().has(gid): + return "'active_goal.id'='%s' is not a known goal" % gid + return "" + return "'active_goal' must be a dictionary" + +# Validate the contents of an active_rewards array. Returns empty string on +# success, or a human-readable reason for the first failure. +func _validate_active_rewards(rewards: Array) -> String: + var known_keys := _known_reward_keys() + var known_types := _known_reward_types() + for ri in range(rewards.size()): + var r = rewards[ri] + if typeof(r) != TYPE_DICTIONARY: + return "active_rewards[%d] must be a dictionary" % ri + if not r.has("type"): + return "active_rewards[%d] missing key 'type'" % ri + var rtype = String(r.get("type", "")) + if rtype.is_empty(): + return "active_rewards[%d].type must be non-empty" % ri + if not known_types.has(rtype): + return "active_rewards[%d].type='%s' is not a known reward type" % [ri, rtype] + for key in known_keys: + if not r.has(key): + continue + var v = r[key] + if key in ["type", "label", "resource"] and typeof(v) != TYPE_STRING: + return "active_rewards[%d].%s must be a string" % [ri, key] + if key in ["remaining", "duration", "trickle_ticks"]: + if typeof(v) != TYPE_INT and typeof(v) != TYPE_FLOAT: + return "active_rewards[%d].%s must be numeric" % [ri, key] + if float(v) < 0: + return "active_rewards[%d].%s must be non-negative" % [ri, key] + return "" + func migrate_save(data: Dictionary) -> Dictionary: # Missing version key means "current" — backward compatible if not data.has("save_version"): diff --git a/tests/test_save_backup.gd b/tests/test_save_backup.gd index 9d1f961..8e3e999 100644 --- a/tests/test_save_backup.gd +++ b/tests/test_save_backup.gd @@ -10,6 +10,8 @@ extends SceneTree # 4. validate_save_schema rejects invalid worker/build/task shapes # 5. validate_save_schema accepts valid worker/build/task shapes # 6. Numeric bounds: negative break_ticks rejected, missing keys caught +# 7. Grid sizing: tile count must match grid_w*grid_h (issue #236) +# 8. active_goal/completed_goal_ids/colony_stance/active_rewards validation # ============================================================================= var tests_run := 0 @@ -189,7 +191,16 @@ func flow_validation_accepts_valid() -> void: "workers": [ {"name": "Jun", "pos": {"x": 11, "y": 2}, "prev_pos": {"x": 11, "y": 2}, "carrying": {"wood": 2}, "task": {"kind": "haul"}, "break_ticks": 0}, ], - "tiles": [{"kind": "tree", "amount": 6, "resource": "wood", "build_kind": ""}], + # Match the LayoutMath-derived default grid (32x5=160 tiles) and supply + # the corresponding grid_w/grid_h so internal-consistency checks accept. + "tiles": [], + "grid_w": 32, + "grid_h": 5, + "anchor_family": "bottom", + "active_goal": {}, + "completed_goal_ids": [], + "colony_stance": "balanced", + "active_rewards": [], "builds": [ {"id": 1, "kind": "hut", "pos": {"x": 3, "y": 2}, "complete": false, "delivered": {}, "progress": 0.5}, ], @@ -198,6 +209,142 @@ func flow_validation_accepts_valid() -> void: var result := gs.validate_save_schema(good_state) _assert(result.valid, "accepts valid worker/build/task shapes") + # A side anchor (10x24=240) should also be accepted. + var side_state := good_state.duplicate() + side_state["grid_w"] = 10 + side_state["grid_h"] = 24 + side_state["anchor_family"] = "side" + # Pad tiles so we exercise the size-matching path rather than the + # tile-empty shortcut above. + var side_tiles: Array = [] + for _i in range(240): + side_tiles.append({"kind": "tree", "amount": 0, "resource": "wood", "build_kind": ""}) + side_state["tiles"] = side_tiles + result = gs.validate_save_schema(side_state) + _assert(result.valid, "accepts side anchor with matching tile count") + +# --------------------------------------------------------------------------- +# Flow 6: grid sizing consistency (issue #236) +# --------------------------------------------------------------------------- + +func flow_grid_sizing_consistency() -> void: + print("\n=== Flow 6: grid sizing consistency ===") + var gs := load_game_state() + + var base := _minimal_valid_state() + + # Tile count does not match grid_w * grid_h + base["grid_w"] = 32 + base["grid_h"] = 5 + # 158 tiles (not 160) + var wrong_grid_tiles: Array = [] + for _i in range(158): + wrong_grid_tiles.append({"kind": "tree", "amount": 0, "resource": "wood", "build_kind": ""}) + base["tiles"] = wrong_grid_tiles + _assert(not gs.validate_save_schema(base).valid, + "rejects tile count that does not equal grid_w*grid_h") + + # Hardcoded grid sizes that did not come from LayoutMath should be rejected + # even when grid_w/grid_h are absent (no fallback to legacy after migration). + var odd_sizes := _minimal_valid_state() + odd_sizes["tiles"] = [{"kind": "tree", "amount": 1, "resource": "wood", "build_kind": ""}] + _assert(not gs.validate_save_schema(odd_sizes).valid, + "rejects a tile count (1) outside LayoutMath-derived grid sizes") + + # A known LayoutMath size (160) should be accepted without grid_w/grid_h. + var sized := _minimal_valid_state() + var t160: Array = [] + for _i in range(160): + t160.append({"kind": "tree", "amount": 0, "resource": "wood", "build_kind": ""}) + sized["tiles"] = t160 + _assert(gs.validate_save_schema(sized).valid, + "accepts 160 tiles (LayoutMath bottom anchor) without grid dims") + + # Negative or zero grid dims are rejected + var bad_dims := _minimal_valid_state() + bad_dims["grid_w"] = 0 + bad_dims["grid_h"] = 5 + _assert(not gs.validate_save_schema(bad_dims).valid, + "rejects zero grid_w") + +# --------------------------------------------------------------------------- +# Flow 7: active_goal / completed_goal_ids / colony_stance / active_rewards +# --------------------------------------------------------------------------- + +func flow_goal_and_stance_validation() -> void: + print("\n=== Flow 7: goal/stance/reward validation ===") + var gs := load_game_state() + + # active_goal referencing an unknown id is rejected + var bad_goal := _minimal_valid_state() + bad_goal["active_goal"] = { + "id": "no_such_goal", "type": "resource", + "target": {"resource": "wood", "amount": 5}, + "current_progress": 0, "completed": false, + } + _assert(not gs.validate_save_schema(bad_goal).valid, + "rejects active_goal with unknown id") + + # active_goal empty dict is valid; a known goal is valid + var known_goal := _minimal_valid_state() + known_goal["active_goal"] = { + "id": "gather_wood", "type": "resource", + "target": {"resource": "wood", "amount": 5}, + "current_progress": 0, "completed": false, + } + _assert(gs.validate_save_schema(known_goal).valid, + "accepts active_goal with known goal id") + + # Unknown completed_goal_id is rejected + var bad_completed := _minimal_valid_state() + bad_completed["completed_goal_ids"] = ["gather_wood", "made_up_goal"] + _assert(not gs.validate_save_schema(bad_completed).valid, + "rejects completed_goal_ids with unknown entry") + + # Invalid colony_stance is rejected + var bad_stance := _minimal_valid_state() + bad_stance["colony_stance"] = "hyper_aggressive" + _assert(not gs.validate_save_schema(bad_stance).valid, + "rejects unknown colony_stance") + # Empty stance is OK (off = no override) + var off_stance := _minimal_valid_state() + off_stance["colony_stance"] = "" + _assert(gs.validate_save_schema(off_stance).valid, + "accepts empty colony_stance") + + # active_rewards entries must be valid reward dictionaries + var bad_reward := _minimal_valid_state() + bad_reward["active_rewards"] = [{"type": "not_a_real_reward"}] + _assert(not gs.validate_save_schema(bad_reward).valid, + "rejects active_rewards with unknown reward type") + + # Non-dict reward entry is rejected + var non_dict_reward := _minimal_valid_state() + non_dict_reward["active_rewards"] = ["gather_speed"] + _assert(not gs.validate_save_schema(non_dict_reward).valid, + "rejects active_rewards entry that isn't a dictionary") + + # Completed with only known ids is accepted + var good_completed := _minimal_valid_state() + good_completed["completed_goal_ids"] = ["gather_wood", "build_hut"] + _assert(gs.validate_save_schema(good_completed).valid, + "accepts completed_goal_ids containing only known goals") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +func _minimal_valid_state() -> Dictionary: + return { + "tick": 0, + "resources": {"wood": 0, "stone": 0, "food": 0}, + "workers": [], + "tiles": [], + "builds": [], + "events": [], + "save_version": 2, + } + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -212,6 +359,8 @@ func _initialize() -> void: flow_list_backups_sorted() flow_validation_rejects_invalid() flow_validation_accepts_valid() + flow_grid_sizing_consistency() + flow_goal_and_stance_validation() print("\n===========================================") print(" Results: %d/%d passed, %d failed" % [tests_passed, tests_run, tests_failed])