diff --git a/scripts/game_state.gd b/scripts/game_state.gd index 31cd386..3a4a609 100644 --- a/scripts/game_state.gd +++ b/scripts/game_state.gd @@ -17,16 +17,19 @@ func _ready() -> void: if OS.has_feature("web"): use_local_storage = JavaScriptBridge.eval("typeof localStorage !== 'undefined'", true) -func save_game(data: Dictionary) -> void: +func save_game(data: Dictionary, path: String = "") -> void: + var target_path := path if not path.is_empty() else SAVE_PATH var payload := JSON.stringify(data) if use_local_storage: JavaScriptBridge.eval("localStorage.setItem('%s', %s)" % [SAVE_KEY, JSON.stringify(payload)], true) return - var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + var file := FileAccess.open(target_path, FileAccess.WRITE) if file: file.store_string(payload) + file.close() -func load_game() -> Dictionary: +func load_game(path: String = "") -> Dictionary: + var target_path := path if not path.is_empty() else SAVE_PATH if use_local_storage: var raw = JavaScriptBridge.eval("localStorage.getItem('%s')" % SAVE_KEY, true) if raw == null or String(raw).is_empty() or String(raw) == "null": @@ -34,10 +37,12 @@ func load_game() -> Dictionary: var parsed = JSON.parse_string(String(raw)) if typeof(parsed) == TYPE_STRING: return JSON.parse_string(parsed) if JSON.parse_string(parsed) is Dictionary else {} - return parsed if parsed is Dictionary else {} - if not FileAccess.file_exists(SAVE_PATH): + if parsed is Dictionary and not parsed.is_empty(): + rebuild_reservations_from_workers(parsed) + return parsed + if not FileAccess.file_exists(target_path): return {} - var file := FileAccess.open(SAVE_PATH, FileAccess.READ) + var file := FileAccess.open(target_path, FileAccess.READ) if not file: return {} var text := file.get_as_text() @@ -53,7 +58,31 @@ func load_game() -> Dictionary: print("SAVE_SCHEMA_VALIDATION_ERROR: %s" % validation_result.reason) return {} - return migrate_save(parsed) + var migrated := migrate_save(parsed) + if not migrated.is_empty(): + rebuild_reservations_from_workers(migrated) + return migrated + +# ── Rebuild reserved_resources from active worker tasks ────────────────────── +# Called after load/migration to prevent double-booking when reservations are +# missing or stale. Only rebuilds when the field is empty (missing from old saves). + +func rebuild_reservations_from_workers(state: Dictionary) -> void: + var existing: Dictionary = state.get("reserved_resources", {}) + if not existing.is_empty(): + return # Already has reservations — trust them + + state["reserved_resources"] = {} + var workers: Array = state.get("workers", []) + for worker in workers: + var task: Dictionary = worker.get("task", {}) + if task.is_empty(): + continue + var kind: String = task.get("kind", "") + if kind == "gather" or kind == "haul": + var resource: String = task.get("resource", "") + if not resource.is_empty(): + state["reserved_resources"][resource] = state["reserved_resources"].get(resource, 0) + 1 # ── Schema validation ──────────────────────────────────────────────────────── # Returns {valid: bool, reason: String} diff --git a/scripts/main.gd b/scripts/main.gd index 10d77a3..ddf1837 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -716,6 +716,7 @@ func load_or_boot() -> void: for worker in state.get("workers", []): if not worker.has("break_ticks"): worker.break_ticks = 0 + rebuild_reservations() apply_priority_order() apply_orientation_lock_ui() @@ -905,6 +906,7 @@ func load_saved_game() -> void: for worker in state.get("workers", []): if not worker.has("break_ticks"): worker.break_ticks = 0 + rebuild_reservations() # Restore active goal state and completed IDs from save if state.has("active_goal") and not state["active_goal"].is_empty(): active_goal = state["active_goal"] @@ -2501,6 +2503,22 @@ func get_reserved(resource: String) -> int: return 0 return int(state.reserved_resources.get(resource, 0)) +# ── Rebuild reserved_resources from active worker tasks ────────────────────── +# Called after load to prevent double-booking when reservations are missing or stale. + +func rebuild_reservations() -> void: + state["reserved_resources"] = {} + var workers: Array = state.get("workers", []) + for worker in workers: + var task: Dictionary = worker.get("task", {}) + if task.is_empty(): + continue + var kind: String = task.get("kind", "") + if kind == "gather" or kind == "haul": + var resource: String = task.get("resource", "") + if not resource.is_empty(): + state["reserved_resources"][resource] = state["reserved_resources"].get(resource, 0) + 1 + # ── Worker intent icons and text (issue #136) ──────────────────────────────── diff --git a/tests/test_reservations.gd b/tests/test_reservations.gd index b43d245..22ff7d1 100644 --- a/tests/test_reservations.gd +++ b/tests/test_reservations.gd @@ -26,6 +26,8 @@ func _initialize() -> void: test_stale_reservations_cleaned_up(gs) test_two_workers_one_need_only_one_succeeds(gs) test_reserve_field_added_to_new_builds(gs) + test_reserved_resources_save_load(gs) + test_reserved_resources_resync_on_load(gs) print("") print("=== reservation tests: %d passed, %d failed ===" % [test_pass, test_fail]) @@ -433,3 +435,81 @@ func test_reserve_field_added_to_new_builds(gs: Node) -> void: var loaded_build = loaded.get("builds", [{}])[0] _assert_has(loaded_build, "reserved", "persisted_build: reserved field preserved") _assert_eq(int(loaded_build.reserved.get("wood", -1)), 0, "persisted_build: reserved.wood = 0") + +func test_reserved_resources_save_load(gs: Node) -> void: + print("") + print("--- reservation: reserved_resources survives save/load ---") + + var state := { + "tick": 50, + "resources": {"wood": 10, "stone": 8, "food": 3}, + "harvested": {"wood": 0, "stone": 0, "food": 0}, + "priority_order": ["build", "haul", "gather"], + "workers": [ + { + "name": "Alice", + "pos": {"x": 1, "y": 1}, + "carrying": {}, + "task": {"kind": "gather", "resource": "wood"}, + "break_ticks": 0, + }, + { + "name": "Bob", + "pos": {"x": 2, "y": 1}, + "carrying": {}, + "task": {"kind": "haul", "resource": "stone"}, + "break_ticks": 0, + }, + ], + "tiles": [], + "builds": [], + "next_build_id": 1, + "events": [], + "save_version": 2, + "reserved_resources": {"wood": 2, "stone": 1}, + } + + gs.save_game(state) + var loaded = gs.load_game() + var reserved: Dictionary = loaded.get("reserved_resources", {}) + _assert_eq(int(reserved.get("wood", -1)), 2, "saved wood reservation persists") + _assert_eq(int(reserved.get("stone", -1)), 1, "saved stone reservation persists") + +func test_reserved_resources_resync_on_load(gs: Node) -> void: + print("") + print("--- reservation: reserved_resources resynced from workers on load ---") + + var state := { + "tick": 60, + "resources": {"wood": 10, "stone": 8, "food": 3}, + "harvested": {"wood": 0, "stone": 0, "food": 0}, + "priority_order": ["build", "haul", "gather"], + "workers": [ + { + "name": "Alice", + "pos": {"x": 1, "y": 1}, + "carrying": {}, + "task": {"kind": "gather", "resource": "wood"}, + "break_ticks": 0, + }, + { + "name": "Bob", + "pos": {"x": 2, "y": 1}, + "carrying": {}, + "task": {"kind": "haul", "resource": "stone"}, + "break_ticks": 0, + }, + ], + "tiles": [], + "builds": [], + "next_build_id": 1, + "events": [], + "save_version": 2, + "reserved_resources": {}, + } + + gs.save_game(state) + var loaded = gs.load_game() + var reserved: Dictionary = loaded.get("reserved_resources", {}) + _assert_eq(int(reserved.get("wood", -1)), 1, "wood reservation rebuilt from gather worker") + _assert_eq(int(reserved.get("stone", -1)), 1, "stone reservation rebuilt from haul worker")