diff --git a/scripts/colony_sim.gd b/scripts/colony_sim.gd new file mode 100644 index 0000000..cd3c940 --- /dev/null +++ b/scripts/colony_sim.gd @@ -0,0 +1,646 @@ +## Scene-free colony simulation — owns the game-state dictionary and all +## worker/task/economy logic so the sim can run and be tested without UI nodes. +## main.gd holds one instance, proxies its own legacy members onto it, and +## renders whatever this class mutates. See the altitude findings in the +## /simplify review and misospace/windowstead#177 for the extraction lineage. +class_name ColonySim +extends RefCounted + +const Constants := preload("res://scripts/constants.gd") +const LayoutMath := preload("res://scripts/layout_math.gd") +const ColonyStance := preload("res://scripts/colony_stance.gd") +const RotatingGoal := preload("res://scripts/rotating_goal.gd") +const GoalProgression := preload("res://scripts/goal_progression.gd") +const GoalReward := preload("res://scripts/goal_reward.gd") +const MilestoneManager := preload("res://scripts/milestone_manager.gd") +const WorkerCapLogic := preload("res://scripts/worker_cap_logic.gd") + +# Tile kinds a worker can gather from. +const GATHERABLE_KINDS := ["tree", "rock", "berries"] +# Food granted when a structure of each kind completes. +const BUILD_COMPLETION_FOOD := {"hut": 1, "garden": 3} + +## The single source of truth for all persisted colony data. +var state: Dictionary = {} +var grid_w: int = LayoutMath.BOTTOM_GRID_W +var grid_h: int = LayoutMath.BOTTOM_GRID_H +var stockpile_pos: Vector2i = LayoutMath.stockpile_pos_for_anchor("bottom") +var priority_order: Array[String] = ["build", "haul", "gather"] +var rng := RandomNumberGenerator.new() +## Set on any state mutation; cleared by the owner when it persists. +var dirty := false +## Incremented on every event push so renderers can skip unchanged logs. +var event_rev := 0 + + +func mark_dirty() -> void: + dirty = true + + +func tick() -> int: + return int(state.get("tick", 0)) + + +func colony_stance() -> String: + return String(state.get("colony_stance", ColonyStance.STANCE_BALANCED)) + + +## Fill in any state keys missing from older saves or fresh bootstraps so the +## rest of the sim can mutate them without existence checks. +func ensure_defaults() -> void: + if not state.has("reserved_resources"): + state["reserved_resources"] = {} + if not state.has("events"): + state["events"] = [] + if not state.has("completed_goal_ids"): + state["completed_goal_ids"] = [] + if not state.has("active_rewards"): + state["active_rewards"] = [] + if not state.has("active_goal"): + state["active_goal"] = GoalProgression.init_goals(state["completed_goal_ids"]) + if not state.has("colony_stance"): + state["colony_stance"] = ColonyStance.STANCE_BALANCED + var milestone_seed: Dictionary = MilestoneManager.make_goal_state() + if not state.has("current_milestone_id"): + state["current_milestone_id"] = milestone_seed["milestone_id"] + if not state.has("completed_milestone_ids"): + state["completed_milestone_ids"] = milestone_seed["completed_ids"] + for worker in state.get("workers", []): + if not worker.has("break_ticks"): + worker["break_ticks"] = 0 + # The event list may have been swapped wholesale (load/bootstrap); force + # rev-gated renderers to refresh. + event_rev += 1 + + +# ── Events ──────────────────────────────────────────────────────────────────── + +func push_event(text: String) -> void: + if not state.has("events"): + return + state.events.push_front({"tick": tick(), "text": text}) + while state.events.size() > Constants.MAX_EVENT_LOG: + state.events.pop_back() + event_rev += 1 + mark_dirty() + + +# ── Tick orchestration ──────────────────────────────────────────────────────── + +func process_tick() -> void: + state["tick"] = tick() + 1 + maybe_fire_event() + _clean_stale_reservations() + + # Food upkeep (issue #147) + if tick() % Constants.FOOD_UPKEEP_INTERVAL_TICKS == 0: + apply_food_upkeep() + + for worker in state.workers: + worker.prev_pos = worker.get("pos", vec_to_data(stockpile_pos)) + if int(worker.get("break_ticks", 0)) > 0: + worker.break_ticks = int(worker.break_ticks) - 1 + if int(worker.break_ticks) <= 0: + push_event("%s is back from a dramatic five-second break." % worker.name) + continue + if worker.task.is_empty(): + worker.task = choose_task(worker) + if not worker.task.is_empty(): + step_worker(worker) + + _process_goals() + _process_milestones() + + +func _process_goals() -> void: + var active_goal: Dictionary = state.get("active_goal", {}) + var completed_ids: Array = state.get("completed_goal_ids", []) + var result: Dictionary = GoalProgression.process_tick(active_goal, completed_ids, state) + state["active_goal"] = result["active_goal"] + state["completed_goal_ids"] = result["completed_ids"] + if result["was_completed"]: + push_event("Goal completed: %s. The colony moves on." % result["goal_id"]) + var new_reward: Dictionary = GoalReward.apply_reward(result["goal_id"]) + if not new_reward.is_empty(): + state["active_rewards"].append(new_reward) + push_event("Reward: %s" % new_reward["label"]) + mark_dirty() + # Tick active rewards (expiration + trickle payouts) + var reward_result: Dictionary = GoalReward.tick_rewards(state.get("active_rewards", []), state) + state["active_rewards"] = reward_result["new_rewards"] + for evt in reward_result["events"]: + push_event(evt) + for expired_label in reward_result["expired"]: + push_event("Reward ended: %s" % expired_label) + + +func _process_milestones() -> void: + # Milestone evaluation (issue #237) + var current_id: String = String(state.get("current_milestone_id", "")) + var active_milestone: Dictionary = MilestoneManager.get_current_milestone(MilestoneManager.MILESTONE_CATALOG, current_id) + if active_milestone.is_empty() or not MilestoneManager.is_milestone_complete(active_milestone, state): + return + state["completed_milestone_ids"].append(current_id) + push_event("Milestone reached: %s" % MilestoneManager.milestone_description(active_milestone)) + state["current_milestone_id"] = MilestoneManager.advance_to_next(state["completed_milestone_ids"], current_id) + mark_dirty() + + +# ── Ambient events ──────────────────────────────────────────────────────────── + +func maybe_fire_event() -> void: + if tick() % Constants.EVENT_INTERVAL_TICKS != 0: + return + var event_roll := rng.randi_range(0, 2) + # If ambient_improve reward is active, convert negative events to positive + if event_roll == 1 and GoalReward.consume_ambient_improve(state.get("active_rewards", [])): + event_roll = 0 + push_event("A goal reward smooths things over.") + match event_roll: + 0: + state.resources.food = int(state.resources.get("food", 0)) + 2 + push_event("A neighbor drops off trail mix. Food +2.") + 1: + var worker: Dictionary = state.workers[rng.randi_range(0, state.workers.size() - 1)] + worker.task = {} + worker.break_ticks = 6 + push_event("%s takes a break and stares into the middle distance." % worker.name) + 2: + spawn_resource_drop() + + +func spawn_resource_drop() -> void: + var pos := find_open_ground() + if pos == Vector2i(-1, -1): + push_event("A supply crate tried to arrive but urban planning won.") + return + var drops := { + "tree": {"amount": 4, "resource": "wood", "message": "A driftwood bundle lands nearby. Fresh wood appeared."}, + "rock": {"amount": 4, "resource": "stone", "message": "A rubble drop lands nearby. Fresh stone appeared."}, + "berries": {"amount": 3, "resource": "food", "message": "A snack crate lands nearby. Fresh food appeared."}, + } + var resource_kind: String = GATHERABLE_KINDS[rng.randi_range(0, GATHERABLE_KINDS.size() - 1)] + var drop: Dictionary = drops[resource_kind] + set_tile(pos, {"kind": resource_kind, "amount": drop.amount, "resource": drop.resource, "build_kind": ""}) + push_event(drop.message) + + +# ── Food upkeep (issue #147, links to #133) ─────────────────────────────────── + +func get_extra_workers_count() -> int: + if not state.has("workers"): + return 0 + var total: int = state.workers.size() + return maxi(total - Constants.BASE_WORKERS_NO_UPKEEP, 0) + + +func apply_food_upkeep() -> void: + if not state.has("workers"): + return + var extra := get_extra_workers_count() + if extra <= 0: + return + var food_cost := extra * Constants.FOOD_PER_EXTRA_WORKER + var current_food := int(state.resources.get("food", 0)) + var new_food := maxi(current_food - food_cost, 0) + if new_food < current_food: + state.resources["food"] = new_food + push_event("The crew ate. Food -%d." % (current_food - new_food)) + mark_dirty() + + +func get_food_slowdown_factor() -> float: + var food := int(state.resources.get("food", 0)) + if food <= Constants.STARVATION_FOOD_THRESHOLD: + return Constants.STARVATION_SPEED_FACTOR + if food <= Constants.LOW_FOOD_THRESHOLD: + # Linear interpolation between starvation and low-food threshold + var range_size := float(Constants.LOW_FOOD_THRESHOLD - Constants.STARVATION_FOOD_THRESHOLD) + if range_size == 0: + return Constants.LOW_FOOD_SPEED_FACTOR + var progress := float(food - Constants.STARVATION_FOOD_THRESHOLD) / range_size + return lerp(Constants.STARVATION_SPEED_FACTOR, Constants.LOW_FOOD_SPEED_FACTOR, progress) + return 1.0 + + +func get_low_food_level() -> String: + var food := int(state.resources.get("food", 0)) + if food <= Constants.STARVATION_FOOD_THRESHOLD: + return "starving" + if food <= Constants.LOW_FOOD_THRESHOLD: + return "low" + return "ok" + + +func should_bias_to_food_gathering() -> bool: + var level := get_low_food_level() + return level == "low" or level == "starving" + + +# ── Worker cap and recruiting (issue #149) ──────────────────────────────────── + +func get_worker_cap() -> int: + return WorkerCapLogic.calculate_worker_cap(state.get("builds", [])) + + +func can_recruit_worker() -> bool: + return WorkerCapLogic.can_recruit(state.get("builds", []), state.get("workers", [])) + + +func recruit_worker() -> void: + if not can_recruit_worker(): + push_event("Not enough housing for another worker. Build more huts.") + return + + # Pick the next available name from WORKER_NAMES (cycle through) + var current: int = state.workers.size() + var next_index: int = current % Constants.WORKER_NAMES.size() + var new_worker := { + "name": Constants.WORKER_NAMES[next_index], + "task": {"kind": "", "data": {}}, + "carrying": {}, + "break_ticks": 0, + "spawn_tick": tick(), + } + state["workers"].append(new_worker) + + # Apply recruit discount reward if active (gives +1 food) + if GoalReward.consume_recruit_discount(state.get("active_rewards", [])): + state.resources["food"] = int(state.resources.get("food", 0)) + 1 + mark_dirty() + + var extra := get_extra_workers_count() + if extra > 0: + var food_cost := extra * Constants.FOOD_PER_EXTRA_WORKER + push_event("New crew member %s joins! Food impact: +%d per cycle." % [new_worker.name, food_cost]) + else: + push_event("New crew member %s joins the tiny colony." % new_worker.name) + + +# ── Task selection ──────────────────────────────────────────────────────────── + +func choose_task(worker: Dictionary) -> Dictionary: + var effective_order := ColonyStance.get_effective_priority_order(colony_stance(), priority_order) + for kind in effective_order: + var tasks: Array[Dictionary] = tasks_for_kind(String(kind)) + if tasks.is_empty(): + continue + # Food stance and low-food bias (issue #147) both sort food tasks first. + var prefer_food: bool = String(kind) == "gather_food" \ + or (String(kind) == "gather" and should_bias_to_food_gathering()) + tasks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if prefer_food: + var a_is_food := ColonyStance.is_food_gather_task(a) + var b_is_food := ColonyStance.is_food_gather_task(b) + if a_is_food != b_is_food: + return a_is_food + return task_distance(worker, a) < task_distance(worker, b) + ) + var chosen := tasks[0] + if (String(chosen.kind) == "gather" or String(chosen.kind) == "gather_food") and chosen.has("resource"): + reserve_resource(String(chosen.resource)) + return chosen + return {} + + +func tasks_for_kind(kind: String) -> Array[Dictionary]: + match kind: + "build": + return gather_build_tasks() + "haul": + return gather_haul_tasks() + "gather", "gather_food": + return gather_gather_tasks() + return [] + + +func task_distance(worker: Dictionary, task: Dictionary) -> int: + var pos := data_to_vec(worker.pos) + var target := data_to_vec(task.target) + return abs(pos.x - target.x) + abs(pos.y - target.y) + + +func gather_build_tasks() -> Array[Dictionary]: + var tasks: Array[Dictionary] = [] + for build in state.builds: + if not bool(build.complete) and has_costs_delivered(build): + tasks.append({"kind": "build", "build_id": int(build.id), "target": build.pos}) + return tasks + + +func gather_haul_tasks() -> Array[Dictionary]: + var tasks: Array[Dictionary] = [] + for build in state.builds: + if bool(build.complete): + continue + for resource in Constants.BUILD_COSTS[String(build.kind)].keys(): + var reserved := int(build.get("reserved", {}).get(resource, 0)) + var need := int(Constants.BUILD_COSTS[String(build.kind)][resource]) - int(build.delivered.get(resource, 0)) - reserved + if need > 0 and int(state.resources.get(resource, 0)) > 0: + tasks.append({"kind": "haul", "build_id": int(build.id), "target": vec_to_data(stockpile_pos), "resource": resource}) + return tasks + + +func gather_gather_tasks() -> Array[Dictionary]: + # One pass to total each gatherable resource, so the reservation check + # below is a lookup instead of a per-tile grid rescan. + var totals := {} + for y in grid_h: + for x in grid_w: + var tile := get_tile(Vector2i(x, y)) + if GATHERABLE_KINDS.has(String(tile.kind)): + var resource := String(tile.resource) + totals[resource] = int(totals.get(resource, 0)) + int(tile.amount) + var tasks: Array[Dictionary] = [] + for y in grid_h: + for x in grid_w: + var pos := Vector2i(x, y) + var tile := get_tile(pos) + if GATHERABLE_KINDS.has(String(tile.kind)) and int(tile.amount) > 0: + # Skip if resource is fully reserved (reserved >= available anywhere) + var resource := String(tile.resource) + if get_reserved(resource) >= int(totals.get(resource, 0)): + continue + tasks.append({"kind": "gather", "target": vec_to_data(pos), "resource": tile.resource}) + return tasks + + +# ── Task execution ──────────────────────────────────────────────────────────── + +func step_worker(worker: Dictionary) -> void: + var task: Dictionary = worker.task + if task.is_empty(): + return + var target := data_to_vec(task.target) + if String(task.kind) == "haul" and int(worker.carrying.get(String(task.resource), 0)) > 0: + var build := get_build(int(task.build_id)) + if not build.is_empty(): + target = data_to_vec(build.pos) + var current := data_to_vec(worker.pos) + if current != target: + worker.pos = vec_to_data(step_toward(current, target)) + return + match String(task.kind): + "gather": do_gather(worker, task) + "haul": do_haul(worker, task) + "build": do_build(worker, task) + + +func do_gather(worker: Dictionary, task: Dictionary) -> void: + var target := data_to_vec(task.target) + var tile := get_tile(target) + if int(tile.amount) <= 0: + worker.task = {} + release_resource(String(task.resource)) + return + tile.amount = int(tile.amount) - 1 + worker.carrying[String(tile.resource)] = int(worker.carrying.get(String(tile.resource), 0)) + 1 + state.harvested[String(task.resource)] = int(state.get("harvested", {}).get(String(task.resource), 0)) + 1 + if int(tile.amount) <= 0: + tile.kind = "ground" + tile.resource = "" + set_tile(target, tile) + mark_dirty() + # Release reservation — resource is now in worker's possession + release_resource(String(task.resource)) + worker.task = {"kind": "haul", "target": vec_to_data(stockpile_pos), "resource": task.resource, "build_id": -1} + + +func do_haul(worker: Dictionary, task: Dictionary) -> void: + var resource := String(task.resource) + var carried := int(worker.carrying.get(resource, 0)) + if carried > 0: + if int(task.build_id) >= 0: + var build := get_build(int(task.build_id)) + if not build.is_empty() and not bool(build.complete): + # Clamp delivery to remaining need (delivered + reserved already account for committed units) + var reserved := int(build.get("reserved", {}).get(resource, 0)) + var cost := int(Constants.BUILD_COSTS[String(build.kind)][resource]) + var total_needed := cost - int(build.delivered.get(resource, 0)) - reserved + var deliver := mini(carried, maxf(total_needed, 0)) + build.delivered[resource] = int(build.delivered.get(resource, 0)) + deliver + # Refund excess back to stockpile + var excess := carried - deliver + if excess > 0: + state.resources[resource] = int(state.resources.get(resource, 0)) + excess + mark_dirty() + # Release reservation for delivered amount + if deliver > 0: + reserved = maxf(reserved - deliver, 0) + build["reserved"] = build.get("reserved", {}) + build.reserved[resource] = reserved + set_build(int(task.build_id), build) + else: + state.resources[resource] = int(state.resources.get(resource, 0)) + carried + mark_dirty() + else: + state.resources[resource] = int(state.resources.get(resource, 0)) + carried + mark_dirty() + worker.carrying[resource] = 0 + worker.task = {} + return + if data_to_vec(worker.pos) == stockpile_pos and int(state.resources.get(resource, 0)) > 0 and int(task.build_id) >= 0: + var build := get_build(int(task.build_id)) + if not build.is_empty() and not bool(build.complete): + state.resources[resource] = int(state.resources.get(resource, 0)) - 1 + worker.carrying[resource] = 1 + # Reserve this unit for the build + var reserved := int(build.get("reserved", {}).get(resource, 0)) + if not build.has("reserved"): + build["reserved"] = {} + build.reserved[resource] = reserved + 1 + mark_dirty() + set_build(int(task.build_id), build) + worker.task.target = build.pos + else: + # Build gone or complete — clear task, resource stays in stockpile + worker.task = {} + return + worker.task = {} + + +func do_build(worker: Dictionary, task: Dictionary) -> void: + var build := get_build(int(task.build_id)) + if build.is_empty() or bool(build.complete): + worker.task = {} + return + build.progress = float(build.progress) + structure_build_speed(String(build.kind)) + if float(build.progress) >= 1.0: + build.complete = true + set_tile(data_to_vec(build.pos), {"kind": build.kind, "amount": 0, "resource": "", "build_kind": ""}) + apply_structure_bonus(String(build.kind)) + push_event("%s finished. The colony looks slightly more legitimate." % String(build.kind).capitalize()) + set_build(int(task.build_id), build) + mark_dirty() + worker.task = {} + + +# ── Structures ──────────────────────────────────────────────────────────────── + +func apply_structure_bonus(kind: String) -> void: + var bonus := int(BUILD_COMPLETION_FOOD.get(kind, 0)) + if bonus > 0: + state.resources.food = int(state.resources.get("food", 0)) + bonus + mark_dirty() + + +func structure_build_speed(kind: String) -> float: + var speed := 0.34 + if kind != "workshop" and is_structure_complete("workshop"): + speed += 0.16 + # Apply food-based slowdown (issue #147) + speed *= get_food_slowdown_factor() + # Apply goal reward build speed bonus + speed += GoalReward.get_build_speed_bonus(state.get("active_rewards", [])) + return speed + + +func has_costs_delivered(build: Dictionary) -> bool: + for resource in Constants.BUILD_COSTS[String(build.kind)].keys(): + if int(build.delivered.get(resource, 0)) < int(Constants.BUILD_COSTS[String(build.kind)][resource]): + return false + return true + + +func is_structure_unlocked(kind: String) -> bool: + var unlock: Variant = Constants.BUILD_UNLOCKS.get(kind, true) + if typeof(unlock) == TYPE_BOOL and bool(unlock): + return true + return is_structure_complete(String(unlock)) + + +func is_structure_complete(kind: String) -> bool: + for build in state.builds: + if String(build.kind) == kind and bool(build.complete): + return true + return false + + +# ── Resource reservations (issue #122) ──────────────────────────────────────── + +func reserve_resource(resource: String, amount: int = 1) -> void: + if not state.has("reserved_resources"): + state["reserved_resources"] = {} + var current := int(state.reserved_resources.get(resource, 0)) + state.reserved_resources[resource] = current + amount + + +func release_resource(resource: String, amount: int = 1) -> void: + if not state.has("reserved_resources"): + return + var current := maxi(0, int(state.reserved_resources.get(resource, 0)) - amount) + state.reserved_resources[resource] = current + mark_dirty() + + +func get_reserved(resource: String) -> int: + if not state.has("reserved_resources"): + return 0 + return int(state.reserved_resources.get(resource, 0)) + + +## Rebuild reserved_resources from active worker tasks after a load. Clears +## first so GameState's rebuild (which trusts non-empty reservations) always runs. +func rebuild_reservations() -> void: + state["reserved_resources"] = {} + GameState.rebuild_reservations_from_workers(state) + + +func _clean_stale_reservations() -> void: + # Remove reservations from builds that have no active haul tasks targeting them. + # This handles: build completion, build deletion, worker break/cleanup. + var hauled_build_ids := {} + for worker in state.workers: + if not worker.task.is_empty() and String(worker.task.kind) == "haul": + hauled_build_ids[int(worker.task.get("build_id", -1))] = true + for build in state.builds: + if bool(build.complete): + continue + if not hauled_build_ids.has(int(build.id)) and build.has("reserved"): + var reserved: Dictionary = build.reserved + for resource in reserved.keys(): + state.resources[resource] = int(state.resources.get(resource, 0)) + int(reserved[resource]) + build.erase("reserved") + mark_dirty() + + +# ── Grid and build accessors ────────────────────────────────────────────────── + +func get_tile(pos: Vector2i) -> Dictionary: + return state.tiles[pos.y * grid_w + pos.x] + + +func set_tile(pos: Vector2i, data: Dictionary) -> void: + state.tiles[pos.y * grid_w + pos.x] = data + mark_dirty() + + +func get_build(id: int) -> Dictionary: + for build in state.builds: + if int(build.id) == id: + return build + return {} + + +func get_build_at_pos(pos: Vector2i) -> Dictionary: + for build in state.builds: + if data_to_vec(build.pos) == pos and not bool(build.complete): + return build + return {} + + +func set_build(id: int, updated: Dictionary) -> void: + for i in state.builds.size(): + if int(state.builds[i].id) == id: + state.builds[i] = updated + return + + +func is_pos_in_bounds(pos: Vector2i) -> bool: + return pos.x >= 0 and pos.x < grid_w and pos.y >= 0 and pos.y < grid_h + + +func is_near_stockpile(pos: Vector2i) -> bool: + return abs(pos.x - stockpile_pos.x) + abs(pos.y - stockpile_pos.y) <= 1 + + +func find_open_ground() -> Vector2i: + for y in grid_h: + for x in grid_w: + var pos := Vector2i(x, y) + if is_near_stockpile(pos): + continue + if String(get_tile(pos).kind) == "ground": + return pos + return Vector2i(-1, -1) + + +func seed_tile(pos: Vector2i) -> Dictionary: + var key := int((pos.x * 13 + pos.y * 7 + pos.x * pos.y) % 14) + if key == 0 or key == 3: + return {"kind": "tree", "amount": 6, "resource": "wood", "build_kind": ""} + if key == 6 or key == 8: + return {"kind": "rock", "amount": 5, "resource": "stone", "build_kind": ""} + if key == 11: + return {"kind": "berries", "amount": 4, "resource": "food", "build_kind": ""} + return {"kind": "ground", "amount": 0, "resource": "", "build_kind": ""} + + +# ── Coordinate helpers ──────────────────────────────────────────────────────── + +static func data_to_vec(data: Variant) -> Vector2i: + if data is Dictionary: + return Vector2i(int(data.x), int(data.y)) + return Vector2i.ZERO + + +static func vec_to_data(pos: Vector2i) -> Dictionary: + return {"x": pos.x, "y": pos.y} + + +static func step_toward(from: Vector2i, to: Vector2i) -> Vector2i: + if from.x != to.x: + return Vector2i(from.x + signi(to.x - from.x), from.y) + if from.y != to.y: + return Vector2i(from.x, from.y + signi(to.y - from.y)) + return from diff --git a/scripts/colony_stance.gd b/scripts/colony_stance.gd index 6b8b24e..6bd6fe7 100644 --- a/scripts/colony_stance.gd +++ b/scripts/colony_stance.gd @@ -43,8 +43,9 @@ static func get_effective_priority_order(colony_stance: String, player_order: Ar if colony_stance == STANCE_FOOD: result.append("gather_food") - # Add the preferred kind if not already in player order - if not player_order.has(preferred): + # Add the preferred kind first if not already queued (the food stance has + # already queued "gather_food" above) + if not result.has(preferred): result.append(preferred) # Then follow the player's manual priority_order, skipping the preferred kind diff --git a/scripts/game_state.gd b/scripts/game_state.gd index 759022c..11713ea 100644 --- a/scripts/game_state.gd +++ b/scripts/game_state.gd @@ -45,7 +45,8 @@ func load_game(path: String = "") -> Dictionary: return {} 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 {} + var inner = JSON.parse_string(parsed) + return inner if inner is Dictionary else {} if parsed is Dictionary and not parsed.is_empty(): rebuild_reservations_from_workers(parsed) return parsed @@ -97,6 +98,9 @@ func rebuild_reservations_from_workers(state: Dictionary) -> void: # Returns {valid: bool, reason: String} # Only validates fields that are present; missing optional fields are allowed. +func _is_numeric(v: Variant) -> bool: + return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT + func validate_save_schema(data: Dictionary) -> Dictionary: # Validate 'save_version' if present — must be a known version if data.has("save_version"): @@ -110,8 +114,7 @@ func validate_save_schema(data: Dictionary) -> Dictionary: if not resources is Dictionary: return {"valid": false, "reason": "'resources' must be a dictionary"} for resource_name in resources: - var val = resources[resource_name] - if typeof(val) != TYPE_INT and typeof(val) != TYPE_FLOAT: + if not _is_numeric(resources[resource_name]): return {"valid": false, "reason": "'resources.%s' must be numeric" % resource_name} # Validate 'harvested' is a dictionary with numeric values (if present) @@ -120,10 +123,17 @@ func validate_save_schema(data: Dictionary) -> Dictionary: if not harvested is Dictionary: return {"valid": false, "reason": "'harvested' must be a dictionary"} for resource_name in harvested: - var val = harvested[resource_name] - if typeof(val) != TYPE_INT and typeof(val) != TYPE_FLOAT: + if not _is_numeric(harvested[resource_name]): return {"valid": false, "reason": "'harvested.%s' must be numeric" % resource_name} + # Validate declared grid dimensions whenever both are present — even for + # tile-less saves, so a bad grid_w/grid_h can't slip through (issue #280). + if data.has("grid_w") and data.has("grid_h"): + if typeof(data["grid_w"]) != TYPE_INT or typeof(data["grid_h"]) != TYPE_INT: + return {"valid": false, "reason": "'grid_w' and 'grid_h' must be integers"} + if int(data["grid_w"]) <= 0 or int(data["grid_h"]) <= 0: + return {"valid": false, "reason": "'grid_w' and 'grid_h' must be positive"} + # Validate 'tiles' is an array (if present) if data.has("tiles"): var tiles = data.get("tiles", []) @@ -140,18 +150,11 @@ func validate_save_schema(data: Dictionary) -> Dictionary: 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). + # anchor family declared in the save (when both fields are present; + # dimensions were type/positivity-checked above). 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]} + if tile_count != int(data["grid_w"]) * int(data["grid_h"]): + return {"valid": false, "reason": "'tiles' count %d does not match grid_w*grid_h=%d" % [tile_count, int(data["grid_w"]) * int(data["grid_h"])]} # Validate each tile has required shape for i in range(tiles.size()): @@ -162,8 +165,7 @@ func validate_save_schema(data: Dictionary) -> Dictionary: if not tile.has(tile_key): return {"valid": false, "reason": "tile[%d] missing key '%s'" % [i, tile_key]} # Validate tile shape: amount must be numeric, resource must be string - var amt: Variant = tile.get("amount", -1) - if typeof(amt) != TYPE_INT and typeof(amt) != TYPE_FLOAT: + if not _is_numeric(tile.get("amount", -1)): return {"valid": false, "reason": "tile[%d].amount must be numeric" % i} var res: Variant = tile.get("resource", "") if typeof(res) != TYPE_STRING: @@ -196,8 +198,7 @@ func validate_save_schema(data: Dictionary) -> Dictionary: if not wcarrying is Dictionary: return {"valid": false, "reason": "worker[%d].carrying must be dictionary" % k} for res_name in wcarrying: - var cv = wcarrying[res_name] - if typeof(cv) != TYPE_INT and typeof(cv) != TYPE_FLOAT: + if not _is_numeric(wcarrying[res_name]): return {"valid": false, "reason": "worker[%d].carrying.%s must be numeric" % [k, res_name]} # Validate task is a dictionary var wtask = worker.get("task", {}) @@ -206,7 +207,7 @@ func validate_save_schema(data: Dictionary) -> Dictionary: # Validate break_ticks is numeric and non-negative (optional — missing defaults to 0 for v1 compat) if worker.has("break_ticks"): var wbreak = worker.get("break_ticks", 0) - if typeof(wbreak) != TYPE_INT and typeof(wbreak) != TYPE_FLOAT: + if not _is_numeric(wbreak): return {"valid": false, "reason": "worker[%d].break_ticks must be numeric" % k} if float(wbreak) < 0: return {"valid": false, "reason": "worker[%d].break_ticks must be non-negative" % k} @@ -226,8 +227,7 @@ func validate_save_schema(data: Dictionary) -> Dictionary: if not build.has(build_key): return {"valid": false, "reason": "build[%d] missing key '%s'" % [j, build_key]} # Validate id is numeric - var bid = build.get("id", -1) - if typeof(bid) != TYPE_INT and typeof(bid) != TYPE_FLOAT: + if not _is_numeric(build.get("id", -1)): return {"valid": false, "reason": "build[%d].id must be numeric" % j} # Validate kind is string var bkind = build.get("kind", "") @@ -314,12 +314,6 @@ func _expected_grid_sizes() -> Array: 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) @@ -344,33 +338,18 @@ func _known_reward_keys() -> Array: # 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 [ + return [ 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 + return ColonyStance.ALL_STANCES # Validate an active_goal value. Returns empty string when valid, or a reason. func _validate_active_goal(value) -> String: @@ -409,7 +388,7 @@ func _validate_active_rewards(rewards: Array) -> String: 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: + if not _is_numeric(v): 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] @@ -436,11 +415,6 @@ func migrate_save(data: Dictionary) -> Dictionary: if save_version == SAVE_VERSION: return data - if save_version < 1: - # Anything below v1 is unsupported — fail explicitly - print("SAVE_MIGRATION_ERROR: unsupported save version %d (minimum: 1)" % save_version) - return {} - if save_version == 1: data = migrate_v1_to_v2(data) data["save_version"] = SAVE_VERSION @@ -481,28 +455,30 @@ func _backup_filename() -> String: """Generate a unique timestamped backup filename.""" _backup_counter += 1 var ts := Time.get_datetime_string_from_system().replace(":", "").replace("-", "").replace(" ", "_") - return "%s_%d.save" % [ts, _backup_counter] - -func backup_save() -> String: - """Create a timestamped backup of the current save file. - Returns the backup path on success, empty string on failure.""" - if not FileAccess.file_exists(SAVE_PATH): - return "" + # BACKUP_PREFIX is what list_backups() filters on — without it backups + # were written but could never be listed or restored. + return "%s%s_%d.save" % [BACKUP_PREFIX, ts, _backup_counter] - var backup_path := "user://%s" % _backup_filename() - var src := FileAccess.open(SAVE_PATH, FileAccess.READ) +func _copy_file(src_path: String, dst_path: String) -> bool: + var src := FileAccess.open(src_path, FileAccess.READ) if not src: - return "" - + return false var content := src.get_as_text() src.close() - - var dst := FileAccess.open(backup_path, FileAccess.WRITE) + var dst := FileAccess.open(dst_path, FileAccess.WRITE) if not dst: - return "" + return false dst.store_string(content) dst.close() - return backup_path + return true + +func backup_save() -> String: + """Create a timestamped backup of the current save file. + Returns the backup path on success, empty string on failure.""" + if not FileAccess.file_exists(SAVE_PATH): + return "" + var backup_path := "user://%s" % _backup_filename() + return backup_path if _copy_file(SAVE_PATH, backup_path) else "" func list_backups() -> Array[String]: """Return sorted (newest-first) list of backup file paths.""" @@ -533,19 +509,7 @@ func restore_backup() -> String: return "" var latest := backups[0] - var src := FileAccess.open(latest, FileAccess.READ) - if not src: - return "" - - var content := src.get_as_text() - src.close() - - var dst := FileAccess.open(SAVE_PATH, FileAccess.WRITE) - if not dst: - return "" - dst.store_string(content) - dst.close() - return latest + return latest if _copy_file(latest, SAVE_PATH) else "" # ── Settings persistence ──────────────────────────────────────────────────── @@ -565,7 +529,8 @@ func load_settings() -> Dictionary: return {} 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 {} + var inner = JSON.parse_string(parsed) + return inner if inner is Dictionary else {} return parsed if parsed is Dictionary else {} if not FileAccess.file_exists(SETTINGS_PATH): return {} diff --git a/scripts/goal_progression.gd b/scripts/goal_progression.gd index 8894b50..e2208fd 100644 --- a/scripts/goal_progression.gd +++ b/scripts/goal_progression.gd @@ -43,9 +43,10 @@ static func compute_progress(goal: Dictionary, game_state: Dictionary) -> void: # Otherwise returns an unchanged result. static func check_and_rotate(goal: Dictionary, completed_ids: Array) -> Dictionary: if goal.is_empty() or not RotatingGoal.is_goal_complete(goal): + # No change — return the originals to avoid a per-tick deep copy. return { - "active_goal": goal.duplicate(true), - "completed_ids": completed_ids.duplicate(), + "active_goal": goal, + "completed_ids": completed_ids, "was_completed": false, "goal_id": "", } diff --git a/scripts/goal_reward.gd b/scripts/goal_reward.gd index d011765..8646952 100644 --- a/scripts/goal_reward.gd +++ b/scripts/goal_reward.gd @@ -81,7 +81,7 @@ static func get_reward_label(goal_id: String) -> String: # Modifies state.resources for trickle payouts. static func tick_rewards(active_rewards: Array, game_state: Dictionary) -> Dictionary: # {expired: Array[String], events: Array[String], new_rewards: Array[Dictionary]} - var result := {"expired": [], "events": [], "new_rewards": active_rewards.duplicate(true)} + var result := {"expired": [], "events": [], "new_rewards": []} var surviving := [] for reward in active_rewards: diff --git a/scripts/main.gd b/scripts/main.gd index 4bcc49e..044ab7c 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -20,6 +20,7 @@ const RESOURCE_TRENDS := Constants.RESOURCE_TRENDS const ColonyStance := preload("res://scripts/colony_stance.gd") const TileRender := preload("res://scripts/tile_render.gd") const WorkerCapLogic := preload("res://scripts/worker_cap_logic.gd") +const ColonySim := preload("res://scripts/colony_sim.gd") @onready var world_grid: GridContainer = %WorldGrid @@ -62,26 +63,77 @@ const WorkerCapLogic := preload("res://scripts/worker_cap_logic.gd") @onready var event_drawer_panel: PanelContainer = %EventDrawerPanel @onready var event_drawer_log: Label = %EventDrawerLog +## The scene-free simulation core — owns `state` and all game logic. +var sim := ColonySim.new() + +# ── Facade properties ───────────────────────────────────────────────────────── +# Legacy member names proxied onto the sim so tests and UI code keep their +# existing API while the sim/state stay the single source of truth. +var state: Dictionary: + get: + return sim.state + set(value): + sim.state = value +var grid_w: int: + get: + return sim.grid_w + set(value): + sim.grid_w = value +var grid_h: int: + get: + return sim.grid_h + set(value): + sim.grid_h = value +var stockpile_pos: Vector2i: + get: + return sim.stockpile_pos + set(value): + sim.stockpile_pos = value +var priority_order: Array[String]: + get: + return sim.priority_order + set(value): + sim.priority_order = value +var tick: int: + get: + return sim.tick() + set(value): + sim.state["tick"] = value +var colony_stance: String: + get: + return sim.colony_stance() + set(value): + sim.state["colony_stance"] = value +var active_goal: Dictionary: + get: + var goal: Dictionary = sim.state.get("active_goal", {}) + return goal + set(value): + sim.state["active_goal"] = value + var tile_views: Array[Dictionary] = [] -var state: Dictionary = {} var settings: Dictionary = {} -var tick := 0 -var food_upkeep_tracker := 0 -var rng := RandomNumberGenerator.new() var prev_resources: Dictionary = {} var tick_timer: Timer var worker_texture_cache: Dictionary = {} var pending_build_kind := "" -var priority_order: Array[String] = ["build", "haul", "gather"] -var colony_stance := ColonyStance.STANCE_BALANCED var hover_tile_index := -1 var drag_start_pos := Vector2i(-9999, -9999) var edge_snap_cooldown := 0.0 const EDGE_SNAP_THRESHOLD := 40 -var grid_w := LayoutMath.BOTTOM_GRID_W -var _dirty := false -var grid_h := LayoutMath.BOTTOM_GRID_H -var stockpile_pos := LayoutMath.stockpile_pos_for_anchor("bottom") +# OptionButton item order for the dock-side dropdown; index ↔ anchor mapping. +const DOCK_OPTIONS := ["right", "left", "bottom"] +# Tick speed tiers indexed by the tick_speed setting. +const TICK_SPEEDS := [ + {"label": "Slow", "mult": 1.6}, + {"label": "Normal", "mult": 1.0}, + {"label": "Fast", "mult": 0.65}, +] +# Persist at most every N ticks; explicit user actions and quit force a flush. +const PERSIST_INTERVAL_TICKS := 10 +# Reassert the borderless/on-top/transparent window flags every N ticks. +const WINDOW_PIN_REASSERT_TICKS := 20 +var _last_persist_tick := -PERSIST_INTERVAL_TICKS var anchor_family := "bottom" var tile_size := Vector2i(56, 56) var _last_usable_rect: Rect2i @@ -98,11 +150,6 @@ var bottom_header_row: HBoxContainer var bottom_status_column: VBoxContainer var bottom_button_row: HBoxContainer var game_active := false -var active_goal: Dictionary = {} -var completed_goal_ids: Array = [] -var active_rewards: Array = [] -var current_milestone_id: String = "" -var completed_milestone_ids: Array = [] var event_drawer_visible := false func make_panel_style(bg: Color, border: Color, corner_radius: int = 12) -> StyleBoxFlat: @@ -166,19 +213,10 @@ func apply_theme() -> void: rank.add_theme_font_size_override("font_size", 12) rank.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0, 1.0)) - var button_normal := make_panel_style(Color(0.18, 0.23, 0.3, 0.96), Color(0.36, 0.45, 0.55, 0.9), 10) - var button_hover := make_panel_style(Color(0.23, 0.3, 0.39, 1.0), Color(0.49, 0.64, 0.78, 1.0), 10) - var button_pressed := make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10) - var button_disabled := make_panel_style(Color(0.12, 0.15, 0.19, 0.65), Color(0.24, 0.28, 0.33, 0.45), 10) for button_name in ["BuildModeButton", "HudMenuButton", "MenuButton", "GatherUpButton", "GatherDownButton", "HaulUpButton", "HaulDownButton", "BuildUpButton", "BuildDownButton", "SaveButton", "ResetButton", "NewGameButton", "SaveGameButton", "LoadGameButton", "SettingsButton", "ExitButton", "SettingsCloseButton"]: var button := maybe_node("%%%s" % button_name) as Button if button: - button.add_theme_stylebox_override("normal", button_normal.duplicate()) - button.add_theme_stylebox_override("hover", button_hover.duplicate()) - button.add_theme_stylebox_override("pressed", button_pressed.duplicate()) - button.add_theme_stylebox_override("disabled", button_disabled.duplicate()) - button.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) - button.add_theme_color_override("font_disabled_color", Color(0.72, 0.76, 0.82, 0.6)) + apply_button_theme(button) button.add_theme_constant_override("h_separation", 6) var build_buttons := maybe_node("%BuildButtons") as VBoxContainer @@ -186,12 +224,7 @@ func apply_theme() -> void: build_buttons.add_theme_constant_override("separation", 6) for child in build_buttons.get_children(): if child is Button: - child.add_theme_stylebox_override("normal", button_normal.duplicate()) - child.add_theme_stylebox_override("hover", button_hover.duplicate()) - child.add_theme_stylebox_override("pressed", button_pressed.duplicate()) - child.add_theme_stylebox_override("disabled", button_disabled.duplicate()) - child.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) - child.add_theme_color_override("font_disabled_color", Color(0.72, 0.76, 0.82, 0.6)) + apply_button_theme(child) var slider := maybe_node("%TickSpeedSlider") as HSlider if slider: @@ -202,13 +235,22 @@ func apply_theme() -> void: var option := maybe_node("%DockSideOption") as OptionButton if option: - option.add_theme_stylebox_override("normal", button_normal.duplicate()) - option.add_theme_stylebox_override("hover", button_hover.duplicate()) - option.add_theme_stylebox_override("pressed", button_pressed.duplicate()) + option.add_theme_stylebox_override("normal", make_panel_style(Color(0.18, 0.23, 0.3, 0.96), Color(0.36, 0.45, 0.55, 0.9), 10)) + option.add_theme_stylebox_override("hover", make_panel_style(Color(0.23, 0.3, 0.39, 1.0), Color(0.49, 0.64, 0.78, 1.0), 10)) + option.add_theme_stylebox_override("pressed", make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10)) option.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) +## Shared button styling for every themed Button in the dock UI. +func apply_button_theme(button: Button) -> void: + button.add_theme_stylebox_override("normal", make_panel_style(Color(0.18, 0.23, 0.3, 0.96), Color(0.36, 0.45, 0.55, 0.9), 10)) + button.add_theme_stylebox_override("hover", make_panel_style(Color(0.23, 0.3, 0.39, 1.0), Color(0.49, 0.64, 0.78, 1.0), 10)) + button.add_theme_stylebox_override("pressed", make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10)) + button.add_theme_stylebox_override("disabled", make_panel_style(Color(0.12, 0.15, 0.19, 0.65), Color(0.24, 0.28, 0.33, 0.45), 10)) + button.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) + button.add_theme_color_override("font_disabled_color", Color(0.72, 0.76, 0.82, 0.6)) + func _ready() -> void: - rng.randomize() + sim.rng.randomize() load_settings() configure_window() title_label.visible = false @@ -357,10 +399,9 @@ func _on_startup_new_game() -> void: sync_dock_option(chosen_anchor) save_settings() # Backup current save before destructive reset (issue #178) - var _gs = GameState.new() - var _bk: String = _gs.backup_save() - if not _bk.is_empty(): - push_event_safe("Backup saved before reset: %s" % _bk.get_file()) + var backup_path: String = GameState.backup_save() + if not backup_path.is_empty(): + push_event("Backup saved before reset: %s" % backup_path.get_file()) apply_dock_position() build_world() bootstrap_state() @@ -376,12 +417,6 @@ func select_startup_anchor(anchor: String) -> void: var button: Button = startup_anchor_buttons[key] button.button_pressed = String(key) == startup_selected_anchor -func push_event_safe(text: String) -> void: - """Safely push an event even when the game state may not have events yet.""" - if state.has("events"): - state["events"].append({"tick": tick, "text": text}) - - func _on_startup_load_game() -> void: load_saved_game() if game_active: @@ -412,10 +447,6 @@ func apply_dock_position() -> void: func update_tile_metrics(dock_anchor: String, usable_rect: Rect2i) -> void: tile_size = tile_size_for_anchor(dock_anchor, usable_rect) -func tile_px_for_anchor(dock_anchor: String, usable_rect: Rect2i) -> int: - var family := LayoutMath.anchor_family_from_dock_anchor(dock_anchor) - return LayoutMath.tile_px_for_work_area(family, usable_rect.size.x, usable_rect.size.y, float(settings.get("zoom_factor", 1.0))) - func tile_size_for_anchor(dock_anchor: String, usable_rect: Rect2i) -> Vector2i: var family := LayoutMath.anchor_family_from_dock_anchor(dock_anchor) return LayoutMath.tile_size_for_work_area(family, usable_rect.size.x, usable_rect.size.y, float(settings.get("zoom_factor", 1.0))) @@ -423,9 +454,6 @@ func tile_size_for_anchor(dock_anchor: String, usable_rect: Rect2i) -> Vector2i: func world_pixel_size() -> Vector2i: return LayoutMath.world_pixel_size_for_tile_size(grid_w, grid_h, tile_size) -func dock_padding_for_anchor(dock_anchor: String) -> Vector2i: - return LayoutMath.dock_padding_for_anchor(anchor_family) - func apply_anchor_geometry(dock_anchor: String) -> void: var family := LayoutMath.anchor_family_from_dock_anchor(dock_anchor) anchor_family = family @@ -565,9 +593,9 @@ func apply_anchor_layout(dock_anchor: String) -> void: hud_row.move_child(menu_hint, 2) # HUD label tuning for bottom mode (issue #21) if status_label: - status_label.add_theme_font_size_override("font_size", 14 if is_bottom else 14) + status_label.add_theme_font_size_override("font_size", 14) if menu_hint: - menu_hint.add_theme_font_size_override("font_size", 13 if is_bottom else 13) + menu_hint.add_theme_font_size_override("font_size", 13) position_popup_panel(dock_anchor) func position_popup_panel(dock_anchor: String) -> void: var backdrop_size: Vector2 = get_node("Backdrop").size @@ -620,13 +648,15 @@ func build_world() -> void: tile_panel.add_theme_stylebox_override("panel", tp_style) world_grid.add_child(tile_panel) tile_panel.mouse_entered.connect(func() -> void: + var previous := hover_tile_index hover_tile_index = tile_index - render_world() + render_tile(previous) + render_tile(tile_index) ) tile_panel.mouse_exited.connect(func() -> void: if hover_tile_index == tile_index: hover_tile_index = -1 - render_world() + render_tile(tile_index) ) tile_panel.gui_input.connect(func(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: @@ -707,22 +737,6 @@ func wire_controls() -> void: ) %RecruitButton.pressed.connect(_on_recruit_worker_pressed) -func load_or_boot() -> void: - var loaded := GameState.load_game() - if not loaded.is_empty(): - apply_loaded_dock_anchor(loaded) - if loaded.is_empty() or not is_save_compatible(loaded): - bootstrap_state() - else: - state = loaded - tick = int(state.get("tick", 0)) - 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() - func apply_loaded_dock_anchor(loaded: Dictionary) -> void: var loaded_anchor := String(loaded.get("dock_anchor", settings.get("dock_anchor", "bottom"))) loaded["dock_anchor"] = loaded_anchor @@ -761,20 +775,13 @@ func bootstrap_state() -> void: }) for y in grid_h: for x in grid_w: - state.tiles.append(seed_tile(Vector2i(x, y))) + state.tiles.append(sim.seed_tile(Vector2i(x, y))) set_tile(stockpile_pos, {"kind": "stockpile", "amount": 0, "resource": "", "build_kind": ""}) - tick = 0 + # Seeds active goal, milestone chain, rewards, and stance into state. + sim.ensure_defaults() apply_priority_order() - # Initialize active goal - active_goal = GoalProgression.init_goals(completed_goal_ids) - # Initialize milestone state - var milestone_seed: Dictionary = MilestoneManager.make_goal_state() - current_milestone_id = String(milestone_seed.get("milestone_id", "")) - completed_milestone_ids = Array(milestone_seed.get("completed_ids", [])) - completed_goal_ids = [] - active_rewards = [] _mark_dirty() - persist() + persist(true) apply_orientation_lock_ui() func load_settings() -> void: @@ -784,21 +791,14 @@ func load_settings() -> void: } settings.merge(GameState.load_settings(), true) dock_side_option.clear() - dock_side_option.add_item("Right") - dock_side_option.add_item("Left") - dock_side_option.add_item("Bottom") + for anchor in DOCK_OPTIONS: + dock_side_option.add_item(cap(anchor)) sync_dock_option(String(settings.get("dock_anchor", "bottom"))) tick_speed_slider.value = float(settings.get("tick_speed", 0)) update_tick_speed_label() func sync_dock_option(dock_anchor: String) -> void: - match dock_anchor: - "left": - dock_side_option.select(1) - "bottom": - dock_side_option.select(2) - _: - dock_side_option.select(0) + dock_side_option.select(maxi(DOCK_OPTIONS.find(dock_anchor), 0)) func apply_orientation_lock_ui() -> void: dock_side_option.disabled = true @@ -810,13 +810,9 @@ func save_settings() -> void: GameState.save_settings(settings) func dock_anchor_from_option(index: int) -> String: - match index: - 1: - return "left" - 2: - return "bottom" - _: - return "right" + if index >= 0 and index < DOCK_OPTIONS.size(): + return DOCK_OPTIONS[index] + return "right" func toggle_menu() -> void: var is_open := not sidebar_scroll.visible @@ -832,11 +828,14 @@ func toggle_menu() -> void: close_menu() update_menu_button_text() -func close_menu() -> void: +func hide_all_popups() -> void: sidebar_scroll.visible = false menu_actions.visible = false management_panels.visible = false settings_panel.visible = false + +func close_menu() -> void: + hide_all_popups() if not pending_build_kind.is_empty(): world_label.text = 'Colony • click ground for %s' % cap(pending_build_kind) else: @@ -881,64 +880,50 @@ func start_new_game() -> void: func save_game() -> void: persist() push_event("Game saved. Tiny bureaucracy, handled.") - menu_actions.visible = false - sidebar_scroll.visible = false - management_panels.visible = false + hide_all_popups() close_settings() update_menu_button_text() render_sidebar() +func _load_failed(message: String) -> void: + push_event(message) + menu_actions.visible = false + close_settings() + if game_active: + render_sidebar() + func load_saved_game() -> void: var loaded := GameState.load_game() if loaded.is_empty(): - if state.has("events"): - push_event("No compatible save found. The colony keeps improvising.") - menu_actions.visible = false - close_settings() - if game_active: - render_sidebar() + _load_failed("No compatible save found. The colony keeps improvising.") return apply_loaded_dock_anchor(loaded) if not is_save_compatible(loaded): - if state.has("events"): - push_event("Save incompatible with current layout. Colony keeps improvising.") - menu_actions.visible = false - close_settings() - if game_active: - render_sidebar() + _load_failed("Save incompatible with current layout. Colony keeps improvising.") return state = loaded game_active = true - tick = int(state.get("tick", 0)) - 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"] - if state.has("completed_goal_ids"): - completed_goal_ids = state["completed_goal_ids"] - if state.has("active_rewards"): - active_rewards = state["active_rewards"] - # Restore milestone state (seed defaults for legacy saves without milestone keys) - var milestone_seed: Dictionary = MilestoneManager.make_goal_state() - current_milestone_id = String(state.get("current_milestone_id", milestone_seed.get("milestone_id", ""))) - completed_milestone_ids = Array(state.get("completed_milestone_ids", milestone_seed.get("completed_ids", []))) - colony_stance = String(state.get("colony_stance", ColonyStance.STANCE_BALANCED)) + # Goals, rewards, milestones, and stance live in state; just backfill + # any keys missing from legacy saves. + sim.ensure_defaults() + sim.rebuild_reservations() apply_priority_order() apply_orientation_lock_ui() push_event("Save loaded. Tiny lives resume their routines.") - menu_actions.visible = false - sidebar_scroll.visible = false - management_panels.visible = false + hide_all_popups() close_settings() update_menu_button_text() render_all() func exit_game() -> void: + persist(true) get_tree().quit() +func _notification(what: int) -> void: + # Flush any debounced state before the window closes. + if what == NOTIFICATION_WM_CLOSE_REQUEST: + persist(true) + func _on_tick_speed_changed(value: float) -> void: settings["tick_speed"] = int(value) update_tick_speed_label() @@ -989,20 +974,14 @@ func _on_dock_side_selected(index: int) -> void: update_menu_button_text() func update_tick_speed_label() -> void: - match int(tick_speed_slider.value): - 0: - tick_speed_value.text = "Slow" - 1: - tick_speed_value.text = "Normal" - 2: - tick_speed_value.text = "Fast" + var setting := clampi(int(tick_speed_slider.value), 0, TICK_SPEEDS.size() - 1) + tick_speed_value.text = TICK_SPEEDS[setting]["label"] func update_menu_button_text() -> void: + menu_button.text = "Menu" if sidebar_scroll.visible: - menu_button.text = "Menu" menu_hint.text = "Planning" if pending_build_kind.is_empty() else "Place %s" % cap(pending_build_kind) else: - menu_button.text = "Menu" menu_hint.text = "%d workers active" % active_worker_count() build_mode_button.text = "Cancel Build" if not pending_build_kind.is_empty() else "Build" @@ -1016,109 +995,41 @@ func active_worker_count() -> int: return active -# ── Food upkeep helpers (issue #147, links to #133) ────────────────────────── +# ── Simulation facade ───────────────────────────────────────────────────────── +# Thin wrappers so tests and UI code keep calling these on main; the logic +# lives in ColonySim. func get_extra_workers_count() -> int: - """Return number of workers above BASE_WORKERS_NO_UPKEEP.""" - if not state.has("workers"): - return 0 - var total: int = state.workers.size() - var extra: int = total - Constants.BASE_WORKERS_NO_UPKEEP - return maxi(extra, 0) + return sim.get_extra_workers_count() func apply_food_upkeep() -> void: - """Deduct food for extra workers. Soft model — never negative.""" - if not state.has("workers"): - return - var extra := get_extra_workers_count() - if extra <= 0: - return - var food_cost := extra * Constants.FOOD_PER_EXTRA_WORKER - var current_food := int(state.resources.get("food", 0)) - var new_food := maxi(current_food - food_cost, 0) - if new_food < current_food: - state.resources["food"] = new_food - push_event("The crew ate. Food -%d." % (current_food - new_food)) - _mark_dirty() + sim.apply_food_upkeep() func get_food_slowdown_factor() -> float: - """Return speed multiplier based on current food level.""" - var food := int(state.resources.get("food", 0)) - if food <= Constants.STARVATION_FOOD_THRESHOLD: - return Constants.STARVATION_SPEED_FACTOR - if food <= Constants.LOW_FOOD_THRESHOLD: - # Linear interpolation between starvation and low-food threshold - var range_size = float(Constants.LOW_FOOD_THRESHOLD - Constants.STARVATION_FOOD_THRESHOLD) - if range_size == 0: - return Constants.LOW_FOOD_SPEED_FACTOR - var progress = float(food - Constants.STARVATION_FOOD_THRESHOLD) / range_size - return lerp(Constants.STARVATION_SPEED_FACTOR, Constants.LOW_FOOD_SPEED_FACTOR, progress) - return 1.0 + return sim.get_food_slowdown_factor() func get_low_food_level() -> String: - """Return 'starving', 'low', or 'ok' based on current food.""" - var food := int(state.resources.get("food", 0)) - if food <= Constants.STARVATION_FOOD_THRESHOLD: - return "starving" - if food <= Constants.LOW_FOOD_THRESHOLD: - return "low" - return "ok" + return sim.get_low_food_level() func should_bias_to_food_gathering() -> bool: - """Return true when low food should bias workers toward gathering food.""" - var level := get_low_food_level() - return level == "low" or level == "starving" + return sim.should_bias_to_food_gathering() -func get_worker_cap() -> int: - return WorkerCapLogic.calculate_worker_cap(state.get("builds", [])) +func get_worker_cap() -> int: + return sim.get_worker_cap() -# ── Recruit worker decision (issue #149, links to #133, #135) ───────────────── func can_recruit_worker() -> bool: - """Return true if the colony has capacity for another worker.""" - return WorkerCapLogic.can_recruit(state.get("workers", []), state.get("builds", [])) + return sim.can_recruit_worker() func recruit_worker() -> void: - """Add a new worker to the colony. No cost — just a decision point.""" - var worker_cap := get_worker_cap() - var current: int = state.workers.size() - if current >= worker_cap: - push_event("Not enough housing for another worker. Build more huts.") - return - - # Pick the next available name from WORKER_NAMES (cycle through) - var next_index: int = current % len(WORKER_NAMES) - var new_worker := { - "name": WORKER_NAMES[next_index], - "task": {"kind": "", "data": {}}, - "carrying": {}, - "break_ticks": 0, - "spawn_tick": tick, - } - state["workers"].append(new_worker) - - # Apply recruit discount reward if active (gives +1 food) - if GoalReward.consume_recruit_discount(active_rewards): - state.resources["food"] = int(state.resources.get("food", 0)) + 1 - _mark_dirty() - - _mark_dirty() - - # Update food info text for the new worker count - var extra := get_extra_workers_count() - if extra > 0: - var food_cost := extra * Constants.FOOD_PER_EXTRA_WORKER - push_event("New crew member %s joins! Food impact: +%d per cycle." % [new_worker.name, food_cost]) - else: - push_event("New crew member %s joins the tiny colony." % new_worker.name) - - persist() + sim.recruit_worker() + persist(true) func _on_recruit_worker_pressed() -> void: @@ -1131,20 +1042,22 @@ func _on_recruit_worker_pressed() -> void: push_event("Colony at capacity (%d/%d). Build more huts to recruit." % [current, worker_cap]) -@onready var stance_buttons: Dictionary = {} +var stance_buttons: Dictionary = {} var stance_panel: PanelContainer = null +var stance_desc_label: Label = null +## The panel is built once; renders only sync the pressed states and the +## description label. Rebuilding buttons/styles every tick was pure churn. func render_stance_toggle() -> void: - # Find or create stance panel in sidebar under menu_actions if not is_instance_valid(menu_actions): return - - # Remove existing stance panel if present - if stance_panel and stance_panel.get_parent(): - stance_panel.get_parent().remove_child(stance_panel) - stance_panel.queue_free() - stance_panel = null - + if stance_panel == null: + _build_stance_panel() + for stance_key in stance_buttons: + stance_buttons[stance_key].button_pressed = (colony_stance == String(stance_key)) + stance_desc_label.text = ColonyStance.STANCE_INFO[colony_stance].description + +func _build_stance_panel() -> void: stance_panel = PanelContainer.new() stance_panel.name = "StancePanel" stance_panel.add_theme_stylebox_override("panel", make_panel_style(Color(0.11, 0.14, 0.18, 0.92), Color(0.28, 0.34, 0.41, 0.75), 12)) @@ -1166,11 +1079,7 @@ func render_stance_toggle() -> void: var btn_row := HBoxContainer.new() btn_row.add_theme_constant_override("separation", 4) box.add_child(btn_row) - - var button_normal := make_panel_style(Color(0.18, 0.23, 0.3, 0.96), Color(0.36, 0.45, 0.55, 0.9), 10) - var button_hover := make_panel_style(Color(0.23, 0.3, 0.39, 1.0), Color(0.49, 0.64, 0.78, 1.0), 10) - var button_pressed := make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10) - + stance_buttons.clear() for stance_key in ColonyStance.ALL_STANCES: var info: Dictionary = ColonyStance.STANCE_INFO[stance_key] @@ -1178,11 +1087,8 @@ func render_stance_toggle() -> void: btn.text = info.label btn.toggle_mode = true btn.button_pressed = (colony_stance == stance_key) + apply_button_theme(btn) btn.add_theme_font_size_override("font_size", 10) - btn.add_theme_stylebox_override("normal", button_normal.duplicate()) - btn.add_theme_stylebox_override("hover", button_hover.duplicate()) - btn.add_theme_stylebox_override("pressed", button_pressed.duplicate()) - btn.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) btn.tooltip_text = info.description btn.pressed.connect(func(s = stance_key): change_stance(s) @@ -1191,14 +1097,14 @@ func render_stance_toggle() -> void: btn_row.add_child(btn) # Add description label - var desc_label := Label.new() - desc_label.name = "StanceDescription" - desc_label.text = ColonyStance.STANCE_INFO[colony_stance].description - desc_label.add_theme_font_size_override("font_size", 10) - desc_label.add_theme_color_override("font_color", Color(0.86, 0.9, 0.95, 0.84)) - desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - box.add_child(desc_label) - + stance_desc_label = Label.new() + stance_desc_label.name = "StanceDescription" + stance_desc_label.text = ColonyStance.STANCE_INFO[colony_stance].description + stance_desc_label.add_theme_font_size_override("font_size", 10) + stance_desc_label.add_theme_color_override("font_color", Color(0.86, 0.9, 0.95, 0.84)) + stance_desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(stance_desc_label) + menu_actions.add_child(stance_panel) @@ -1206,8 +1112,11 @@ func change_stance(new_stance: String) -> void: if not ColonyStance.ALL_STANCES.has(new_stance): return if new_stance == colony_stance: + # Re-sync pressed states so clicking the active button doesn't untoggle it. + render_stance_toggle() return colony_stance = new_stance + _mark_dirty() var new_label: String = ColonyStance.STANCE_INFO[new_stance].label push_event("Colony stance changed to %s. Workers adjust priorities." % new_label) render_all() @@ -1223,7 +1132,7 @@ func render_crew_panel() -> void: var extra := get_extra_workers_count() # Cap info: show current / cap - crew_cap_info.text = "%d / %d workers" % [current, cap] + crew_cap_info.text = "%d / %d workers" % [current, worker_cap] # Food impact text if extra <= 0: @@ -1247,7 +1156,7 @@ func render_crew_panel() -> void: recruit_button.text = "Recruit Worker" else: recruit_button.disabled = true - recruit_button.text = "At Cap (%d/%d)" % [current, cap] + recruit_button.text = "At Cap (%d/%d)" % [current, worker_cap] # Warning when food is low and trying to recruit if crew_warning: @@ -1326,98 +1235,21 @@ func stockpile_summary_text(compact: bool = false) -> String: return "Stored W %d %s S %d %s F %d %s • Harvested W %d S %d F %d" % [wood, w_trend, stone, s_trend, food, f_trend, int(harvested.get("wood", 0)), int(harvested.get("stone", 0)), int(harvested.get("food", 0))] return "Stored W %d %s S %d %s F %d %s\nHarvested W %d S %d F %d" % [wood, w_trend, stone, s_trend, food, f_trend, int(harvested.get("wood", 0)), int(harvested.get("stone", 0)), int(harvested.get("food", 0))] -func activity_summary_text() -> String: - var lines := [] - for worker in state.workers: - lines.append("%s: %s" % [String(worker.name), worker_brief(worker)]) - if lines.is_empty(): - return "Activity\nNo crew activity" - return "Activity\n%s" % "\n".join(lines) - -func worker_brief(worker: Dictionary) -> String: - var summary := task_name(worker) - var carrying := carrying_name(worker.get("carrying", {})) - if carrying != "hands free": - summary += " (%s)" % carrying - return summary - func tick_seconds_for_setting() -> float: - var multiplier := 1.0 - if settings.get('focus_mode', false): - multiplier = 2.5 - match int(settings.get("tick_speed", 0)): - 0: - return BASE_TICK_SECONDS * 1.6 * multiplier - 1: - return BASE_TICK_SECONDS * multiplier - 2: - return BASE_TICK_SECONDS * 0.65 * multiplier - return BASE_TICK_SECONDS * multiplier - -func seed_tile(pos: Vector2i) -> Dictionary: - var key := int((pos.x * 13 + pos.y * 7 + pos.x * pos.y) % 14) - if key == 0 or key == 3: - return {"kind": "tree", "amount": 6, "resource": "wood", "build_kind": ""} - if key == 6 or key == 8: - return {"kind": "rock", "amount": 5, "resource": "stone", "build_kind": ""} - if key == 11: - return {"kind": "berries", "amount": 4, "resource": "food", "build_kind": ""} - return {"kind": "ground", "amount": 0, "resource": "", "build_kind": ""} + var multiplier := 2.5 if settings.get('focus_mode', false) else 1.0 + var setting := int(settings.get("tick_speed", 0)) + if setting < 0 or setting >= TICK_SPEEDS.size(): + return BASE_TICK_SECONDS * multiplier + return BASE_TICK_SECONDS * float(TICK_SPEEDS[setting]["mult"]) * multiplier func _on_tick() -> void: if not game_active or state.is_empty(): return - keep_window_pinned() - tick += 1 - # _dirty is set only in mutation points, not every tick - maybe_fire_event() - _clean_stale_reservations() - - # Food upkeep (issue #147) - food_upkeep_tracker += 1 - if food_upkeep_tracker >= Constants.FOOD_UPKEEP_INTERVAL_TICKS: - food_upkeep_tracker = 0 - apply_food_upkeep() - - for worker in state.workers: - worker.prev_pos = worker.get("pos", vec_to_data(stockpile_pos)) - if int(worker.get("break_ticks", 0)) > 0: - worker.break_ticks = int(worker.break_ticks) - 1 - if int(worker.break_ticks) <= 0: - push_event("%s is back from a dramatic five-second break." % worker.name) - continue - if worker.task.is_empty(): - worker.task = choose_task(worker) - if not worker.task.is_empty(): - step_worker(worker) - - # Update active goal progress and check for completion/rotation - var result = GoalProgression.process_tick(active_goal, completed_goal_ids, state) - active_goal = result["active_goal"] - completed_goal_ids = result["completed_ids"] - if result["was_completed"]: - push_event("Goal completed: %s. The colony moves on." % result["goal_id"]) - # Apply goal completion reward - var new_reward = GoalReward.apply_reward(result["goal_id"]) - if not new_reward.is_empty(): - active_rewards.append(new_reward) - push_event("Reward: %s" % new_reward["label"]) - # Tick active rewards (expiration + trickle payouts) - var reward_result = GoalReward.tick_rewards(active_rewards, state) - active_rewards = reward_result["new_rewards"] - for evt in reward_result["events"]: - push_event(evt) - for expired_label in reward_result["expired"]: - push_event("Reward ended: %s" % expired_label) - # Milestone evaluation (issue #237) - var active_milestone: Dictionary = MilestoneManager.get_current_milestone(MilestoneManager.MILESTONE_CATALOG, current_milestone_id) - if not active_milestone.is_empty() and MilestoneManager.is_milestone_complete(active_milestone, state): - var completed_milestone_id: String = current_milestone_id - completed_milestone_ids.append(completed_milestone_id) - push_event("Milestone reached: %s" % MilestoneManager.milestone_description(active_milestone)) - current_milestone_id = MilestoneManager.advance_to_next(completed_milestone_ids, completed_milestone_id) + # Reassert window pinning occasionally in case the OS dropped a flag. + if tick % WINDOW_PIN_REASSERT_TICKS == 0: + keep_window_pinned() + sim.process_tick() persist() - state.workers = state.workers render_all() func _process(delta: float) -> void: @@ -1433,251 +1265,42 @@ func _process(delta: float) -> void: apply_dock_position() _dock_recheck_timer = DOCK_RECHECK_COOLDOWN edge_snap_cooldown = maxf(edge_snap_cooldown - delta, 0.0) - var current_pos := DisplayServer.window_get_position() - var window_size := DisplayServer.window_get_size() - var dragging := Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and current_pos != drag_start_pos - if dragging: - if drag_start_pos == Vector2i(-9999, -9999): - drag_start_pos = current_pos - var screen := DisplayServer.window_get_current_screen() - var usable := DisplayServer.screen_get_usable_rect(screen) - var right_edge := current_pos.x + window_size.x - var bottom_edge := current_pos.y + window_size.y - if current_pos.x - usable.position.x <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: - DisplayServer.window_set_position(Vector2i(usable.position.x, current_pos.y)) - edge_snap_cooldown = 0.15 - elif usable.position.x + usable.size.x - right_edge <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: - DisplayServer.window_set_position(Vector2i(int(usable.position.x + usable.size.x - window_size.x), current_pos.y)) - edge_snap_cooldown = 0.15 - elif current_pos.y - usable.position.y <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: - DisplayServer.window_set_position(Vector2i(current_pos.x, usable.position.y)) - edge_snap_cooldown = 0.15 - elif usable.position.y + usable.size.y - bottom_edge <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: - DisplayServer.window_set_position(Vector2i(current_pos.x, int(usable.position.y + usable.size.y - window_size.y))) - edge_snap_cooldown = 0.15 + # Only poll window position/size while the mouse is actually down — + # edge snapping is a drag-only behavior and the OS queries aren't free. + if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): + var current_pos := DisplayServer.window_get_position() + var window_size := DisplayServer.window_get_size() + if current_pos != drag_start_pos: + if drag_start_pos == Vector2i(-9999, -9999): + drag_start_pos = current_pos + var screen := DisplayServer.window_get_current_screen() + var usable := DisplayServer.screen_get_usable_rect(screen) + var right_edge := current_pos.x + window_size.x + var bottom_edge := current_pos.y + window_size.y + if current_pos.x - usable.position.x <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: + DisplayServer.window_set_position(Vector2i(usable.position.x, current_pos.y)) + edge_snap_cooldown = 0.15 + elif usable.position.x + usable.size.x - right_edge <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: + DisplayServer.window_set_position(Vector2i(int(usable.position.x + usable.size.x - window_size.x), current_pos.y)) + edge_snap_cooldown = 0.15 + elif current_pos.y - usable.position.y <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: + DisplayServer.window_set_position(Vector2i(current_pos.x, usable.position.y)) + edge_snap_cooldown = 0.15 + elif usable.position.y + usable.size.y - bottom_edge <= EDGE_SNAP_THRESHOLD and edge_snap_cooldown <= 0.0: + DisplayServer.window_set_position(Vector2i(current_pos.x, int(usable.position.y + usable.size.y - window_size.y))) + edge_snap_cooldown = 0.15 render_worker_overlay() func choose_task(worker: Dictionary) -> Dictionary: - var effective_order := ColonyStance.get_effective_priority_order(colony_stance, priority_order) - for kind in effective_order: - var tasks: Array[Dictionary] = tasks_for_kind(String(kind)) - if tasks.is_empty(): - continue - # Bias toward food gathering when food is low (issue #147) - if String(kind) == "gather" and should_bias_to_food_gathering(): - tasks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - var a_is_food := String(a.get("resource", "")) == "food" - var b_is_food := String(b.get("resource", "")) == "food" - if a_is_food and not b_is_food: - return true - if not a_is_food and b_is_food: - return false - return task_distance(worker, a) < task_distance(worker, b) - ) - elif String(kind) == "gather_food": - # Food stance: sort food gather tasks first - tasks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - var a_is_food := ColonyStance.is_food_gather_task(a) - var b_is_food := ColonyStance.is_food_gather_task(b) - if a_is_food and not b_is_food: - return true - if not a_is_food and b_is_food: - return false - return task_distance(worker, a) < task_distance(worker, b) - ) - else: - tasks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return task_distance(worker, a) < task_distance(worker, b) - ) - var chosen := tasks[0] - if (String(chosen.kind) == "gather" or String(chosen.kind) == "gather_food") and chosen.has("resource"): - reserve_resource(String(chosen.resource)) - return chosen - return {} - -func tasks_for_kind(kind: String) -> Array[Dictionary]: - match kind: - "build": - return gather_build_tasks() - "haul": - return gather_haul_tasks() - "gather", "gather_food": - return gather_gather_tasks() - return [] - -func task_distance(worker: Dictionary, task: Dictionary) -> int: - var pos := data_to_vec(worker.pos) - var target := data_to_vec(task.target) - return abs(pos.x - target.x) + abs(pos.y - target.y) - -func gather_build_tasks() -> Array[Dictionary]: - var tasks: Array[Dictionary] = [] - for build in state.builds: - if not bool(build.complete) and has_costs_delivered(build): - tasks.append({"kind": "build", "build_id": int(build.id), "target": build.pos}) - return tasks - -func gather_haul_tasks() -> Array[Dictionary]: - var tasks: Array[Dictionary] = [] - for build in state.builds: - if bool(build.complete): - continue - for resource in BUILD_COSTS[String(build.kind)].keys(): - var reserved := int(build.get("reserved", {}).get(resource, 0)) - var need := int(BUILD_COSTS[String(build.kind)][resource]) - int(build.delivered.get(resource, 0)) - reserved - if need > 0 and int(state.resources.get(resource, 0)) > 0: - tasks.append({"kind": "haul", "build_id": int(build.id), "target": vec_to_data(stockpile_pos), "resource": resource}) - return tasks - -func _clean_stale_reservations() -> void: - # Remove reservations from builds that have no active haul tasks targeting them. - # This handles: build completion, build deletion, worker break/cleanup. - for build in state.builds: - if bool(build.complete): - continue - var has_haul := false - for worker in state.workers: - if not worker.task.is_empty() and String(worker.task.kind) == "haul": - if int(worker.task.get("build_id", -1)) == int(build.id): - has_haul = true - break - if not has_haul and build.has("reserved"): - var reserved: Dictionary = build.reserved - for resource in reserved.keys(): - state.resources[resource] = int(state.resources.get(resource, 0)) + int(reserved[resource]) - build.erase("reserved") - _mark_dirty() - + return sim.choose_task(worker) -func gather_gather_tasks() -> Array[Dictionary]: - var tasks: Array[Dictionary] = [] - for y in grid_h: - for x in grid_w: - var pos := Vector2i(x, y) - var tile := get_tile(pos) - if ["tree", "rock", "berries"].has(String(tile.kind)) and int(tile.amount) > 0: - # Skip if resource is fully reserved (reserved >= available on tile) - var resource := String(tile.resource) - var reserved := get_reserved(resource) - var total_available := count_total_resource(resource) - if reserved >= total_available: - continue - tasks.append({"kind": "gather", "target": vec_to_data(pos), "resource": tile.resource}) - return tasks - -func count_total_resource(resource: String) -> int: - var total := 0 - for y in grid_h: - for x in grid_w: - var pos := Vector2i(x, y) - var tile := get_tile(pos) - if String(tile.resource) == resource and ["tree", "rock", "berries"].has(String(tile.kind)): - total += int(tile.amount) - return total - -func step_worker(worker: Dictionary) -> void: - var task: Dictionary = worker.task - if task.is_empty(): - return - var target := data_to_vec(task.target) - if String(task.kind) == "haul" and int(worker.carrying.get(String(task.resource), 0)) > 0: - var build := get_build(int(task.build_id)) - if not build.is_empty(): - target = data_to_vec(build.pos) - var current := data_to_vec(worker.pos) - if current != target: - worker.pos = vec_to_data(step_toward(current, target)) - return - match String(task.kind): - "gather": do_gather(worker, task) - "haul": do_haul(worker, task) - "build": do_build(worker, task) func do_gather(worker: Dictionary, task: Dictionary) -> void: - var target := data_to_vec(task.target) - var tile := get_tile(target) - if int(tile.amount) <= 0: - worker.task = {} - release_resource(String(task.resource)) - return - tile.amount = int(tile.amount) - 1 - worker.carrying[String(tile.resource)] = int(worker.carrying.get(String(tile.resource), 0)) + 1 - state.harvested[String(task.resource)] = int(state.get("harvested", {}).get(String(task.resource), 0)) + 1 - if int(tile.amount) <= 0: - tile.kind = "ground" - tile.resource = "" - set_tile(target, tile) - _mark_dirty() - # Release reservation — resource is now in worker's possession - release_resource(String(task.resource)) - worker.task = {"kind": "haul", "target": vec_to_data(stockpile_pos), "resource": task.resource, "build_id": -1} + sim.do_gather(worker, task) -func do_haul(worker: Dictionary, task: Dictionary) -> void: - var resource := String(task.resource) - var carried := int(worker.carrying.get(resource, 0)) - if carried > 0: - if int(task.build_id) >= 0: - var build := get_build(int(task.build_id)) - if not build.is_empty() and not bool(build.complete): - # Clamp delivery to remaining need (delivered + reserved already account for committed units) - var reserved := int(build.get("reserved", {}).get(resource, 0)) - var cost := int(BUILD_COSTS[String(build.kind)][resource]) - var total_needed := cost - int(build.delivered.get(resource, 0)) - reserved - var deliver := mini(carried, maxf(total_needed, 0)) - build.delivered[resource] = int(build.delivered.get(resource, 0)) + deliver - # Refund excess back to stockpile - var excess := carried - deliver - if excess > 0: - state.resources[resource] = int(state.resources.get(resource, 0)) + excess - _mark_dirty() - # Release reservation for delivered amount - if deliver > 0: - reserved = maxf(reserved - deliver, 0) - build["reserved"] = build.get("reserved", {}) - build.reserved[resource] = reserved - set_build(int(task.build_id), build) - else: - state.resources[resource] = int(state.resources.get(resource, 0)) + carried - _mark_dirty() - else: - state.resources[resource] = int(state.resources.get(resource, 0)) + carried - _mark_dirty() - worker.carrying[resource] = 0 - worker.task = {} - return - if data_to_vec(worker.pos) == stockpile_pos and int(state.resources.get(resource, 0)) > 0 and int(task.build_id) >= 0: - var build := get_build(int(task.build_id)) - if not build.is_empty() and not bool(build.complete): - state.resources[resource] = int(state.resources.get(resource, 0)) - 1 - worker.carrying[resource] = 1 - # Reserve this unit for the build - var reserved := int(build.get("reserved", {}).get(resource, 0)) - if not build.has("reserved"): - build["reserved"] = {} - build.reserved[resource] = reserved + 1 - _mark_dirty() - set_build(int(task.build_id), build) - worker.task.target = build.pos - else: - # Build gone or complete — clear task, resource stays in stockpile - worker.task = {} - return - worker.task = {} -func do_build(worker: Dictionary, task: Dictionary) -> void: - var build := get_build(int(task.build_id)) - if build.is_empty() or bool(build.complete): - worker.task = {} - return - build.progress = float(build.progress) + structure_build_speed(String(build.kind)) - if float(build.progress) >= 1.0: - build.complete = true - set_tile(data_to_vec(build.pos), {"kind": build.kind, "amount": 0, "resource": "", "build_kind": ""}) - apply_structure_bonus(String(build.kind)) - push_event("%s finished. The colony looks slightly more legitimate." % cap(String(build.kind))) - set_build(int(task.build_id), build) - _mark_dirty() - worker.task = {} +func do_haul(worker: Dictionary, task: Dictionary) -> void: + sim.do_haul(worker, task) func begin_build_placement(kind: String) -> void: if not is_structure_unlocked(kind): @@ -1687,10 +1310,7 @@ func begin_build_placement(kind: String) -> void: world_label.text = "Colony • placing %s • %s" % [cap(kind), build_cost_text(kind)] status_label.text = build_preview_text(kind) push_event("Placement mode: click a ground tile for %s." % cap(kind)) - sidebar_scroll.visible = false - menu_actions.visible = false - management_panels.visible = false - settings_panel.visible = false + hide_all_popups() apply_dock_position() update_menu_button_text() render_all() @@ -1701,11 +1321,14 @@ func handle_tile_click(index: int) -> void: var pos := Vector2i(index % grid_w, index / grid_w) place_structure_at(pos, pending_build_kind) +func is_near_stockpile(pos: Vector2i) -> bool: + return sim.is_near_stockpile(pos) + func place_structure_at(pos: Vector2i, kind: String) -> void: if String(get_tile(pos).kind) != "ground": push_event("That tile is busy. Pick open ground for %s." % cap(kind)) return - if abs(pos.x - stockpile_pos.x) + abs(pos.y - stockpile_pos.y) <= 1: + if is_near_stockpile(pos): push_event("Leave some breathing room around the stockpile.") return if not is_structure_unlocked(kind): @@ -1717,12 +1340,15 @@ func queue_structure_at(pos: Vector2i, kind: String) -> void: if pos == Vector2i(-1, -1): push_event("No room for %s. Dense urban planning strikes again." % kind) return + var zero_costs := {} + for resource in BUILD_COSTS.get(kind, {}).keys(): + zero_costs[resource] = 0 var build := { "id": int(state.next_build_id), "kind": kind, "pos": vec_to_data(pos), - "delivered": {"wood": 0, "stone": 0}, - "reserved": {"wood": 0, "stone": 0}, + "delivered": zero_costs.duplicate(), + "reserved": zero_costs.duplicate(), "progress": 0.0, "complete": false, } @@ -1732,10 +1358,7 @@ func queue_structure_at(pos: Vector2i, kind: String) -> void: set_tile(pos, {"kind": "foundation", "amount": 0, "resource": "", "build_kind": kind}) push_event("%s queued. The workers will fake having a plan." % cap(kind)) pending_build_kind = "" - sidebar_scroll.visible = false - menu_actions.visible = false - management_panels.visible = false - settings_panel.visible = false + hide_all_popups() hover_tile_index = -1 world_label.text = "Colony" apply_dock_position() @@ -1748,10 +1371,7 @@ func cancel_build_placement() -> void: return var kind := pending_build_kind pending_build_kind = "" - sidebar_scroll.visible = false - menu_actions.visible = false - management_panels.visible = false - settings_panel.visible = false + hide_all_popups() hover_tile_index = -1 world_label.text = "Colony" if not kind.is_empty(): @@ -1760,74 +1380,29 @@ func cancel_build_placement() -> void: update_menu_button_text() render_all() -func maybe_fire_event() -> void: - if tick % EVENT_INTERVAL_TICKS != 0: - return - var event_roll := rng.randi_range(0, 2) - # If ambient_improve reward is active, convert negative events to positive - if event_roll == 1 and GoalReward.consume_ambient_improve(active_rewards): - event_roll = 0 - push_event("A goal reward smooths things over.") - match event_roll: - 0: - state.resources.food = int(state.resources.get("food", 0)) + 2 - push_event("A neighbor drops off trail mix. Food +2.") - 1: - var worker: Dictionary = state.workers[rng.randi_range(0, state.workers.size() - 1)] - worker.task = {} - worker.break_ticks = 6 - push_event("%s takes a break and stares into the middle distance." % worker.name) - 2: - spawn_resource_drop() - -func spawn_resource_drop() -> void: - var pos := find_open_ground() - if pos == Vector2i(-1, -1): - push_event("A supply crate tried to arrive but urban planning won.") - return - var options: Array[String] = ["tree", "rock", "berries"] - var resource_kind: String = options[rng.randi_range(0, options.size() - 1)] - match resource_kind: - "tree": - set_tile(pos, {"kind": "tree", "amount": 4, "resource": "wood", "build_kind": ""}) - push_event("A driftwood bundle lands nearby. Fresh wood appeared.") - "rock": - set_tile(pos, {"kind": "rock", "amount": 4, "resource": "stone", "build_kind": ""}) - "berries": - set_tile(pos, {"kind": "berries", "amount": 3, "resource": "food", "build_kind": ""}) - push_event("A snack crate lands nearby. Fresh food appeared.") - if resource_kind == "rock": - push_event("A rubble drop lands nearby. Fresh stone appeared.") - -func apply_structure_bonus(kind: String) -> void: - match kind: - "hut": - state.resources.food = int(state.resources.get("food", 0)) + 1 - _mark_dirty() - "garden": - state.resources.food = int(state.resources.get("food", 0)) + 3 - _mark_dirty() - -func structure_build_speed(kind: String) -> float: - var speed := 0.34 - if kind != "workshop" and is_structure_complete("workshop"): - speed += 0.16 - # Apply food-based slowdown (issue #147) - speed *= get_food_slowdown_factor() - # Apply goal reward build speed bonus - speed += GoalReward.get_build_speed_bonus(active_rewards) - return speed - func render_all() -> void: - render_crew_panel() + # Always-visible dock chrome. render_world() render_worker_overlay() + render_header() render_goal() - render_sidebar() render_hud_row() render_event_drawer() - render_build_buttons() - render_stance_toggle() + # Popup content only needs rendering while the popup is open; every path + # that opens it calls render_all() again. + if sidebar_scroll.visible: + render_crew_panel() + render_sidebar() + render_build_buttons() + render_stance_toggle() + +## Header labels that live in the always-visible dock strip. +func render_header() -> void: + resource_label.text = stockpile_summary_text(false) + # Save current resources for trend comparison next tick + prev_resources = {"wood": int(state.resources.get("wood", 0)), "stone": int(state.resources.get("stone", 0)), "food": int(state.resources.get("food", 0))} + status_label.text = settlement_status_text() + world_label.text = "Colony" if pending_build_kind.is_empty() else "Colony • click ground for %s" % cap(pending_build_kind) func render_hud_row() -> void: @@ -1856,63 +1431,82 @@ func render_hud_row() -> void: # Active goal progress — show compactly in HUD row if is_instance_valid(hud_goal_label): if not active_goal.is_empty(): - var goal_type := String(active_goal.get("type", "")) - var progress := int(active_goal.get("current_progress", 0)) - var target := int(active_goal.get("target", {}).get("amount", 0)) - var is_complete := RotatingGoal.is_goal_complete(active_goal) - - var goal_text := "" - match goal_type: - RotatingGoal.GOAL_TYPE_RESOURCE: - var resource := String(active_goal.get("target", {}).get("resource", "")) - goal_text = "Goal: %s" % cap(resource) - RotatingGoal.GOAL_TYPE_BUILD: - var build_kind := String(active_goal.get("target", {}).get("build_kind", "")) - goal_text = "Build: %s" % cap(build_kind) - RotatingGoal.GOAL_TYPE_BUILD_COMPLETE: - goal_text = "Goal: Finish a build" - - # Add progress only when useful (not at 0, not complete) - if target > 0 and progress > 0 and not is_complete: - goal_text += " (%d/%d)" % [progress, target] - elif is_complete: - goal_text += " ✓" - - hud_goal_label.text = goal_text + hud_goal_label.text = goal_summary_text(true) hud_goal_label.visible = true else: hud_goal_label.visible = false +## Shared goal-text formatting for the HUD row (compact) and sidebar goal label. +func goal_summary_text(compact: bool) -> String: + var goal_type := String(active_goal.get("type", "")) + var progress := int(active_goal.get("current_progress", 0)) + var target := int(active_goal.get("target", {}).get("amount", 0)) + var goal_text := "" + match goal_type: + RotatingGoal.GOAL_TYPE_RESOURCE: + var resource := String(active_goal.get("target", {}).get("resource", "")) + goal_text = "Goal: %s" % cap(resource) if compact else "Goal: Reach %d %s" % [target, resource] + RotatingGoal.GOAL_TYPE_BUILD: + var build_kind := String(active_goal.get("target", {}).get("build_kind", "")) + goal_text = ("Build: %s" if compact else "Goal: Build %s") % cap(build_kind) + RotatingGoal.GOAL_TYPE_BUILD_COMPLETE: + goal_text = "Goal: Finish a build" + + # Add progress only when useful (not at 0, not complete) + var is_complete := RotatingGoal.is_goal_complete(active_goal) + if target > 0 and progress > 0 and not is_complete: + goal_text += " (%d/%d)" % [progress, target] + elif is_complete: + goal_text += " ✓" + return goal_text + func render_world() -> void: - for y in grid_h: - for x in grid_w: - var index := y * grid_w + x - var view := tile_views[index] - var pos := Vector2i(x, y) - var tile := get_tile(pos) - var panel: Panel = view.panel - var icon_label: Label = view.icon - var amount_label: Label = view.amount - var progress_label: Label = view.progress - panel.add_theme_stylebox_override("panel", tile_style(tile, pos)) - icon_label.text = tile_icon(tile, pos) - amount_label.text = tile_amount_text(tile, pos) - amount_label.visible = hover_tile_index == index - progress_label.text = "" + for index in tile_views.size(): + render_tile(index) + +func render_tile(index: int) -> void: + if index < 0 or index >= tile_views.size(): + return + var view := tile_views[index] + var pos := Vector2i(index % grid_w, index / grid_w) + var tile := get_tile(pos) + var panel: Panel = view.panel + var icon_label: Label = view.icon + var amount_label: Label = view.amount + var progress_label: Label = view.progress + # Styleboxes come from a cache keyed by look; skip the override (and the + # redraw it forces) when the tile's style hasn't changed. + var style := tile_style(tile, pos) + if view.get("style") != style: + view["style"] = style + panel.add_theme_stylebox_override("panel", style) + icon_label.text = tile_icon(tile, pos) + amount_label.text = tile_amount_text(tile, pos) + amount_label.visible = hover_tile_index == index + progress_label.text = "" + +# Reused per-frame scratch dictionaries and per-sprite caches — this runs in +# _process, so per-frame allocations (string keys, texture lookups) add up. +var _overlay_collision_slots: Dictionary = {} +var _overlay_used_slots: Dictionary = {} +var _overlay_sprite_cache: Dictionary = {} +var _overlay_tile_size := Vector2i.ZERO func render_worker_overlay() -> void: if tile_views.is_empty(): return for child in world_overlay.get_children(): child.visible = false - var collision_slots := {} + _overlay_collision_slots.clear() for worker in state.get("workers", []): - var pos_key := vec_key(data_to_vec(worker.get("pos", vec_to_data(stockpile_pos)))) - collision_slots[pos_key] = int(collision_slots.get(pos_key, 0)) + 1 - var used_slots := {} + var slot_pos := data_to_vec(worker.get("pos", vec_to_data(stockpile_pos))) + _overlay_collision_slots[slot_pos] = int(_overlay_collision_slots.get(slot_pos, 0)) + 1 + _overlay_used_slots.clear() var progress := 1.0 if tick_timer and tick_timer.wait_time > 0.0: progress = clampf(1.0 - (tick_timer.time_left / tick_timer.wait_time), 0.0, 1.0) + var resize_needed := _overlay_tile_size != tile_size + _overlay_tile_size = tile_size for worker in state.get("workers", []): var name := String(worker.get("name", "worker")) var sprite: TextureRect @@ -1924,20 +1518,31 @@ func render_worker_overlay() -> void: sprite.stretch_mode = TextureRect.STRETCH_SCALE world_overlay.add_child(sprite) worker_overlay_nodes[name] = sprite - sprite.custom_minimum_size = Vector2(int(tile_size.x * 0.96), int(tile_size.y * 1.08)) - sprite.size = sprite.custom_minimum_size + sprite.custom_minimum_size = Vector2(int(tile_size.x * 0.96), int(tile_size.y * 1.08)) + sprite.size = sprite.custom_minimum_size + if resize_needed: + sprite.custom_minimum_size = Vector2(int(tile_size.x * 0.96), int(tile_size.y * 1.08)) + sprite.size = sprite.custom_minimum_size sprite.visible = true - sprite.texture = worker_texture(name, worker_anim_frame(worker), carried_resource(worker)) + # Reassign the texture only when the animation frame or cargo changed. + var frame := worker_anim_frame(worker) + var carrying := carried_resource(worker) + var texture_stale := true + if _overlay_sprite_cache.has(name): + var cached: Dictionary = _overlay_sprite_cache[name] + texture_stale = int(cached["frame"]) != frame or String(cached["carrying"]) != carrying + if texture_stale: + sprite.texture = worker_texture(name, frame, carrying) + _overlay_sprite_cache[name] = {"frame": frame, "carrying": carrying} var from_pos := data_to_vec(worker.get("prev_pos", worker.get("pos", vec_to_data(stockpile_pos)))) var to_pos := data_to_vec(worker.get("pos", vec_to_data(stockpile_pos))) var from_center := tile_center(from_pos) var to_center := tile_center(to_pos) var eased := ease(progress, 0.3) var draw_pos := from_center.lerp(to_center, eased) - var pos_key := vec_key(to_pos) - var slot := int(used_slots.get(pos_key, 0)) - used_slots[pos_key] = slot + 1 - draw_pos += worker_collision_offset(slot, int(collision_slots.get(pos_key, 1))) + var slot := int(_overlay_used_slots.get(to_pos, 0)) + _overlay_used_slots[to_pos] = slot + 1 + draw_pos += worker_collision_offset(slot, int(_overlay_collision_slots.get(to_pos, 1))) sprite.position = draw_pos - sprite.custom_minimum_size * 0.5 func worker_collision_offset(slot: int, total: int) -> Vector2: @@ -1971,7 +1576,7 @@ func can_place_at(pos: Vector2i, kind: String) -> bool: return false if String(get_tile(pos).kind) != "ground": return false - if abs(pos.x - stockpile_pos.x) + abs(pos.y - stockpile_pos.y) <= 1: + if is_near_stockpile(pos): return false return is_structure_unlocked(kind) @@ -1993,62 +1598,51 @@ func render_goal() -> void: goal_label.visible = false return goal_label.visible = true + goal_label.text = goal_summary_text(false) - var goal_type := String(active_goal.get("type", "")) - var progress := int(active_goal.get("current_progress", 0)) - var target := int(active_goal.get("target", {}).get("amount", 0)) - - # Format compact goal text matching the spec examples - var goal_text := "" - match goal_type: - RotatingGoal.GOAL_TYPE_RESOURCE: - var resource := String(active_goal.get("target", {}).get("resource", "")) - goal_text = "Goal: Reach %d %s" % [target, resource] - RotatingGoal.GOAL_TYPE_BUILD: - var build_kind := String(active_goal.get("target", {}).get("build_kind", "")) - goal_text = "Goal: Build %s" % cap(build_kind) - RotatingGoal.GOAL_TYPE_BUILD_COMPLETE: - goal_text = "Goal: Finish a build" - - # Add progress when useful (not at 0 and not complete) - var is_complete := RotatingGoal.is_goal_complete(active_goal) - if target > 0 and progress > 0 and not is_complete: - goal_text += " (%d/%d)" % [progress, target] - elif is_complete: - goal_text += " ✓" - - goal_label.text = goal_text +var _crew_rows: Array = [] +var _rendered_event_rev := -1 func render_sidebar() -> void: - var compact_header := anchor_family != "bottom" - resource_label.text = stockpile_summary_text(false) + _render_crew_list() + _render_event_log() - # Save current resources for trend comparison next tick - prev_resources = {"wood": int(state.resources.get("wood", 0)), "stone": int(state.resources.get("stone", 0)), "food": int(state.resources.get("food", 0))} - status_label.text = settlement_status_text(compact_header or anchor_family == "bottom") - activity_label.text = activity_summary_text() - world_label.text = "Colony" if pending_build_kind.is_empty() else "Colony • click ground for %s" % cap(pending_build_kind) - for child in crew_list.get_children(): - child.queue_free() - for worker in state.workers: - var hbox := HBoxContainer.new() - hbox.add_theme_constant_override("separation", 6) - var icon_label := Label.new() - icon_label.text = worker_intent_icon(worker) - icon_label.modulate = Color(1, 1, 1, 0.95) - hbox.add_child(icon_label) - var name_label := Label.new() - name_label.text = "%s" % worker.name - name_label.add_theme_color_override("font_color", WORKER_BADGE_COLORS.get(worker.name, Color.WHITE)) - hbox.add_child(name_label) - var detail_label := Label.new() - detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - var intent_text := worker_intent_text(worker) - detail_label.text = intent_text - detail_label.modulate = Color(0.86, 0.9, 0.95, 0.84) - hbox.add_child(detail_label) - crew_list.add_child(hbox) +## Crew rows are created once per roster change and updated in place — +## rebuilding four nodes per worker every tick was pure churn. +func _render_crew_list() -> void: + var workers: Array = state.get("workers", []) + if _crew_rows.size() != workers.size(): + for child in crew_list.get_children(): + child.queue_free() + _crew_rows.clear() + for worker in workers: + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 6) + var icon_label := Label.new() + icon_label.modulate = Color(1, 1, 1, 0.95) + hbox.add_child(icon_label) + var name_label := Label.new() + hbox.add_child(name_label) + var detail_label := Label.new() + detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + detail_label.modulate = Color(0.86, 0.9, 0.95, 0.84) + hbox.add_child(detail_label) + crew_list.add_child(hbox) + _crew_rows.append({"icon": icon_label, "name": name_label, "detail": detail_label}) + for i in workers.size(): + var worker: Dictionary = workers[i] + var row: Dictionary = _crew_rows[i] + row.icon.text = worker_intent_icon(worker) + row.name.text = String(worker.name) + row.name.add_theme_color_override("font_color", WORKER_BADGE_COLORS.get(worker.name, Color.WHITE)) + row.detail.text = worker_intent_text(worker) + +## The RichTextLabel log only re-renders when the event list actually changed. +func _render_event_log() -> void: + if _rendered_event_rev == sim.event_rev: + return + _rendered_event_rev = sim.event_rev event_log.clear() for entry in state.events: event_log.append_text("t%02d %s\n" % [int(entry.tick), String(entry.text)]) @@ -2078,8 +1672,8 @@ func update_build_preview(kind: String) -> void: func build_cost_text(kind: String) -> String: var costs: Dictionary = BUILD_COSTS.get(kind, {}) var parts: Array[String] = [] - for resource in ["wood", "stone"]: - var amount := int(costs.get(resource, 0)) + for resource in costs.keys(): + var amount := int(costs[resource]) if amount > 0: parts.append("%d %s" % [amount, resource]) return "free" if parts.is_empty() else " / ".join(parts) @@ -2087,8 +1681,8 @@ func build_cost_text(kind: String) -> String: func missing_build_resources(kind: String) -> Array[String]: var missing: Array[String] = [] var costs: Dictionary = BUILD_COSTS.get(kind, {}) - for resource in ["wood", "stone"]: - var shortage := int(costs.get(resource, 0)) - int(state.get("resources", {}).get(resource, 0)) + for resource in costs.keys(): + var shortage := int(costs[resource]) - int(state.get("resources", {}).get(resource, 0)) if shortage > 0: missing.append("%d %s" % [shortage, resource]) return missing @@ -2121,9 +1715,8 @@ func tile_icon(tile: Dictionary, pos: Vector2i) -> String: if not build.is_empty() and has_costs_delivered(build) and tick % 2 == 0: return "🔨" return "🏗" - "hut": return "🏠" - "workshop": return "🛠" - "garden": return "🪴" + "hut", "workshop", "garden": + return structure_icon(String(tile.kind)) _: return "" @@ -2146,57 +1739,6 @@ func tile_amount_text(tile: Dictionary, pos: Vector2i) -> String: _: return "" -func tile_progress_text(tile: Dictionary, pos: Vector2i) -> String: - if not pending_build_kind.is_empty() and pos == hovered_tile_pos(): - return "place" if can_place_at(pos, pending_build_kind) else "blocked" - if String(tile.kind) != "foundation": - var workers_here := workers_at_pos(pos) - if not workers_here.is_empty(): - return worker_tile_status(workers_here[0]) - if String(tile.kind) == "ground": - return ["open", "path", "wind"][(tick + pos.x * 2 + pos.y) % 3] - return "" - var build := get_build_at_pos(pos) - if build.is_empty(): - return "queued" - if not has_costs_delivered(build): - var delivered: Dictionary = build.delivered - return "%dw %ds" % [int(delivered.get("wood", 0)), int(delivered.get("stone", 0))] - return "%d%%" % int(round(float(build.get("progress", 0.0)) * 100.0)) - -func worker_tile_status(worker: Dictionary) -> String: - if int(worker.get("break_ticks", 0)) > 0: - return "rest" - var task: Dictionary = worker.get("task", {}) - if task.is_empty(): - return "idle" - match String(task.kind): - "gather": - return "gather" - "haul": - return "haul" - "build": - return "build" - return String(task.kind) - -func workers_at_pos(pos: Vector2i) -> Array: - var workers_here: Array = [] - for worker in state.workers: - if data_to_vec(worker.pos) == pos: - workers_here.append(worker) - return workers_here - -func render_worker_sprites(container: HBoxContainer, workers_here: Array) -> void: - for child in container.get_children(): - child.queue_free() - for worker in workers_here: - var sprite := TextureRect.new() - sprite.custom_minimum_size = Vector2(int(tile_size.x * 0.62), int(tile_size.y * 0.7)) - sprite.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - sprite.stretch_mode = TextureRect.STRETCH_SCALE - sprite.texture = worker_texture(String(worker.name), worker_anim_frame(worker), carried_resource(worker)) - container.add_child(sprite) - func carried_resource(worker: Dictionary) -> String: var carrying: Dictionary = worker.get("carrying", {}) for key in ["stone", "wood", "food"]: @@ -2268,62 +1810,40 @@ func worker_texture(name: String, frame: int, carrying: String = "") -> Texture2 worker_texture_cache[cache_key] = texture return texture +# Style/context plumbing for TileRender. The theme/context dictionaries are +# reused across calls, and finished styleboxes are cached by (kind, accent) — +# the palette is small and fixed, so the cache stays tiny. +var _tile_style_cache: Dictionary = {} +var _tile_style_theme := {"TILE_BACKDROPS": TILE_BACKDROPS, "stockpile_pos": Vector2i.ZERO} +var _tile_accent_ctx: Dictionary = {} +var _tile_accent_theme := {"RESOURCE_COLORS": RESOURCE_COLORS, "STRUCTURE_COLORS": STRUCTURE_COLORS} + ## Delegates to TileRender.tile_style, passing scene state via context. func tile_style(tile: Dictionary, pos: Vector2i) -> StyleBoxFlat: var accent := tile_accent(tile, pos) - var render_theme := { - "TILE_BACKDROPS": TILE_BACKDROPS, - "stockpile_pos": stockpile_pos, - } - return TileRender.tile_style(tile, pos, render_theme, accent) + var kind := String(tile.kind) + if pos == stockpile_pos: + kind = "stockpile" + var key := [kind, accent] + if _tile_style_cache.has(key): + return _tile_style_cache[key] + _tile_style_theme["stockpile_pos"] = stockpile_pos + var style := TileRender.tile_style(tile, pos, _tile_style_theme, accent) + _tile_style_cache[key] = style + return style ## Delegates to TileRender.tile_accent, passing scene state via context. func tile_accent(tile: Dictionary, pos: Vector2i) -> Color: - var ctx := { - "pending_build_kind": pending_build_kind, - "hover_pos": hovered_tile_pos(), - "stockpile_pos": stockpile_pos, - "can_place_fn": can_place_at, - } - var render_theme := { - "RESOURCE_COLORS": RESOURCE_COLORS, - "STRUCTURE_COLORS": STRUCTURE_COLORS, - } - return TileRender.tile_accent(tile, pos, ctx, render_theme) + _tile_accent_ctx["pending_build_kind"] = pending_build_kind + _tile_accent_ctx["hover_pos"] = hovered_tile_pos() + _tile_accent_ctx["stockpile_pos"] = stockpile_pos + if not _tile_accent_ctx.has("can_place_fn"): + _tile_accent_ctx["can_place_fn"] = can_place_at + return TileRender.tile_accent(tile, pos, _tile_accent_ctx, _tile_accent_theme) -func task_name(worker: Dictionary) -> String: - if int(worker.get("break_ticks", 0)) > 0: - return "taking five" - var task: Dictionary = worker.task - if task.is_empty(): - return "idle" - match String(task.kind): - "gather": - return "gathering %s" % String(task.get("resource", "supplies")) - "haul": - if int(task.get("build_id", -1)) >= 0: - var build := get_build(int(task.build_id)) - if not build.is_empty(): - return "hauling %s to %s" % [String(task.get("resource", "goods")), String(build.kind)] - return "returning %s" % String(task.get("resource", "goods")) - "build": - var build := get_build(int(task.get("build_id", -1))) - if not build.is_empty(): - return "building %s" % String(build.kind) - return "building" - return String(task.kind) - -func carrying_name(carrying: Dictionary) -> String: - var parts := [] - for key in carrying.keys(): - var amount := int(carrying[key]) - if amount > 0: - parts.append("%d %s" % [amount, key]) - return ", ".join(parts) if not parts.is_empty() else "hands free" - -func settlement_status_text(compact: bool = false) -> String: +func settlement_status_text() -> String: var queued := 0 var building := 0 var idle := 0 @@ -2352,8 +1872,6 @@ func settlement_status_text(compact: bool = false) -> String: var reward_text = RotatingGoal.get_reward_preview_text(active_goal) if not reward_text.is_empty(): status += " • " + reward_text - if compact: - return status + "\nNext: " + next_unlock return status + "\nNext: " + next_unlock func next_unlock_text() -> String: @@ -2375,36 +1893,17 @@ func is_save_compatible(loaded: Dictionary) -> bool: return false return true -func find_open_ground() -> Vector2i: - for y in grid_h: - for x in grid_w: - var pos := Vector2i(x, y) - if abs(pos.x - stockpile_pos.x) + abs(pos.y - stockpile_pos.y) <= 1: - continue - if String(get_tile(pos).kind) == "ground": - return pos - return Vector2i(-1, -1) - func is_pos_in_bounds(pos: Vector2i) -> bool: - return pos.x >= 0 and pos.x < grid_w and pos.y >= 0 and pos.y < grid_h + return sim.is_pos_in_bounds(pos) func has_costs_delivered(build: Dictionary) -> bool: - for resource in BUILD_COSTS[String(build.kind)].keys(): - if int(build.delivered.get(resource, 0)) < int(BUILD_COSTS[String(build.kind)][resource]): - return false - return true + return sim.has_costs_delivered(build) func is_structure_unlocked(kind: String) -> bool: - var unlock: Variant = BUILD_UNLOCKS.get(kind, true) - if typeof(unlock) == TYPE_BOOL and bool(unlock): - return true - return is_structure_complete(String(unlock)) + return sim.is_structure_unlocked(kind) func is_structure_complete(kind: String) -> bool: - for build in state.builds: - if String(build.kind) == kind and bool(build.complete): - return true - return false + return sim.is_structure_complete(kind) func toggle_event_drawer() -> void: @@ -2413,10 +1912,15 @@ func toggle_event_drawer() -> void: render_all() +var _drawer_event_rev := -1 + func render_event_drawer() -> void: """Render the compact event drawer: collapsed label + expanded log.""" if not is_instance_valid(event_drawer_label): return + if _drawer_event_rev == sim.event_rev: + return + _drawer_event_rev = sim.event_rev # Update collapsed label with latest event var events = state.get("events", []) @@ -2436,110 +1940,44 @@ func render_event_drawer() -> void: func push_event(text: String) -> void: - state.events.push_front({"tick": tick, "text": text}) - while state.events.size() > MAX_EVENT_LOG: - state.events.pop_back() - _mark_dirty() + sim.push_event(text) -func persist() -> void: - if not _dirty: +## Save the colony. Debounced on the tick path; pass force=true for explicit +## user actions (save/recruit/placement) and on quit so nothing is lost. +func persist(force := false) -> void: + if not sim.dirty: + return + if not force and tick - _last_persist_tick < PERSIST_INTERVAL_TICKS: return - _dirty = false + sim.dirty = false + _last_persist_tick = tick state["priority_order"] = priority_order.duplicate() - state["colony_stance"] = colony_stance state["dock_anchor"] = String(settings.get("dock_anchor", "bottom")) - state.tick = tick state["save_version"] = GameState.SAVE_VERSION if not state.has("reserved_resources"): state["reserved_resources"] = {} - # Persist active goal state and completed IDs for goal rotation - if not active_goal.is_empty(): - state["active_goal"] = active_goal.duplicate(true) - state["completed_goal_ids"] = completed_goal_ids.duplicate() - # Persist active rewards for goal reward system - state["active_rewards"] = active_rewards.duplicate(true) - # Persist milestone state (issue #237) - state["current_milestone_id"] = current_milestone_id - state["completed_milestone_ids"] = completed_milestone_ids.duplicate() GameState.save_game(state) func get_tile(pos: Vector2i) -> Dictionary: - return state.tiles[pos.y * grid_w + pos.x] + return sim.get_tile(pos) func set_tile(pos: Vector2i, data: Dictionary) -> void: - state.tiles[pos.y * grid_w + pos.x] = data - _mark_dirty() + sim.set_tile(pos, data) func get_build(id: int) -> Dictionary: - for build in state.builds: - if int(build.id) == id: - return build - return {} + return sim.get_build(id) func get_build_at_pos(pos: Vector2i) -> Dictionary: - for build in state.builds: - if data_to_vec(build.pos) == pos and not bool(build.complete): - return build - return {} - -func set_build(id: int, updated: Dictionary) -> void: - for i in state.builds.size(): - if int(state.builds[i].id) == id: - state.builds[i] = updated - return - -func step_toward(from: Vector2i, to: Vector2i) -> Vector2i: - if from.x != to.x: - return Vector2i(from.x + signi(to.x - from.x), from.y) - if from.y != to.y: - return Vector2i(from.x, from.y + signi(to.y - from.y)) - return from + return sim.get_build_at_pos(pos) func data_to_vec(data: Variant) -> Vector2i: - if data is Dictionary: - return Vector2i(int(data.x), int(data.y)) - return Vector2i.ZERO + return ColonySim.data_to_vec(data) func vec_to_data(pos: Vector2i) -> Dictionary: - return {"x": pos.x, "y": pos.y} - -func vec_key(pos: Vector2i) -> String: - return "%d,%d" % [pos.x, pos.y] - - -func reserve_resource(resource: String, amount: int = 1) -> void: - if not state.has("reserved_resources"): - state["reserved_resources"] = {} - var current := int(state.reserved_resources.get(resource, 0)) - state.reserved_resources[resource] = current + amount - -func release_resource(resource: String, amount: int = 1) -> void: - if not state.has("reserved_resources"): - return - var current := maxi(0, int(state.reserved_resources.get(resource, 0)) - amount) - state.reserved_resources[resource] = current - _mark_dirty() + return ColonySim.vec_to_data(pos) func get_reserved(resource: String) -> int: - if not state.has("reserved_resources"): - 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 + return sim.get_reserved(resource) # ── Worker intent icons and text (issue #136) ──────────────────────────────── @@ -2555,28 +1993,24 @@ func worker_intent_icon(worker: Dictionary) -> String: match kind: "gather": var resource := String(task.get("resource", "")) - match resource: - "wood": return Constants.WORKER_INTENT_ICONS.get("gather_wood", "🪓") - "stone": return Constants.WORKER_INTENT_ICONS.get("gather_stone", "⛏") - "food": return Constants.WORKER_INTENT_ICONS.get("gather_food", "🫐") - return Constants.WORKER_INTENT_ICONS.get("idle", "💤") + return Constants.WORKER_INTENT_ICONS.get( + "gather_%s" % resource, Constants.WORKER_INTENT_ICONS.get("idle", "💤")) "haul": return Constants.WORKER_INTENT_ICONS.get("haul", "📦") "build": - var build_kind := String(task.get("build_kind", "")) - if build_kind.is_empty(): - var build_id := int(task.get("build_id", -1)) - for b in state.get("builds", []): - if int(b.id) == build_id: - build_kind = String(b.kind) - break - match build_kind: - "hut": return Constants.WORKER_INTENT_ICONS.get("build_hut", "🏗") - "workshop": return Constants.WORKER_INTENT_ICONS.get("build_workshop", "🏗") - "garden": return Constants.WORKER_INTENT_ICONS.get("build_garden", "🏗") - return Constants.WORKER_INTENT_ICONS.get("build_hut", "🏗") + var build_kind := _task_build_kind(task) + return Constants.WORKER_INTENT_ICONS.get( + "build_%s" % build_kind, Constants.WORKER_INTENT_ICONS.get("build_hut", "🏗")) return Constants.WORKER_INTENT_ICONS.get("idle", "💤") +## Resolve the structure kind a task targets, from the task itself or its build. +func _task_build_kind(task: Dictionary) -> String: + var build_kind := String(task.get("build_kind", "")) + if build_kind.is_empty(): + build_kind = String(get_build(int(task.get("build_id", -1))).get("kind", "")) + return build_kind + + func worker_intent_text(worker: Dictionary) -> String: """Return human-readable intent text for a worker, including idle/blocked reasons.""" if int(worker.get("break_ticks", 0)) > 0: @@ -2589,11 +2023,7 @@ func worker_intent_text(worker: Dictionary) -> String: match kind: "gather": var resource := String(task.get("resource", "")) - match resource: - "wood": return "gathering wood" - "stone": return "gathering stone" - "food": return "gathering food" - return "gathering" + return "gathering %s" % resource if not resource.is_empty() else "gathering" "haul": var resource := String(task.get("resource", "")) if int(task.get("build_id", -1)) >= 0: @@ -2602,14 +2032,7 @@ func worker_intent_text(worker: Dictionary) -> String: return "hauling %s to %s" % [resource, build.kind] return "hauling %s" % resource "build": - var build_kind := String(task.get("build_kind", "")) - if build_kind.is_empty(): - var build_id := int(task.get("build_id", -1)) - for b in state.get("builds", []): - if int(b.id) == build_id: - build_kind = String(b.kind) - break - return "building %s" % build_kind + return "building %s" % _task_build_kind(task) return "working" @@ -2640,7 +2063,7 @@ func worker_idle_reason(worker: Dictionary) -> String: func _mark_dirty() -> void: """Set the dirty flag to indicate game state has changed.""" - _dirty = true + sim.mark_dirty() func cap(text: String) -> String: return text.substr(0, 1).to_upper() + text.substr(1) diff --git a/scripts/render_module.gd b/scripts/render_module.gd deleted file mode 100644 index 656ceeb..0000000 --- a/scripts/render_module.gd +++ /dev/null @@ -1,41 +0,0 @@ -extends RefCounted - -## Render helpers for tile styling and accent colors. - -const Constants := preload("res://scripts/constants.gd") - - -static func tile_style(tile: Dictionary, pos: Vector2i, stockpile_pos: Vector2i, pending_build_kind: String = "", hovered_pos: Vector2i = Vector2i(-1, -1), can_place: bool = false) -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.corner_radius_top_left = 8 - style.corner_radius_top_right = 8 - style.corner_radius_bottom_right = 8 - style.corner_radius_bottom_left = 8 - style.border_width_left = 2 - style.border_width_top = 2 - style.border_width_right = 2 - style.border_width_bottom = 2 - style.content_margin_left = 4 - style.content_margin_top = 3 - style.content_margin_right = 4 - style.content_margin_bottom = 3 - var kind := "stockpile" if pos == stockpile_pos else String(tile.kind) - style.bg_color = Constants.TILE_BACKDROPS.get(kind, Color("#1b2128")) - style.border_color = tile_accent(tile, pos, stockpile_pos, pending_build_kind, hovered_pos, can_place) - style.shadow_color = Color(0, 0, 0, 0.25) - style.shadow_size = 2 - return style - - -static func tile_accent(tile: Dictionary, pos: Vector2i, stockpile_pos: Vector2i, pending_build_kind: String = "", hovered_pos: Vector2i = Vector2i(-1, -1), can_place: bool = false) -> Color: - if not pending_build_kind.is_empty() and pos == hovered_pos: - return Color("#73d38c") if can_place else Color("#d36b6b") - if pos == stockpile_pos: - return Color("#d4b36f") - if Constants.RESOURCE_COLORS.has(String(tile.resource)): - return Constants.RESOURCE_COLORS[String(tile.resource)] - if Constants.STRUCTURE_COLORS.has(String(tile.kind)): - return Constants.STRUCTURE_COLORS[String(tile.kind)] - if String(tile.kind) == "foundation": - return Color("#c7a25e") - return Color(1, 1, 1, 0.35) diff --git a/scripts/rotating_goal.gd b/scripts/rotating_goal.gd index 547409f..6ed75f4 100644 --- a/scripts/rotating_goal.gd +++ b/scripts/rotating_goal.gd @@ -2,6 +2,8 @@ class_name RotatingGoal # Rotating colony goal data model — pure data, no UI. # See misospace/windowstead#142 and #131. +const GoalReward := preload("res://scripts/goal_reward.gd") + const GOAL_TYPE_RESOURCE := "resource" const GOAL_TYPE_BUILD := "build" const GOAL_TYPE_BUILD_COMPLETE := "build_complete" @@ -18,14 +20,16 @@ const GOAL_TYPE_BUILD_COMPLETE := "build_complete" # ── Goal catalog (fixed, deterministic) ────────────────────────────────────── # Each entry is a template; apply_goal_template creates an active goal. +# Reward preview text is derived from GoalReward.REWARD_CATALOG (keyed by the +# same goal IDs) so the two tables can't drift apart. const GOAL_CATALOG := [ - {"id": "gather_wood", "type": GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 10}, "reward": "+1 food"}, - {"id": "gather_stone", "type": GOAL_TYPE_RESOURCE, "target": {"resource": "stone", "amount": 5}, "reward": "+1 food"}, - {"id": "gather_food", "type": GOAL_TYPE_RESOURCE, "target": {"resource": "food", "amount": 8}, "reward": "+1 food"}, - {"id": "build_hut", "type": GOAL_TYPE_BUILD, "target": {"build_kind": "hut"}, "reward": "haul speed +10%"}, - {"id": "build_workshop", "type": GOAL_TYPE_BUILD, "target": {"build_kind": "workshop"}, "reward": "next recruit -1 food"}, - {"id": "build_garden", "type": GOAL_TYPE_BUILD, "target": {"build_kind": "garden"}, "reward": "ambient event improves"}, - {"id": "any_build", "type": GOAL_TYPE_BUILD_COMPLETE, "target": {}, "reward": "+1 food"}, + {"id": "gather_wood", "type": GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 10}}, + {"id": "gather_stone", "type": GOAL_TYPE_RESOURCE, "target": {"resource": "stone", "amount": 5}}, + {"id": "gather_food", "type": GOAL_TYPE_RESOURCE, "target": {"resource": "food", "amount": 8}}, + {"id": "build_hut", "type": GOAL_TYPE_BUILD, "target": {"build_kind": "hut"}}, + {"id": "build_workshop", "type": GOAL_TYPE_BUILD, "target": {"build_kind": "workshop"}}, + {"id": "build_garden", "type": GOAL_TYPE_BUILD, "target": {"build_kind": "garden"}}, + {"id": "any_build", "type": GOAL_TYPE_BUILD_COMPLETE, "target": {}}, ] # ── Create an active goal from a catalog entry ─────────────────────────────── @@ -37,8 +41,9 @@ static func apply_goal_template(template: Dictionary) -> Dictionary: "current_progress": 0, "completed": false, } - if template.has("reward") and not String(template["reward"]).is_empty(): - goal["reward"] = template["reward"] + var reward_label: String = GoalReward.get_reward_label(String(template["id"])) + if not reward_label.is_empty(): + goal["reward"] = reward_label return goal # ── Deterministic goal selection ───────────────────────────────────────────── @@ -90,33 +95,13 @@ static func compute_build_complete_progress(goal: Dictionary, game_state: Dictio # ── Completion detection ───────────────────────────────────────────────────── -# Check if a resource goal is complete. -static func is_resource_complete(goal: Dictionary) -> bool: - if goal.get("type") != GOAL_TYPE_RESOURCE: - return false - return goal.get("current_progress", 0) >= goal.get("target", {}).get("amount", 0) - -# Check if a build goal is complete (at least one build of the target kind). -static func is_build_complete(goal: Dictionary) -> bool: - if goal.get("type") != GOAL_TYPE_BUILD: - return false - return goal.get("current_progress", 0) > 0 - -# Check if a build-complete goal is complete. -static func is_build_complete_goal(goal: Dictionary) -> bool: - if goal.get("type") != GOAL_TYPE_BUILD_COMPLETE: - return false - return goal.get("current_progress", 0) > 0 - # Generic completion check — dispatches by type. static func is_goal_complete(goal: Dictionary) -> bool: match goal.get("type"): GOAL_TYPE_RESOURCE: - return is_resource_complete(goal) - GOAL_TYPE_BUILD: - return is_build_complete(goal) - GOAL_TYPE_BUILD_COMPLETE: - return is_build_complete_goal(goal) + return goal.get("current_progress", 0) >= goal.get("target", {}).get("amount", 0) + GOAL_TYPE_BUILD, GOAL_TYPE_BUILD_COMPLETE: + return goal.get("current_progress", 0) > 0 return false # Mark a goal as completed (no-op reward; just sets flag). diff --git a/scripts/worker_cap_logic.gd b/scripts/worker_cap_logic.gd index 058622b..21ca374 100644 --- a/scripts/worker_cap_logic.gd +++ b/scripts/worker_cap_logic.gd @@ -18,10 +18,5 @@ static func calculate_worker_cap(builds: Array) -> int: ## Check if the colony can recruit another worker. -## - If no workers exist yet, always allow recruitment. -## - Otherwise, compare current count against calculated cap. static func can_recruit(builds: Array, workers: Array) -> bool: - if workers.size() == 0: - return true - var cap: int = calculate_worker_cap(builds) - return workers.size() < cap + return workers.size() < calculate_worker_cap(builds) diff --git a/tests/test_case.gd b/tests/test_case.gd new file mode 100644 index 0000000..8ca0798 --- /dev/null +++ b/tests/test_case.gd @@ -0,0 +1,94 @@ +extends SceneTree +## Shared test harness for all tests/*.gd suites (issue #281). +## +## Each suite extends this script by path and overrides run_tests(): +## +## extends "res://tests/test_case.gd" +## +## func run_tests() -> void: +## assert_eq(1 + 1, 2, "math works") +## +## Conventions: +## - Suites stay individually runnable via +## `godot --headless --path . --script res://tests/.gd`. +## - Output contract (CI greps these): one `TEST : ` line per +## assertion (FAIL lines carry an optional `— ` suffix), then a +## summary line; the process exits non-zero when anything failed. +## - Scripts from scripts/ have no class_name unless noted; access them with +## `const X := preload("res://scripts/x.gd")`, never as bare globals. + +var test_pass := 0 +var test_fail := 0 + +## Suite name shown in the summary; defaults to the script's file name. +var suite_name := "" + + +func _initialize() -> void: + if suite_name.is_empty(): + suite_name = String(get_script().resource_path).get_file().get_basename() + await run_tests() + _finish() + + +## Override in each suite. May use await. +func run_tests() -> void: + assert_true(false, "%s: run_tests() not implemented" % suite_name) + + +func _finish() -> void: + print("") + print("=== %s summary: %d passed, %d failed ===" % [suite_name, test_pass, test_fail]) + if test_fail > 0: + print("FAILURES DETECTED — CI should fail") + quit(1) + else: + print("%s: ok" % suite_name) + quit(0) + + +# ── Assertion vocabulary ────────────────────────────────────────────────────── +# All assertions record the result, print the contract line, and return the +# boolean outcome so callers can branch (e.g. skip dependent checks). + +func assert_true(condition: Variant, name: String, detail: String = "") -> bool: + var ok := bool(condition) + if ok: + test_pass += 1 + print("TEST %s: PASS" % name) + else: + test_fail += 1 + if detail.is_empty(): + print("TEST %s: FAIL" % name) + else: + print("TEST %s: FAIL — %s" % [name, detail]) + return ok + + +func assert_false(condition: Variant, name: String, detail: String = "") -> bool: + return assert_true(not bool(condition), name, detail) + + +func assert_eq(actual: Variant, expected: Variant, name: String) -> bool: + return assert_true(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) + + +func assert_ne(actual: Variant, unexpected: Variant, name: String) -> bool: + return assert_true(actual != unexpected, name, "expected anything but %s" % str(unexpected)) + + +func assert_null(value: Variant, name: String) -> bool: + return assert_true(value == null, name, "expected null, got %s" % str(value)) + + +func assert_not_null(value: Variant, name: String) -> bool: + return assert_true(value != null, name, "expected non-null value") + + +## Works for any value exposing is_empty() (Dictionary, Array, String, ...). +func assert_empty(value: Variant, name: String) -> bool: + return assert_true(value.is_empty(), name, "expected empty, got %s" % str(value)) + + +func assert_not_empty(value: Variant, name: String) -> bool: + return assert_true(not value.is_empty(), name, "expected non-empty value") diff --git a/tests/test_colony_stance.gd b/tests/test_colony_stance.gd index 99eaa1f..edfea8c 100644 --- a/tests/test_colony_stance.gd +++ b/tests/test_colony_stance.gd @@ -1,65 +1,61 @@ -extends SceneTree +extends "res://tests/test_case.gd" # ── Colony Stance Tests (issue #140) ──────────────────────────────────────── # Verifies stance weighting, effective priority order, and persistence. -var test_pass := 0 -var test_fail := 0 +const Stance := preload("res://scripts/colony_stance.gd") -func _initialize() -> void: - # Preload the scripts we need - var stance_script: GDScript = preload("res://scripts/colony_stance.gd") - var main_script: GDScript = preload("res://scripts/main.gd") +func run_tests() -> void: # ── Test 1: get_effective_priority_order for balanced stance ── print("") print("--- stance: effective priority order ---") var player_order: Array[String] = ["build", "haul", "gather"] - + # Balanced stance should return player order unchanged - var balanced_order := stance_script.get_effective_priority_order(stance_script.STANCE_BALANCED, player_order) - _assert_eq(balanced_order.size(), 3, "balanced: has 3 entries") - _assert_eq(balanced_order[0], "build", "balanced: first is build") - _assert_eq(balanced_order[1], "haul", "balanced: second is haul") - _assert_eq(balanced_order[2], "gather", "balanced: third is gather") + var balanced_order := Stance.get_effective_priority_order(Stance.STANCE_BALANCED, player_order) + assert_eq(balanced_order.size(), 3, "balanced: has 3 entries") + assert_eq(balanced_order[0], "build", "balanced: first is build") + assert_eq(balanced_order[1], "haul", "balanced: second is haul") + assert_eq(balanced_order[2], "gather", "balanced: third is gather") # Empty stance string should also return player order unchanged - var empty_order := stance_script.get_effective_priority_order("", player_order) - _assert_eq(empty_order.size(), 3, "empty stance: has 3 entries") - _assert_eq(empty_order[0], "build", "empty stance: first is build") + var empty_order := Stance.get_effective_priority_order("", player_order) + assert_eq(empty_order.size(), 3, "empty stance: has 3 entries") + assert_eq(empty_order[0], "build", "empty stance: first is build") # ── Test 2: Build stance puts build first ── - var build_order := stance_script.get_effective_priority_order(stance_script.STANCE_BUILD, player_order) - _assert_eq(build_order.size(), 3, "build stance: has 3 entries") - _assert_eq(build_order[0], "build", "build stance: first is build") + var build_order := Stance.get_effective_priority_order(Stance.STANCE_BUILD, player_order) + assert_eq(build_order.size(), 3, "build stance: has 3 entries") + assert_eq(build_order[0], "build", "build stance: first is build") # ── Test 3: Gather stance puts gather first ── - var gather_order := stance_script.get_effective_priority_order(stance_script.STANCE_GATHER, player_order) - _assert_eq(gather_order.size(), 3, "gather stance: has 3 entries") - _assert_eq(gather_order[0], "gather", "gather stance: first is gather") + var gather_order := Stance.get_effective_priority_order(Stance.STANCE_GATHER, player_order) + assert_eq(gather_order.size(), 3, "gather stance: has 3 entries") + assert_eq(gather_order[0], "gather", "gather stance: first is gather") # ── Test 4: Food stance adds gather_food first ── - var food_order := stance_script.get_effective_priority_order(stance_script.STANCE_FOOD, player_order) - _assert_eq(food_order.size(), 4, "food stance: has 4 entries (gather_food + 3)") - _assert_eq(food_order[0], "gather_food", "food stance: first is gather_food") - _assert_eq(food_order[1], "build", "food stance: second is build") + var food_order := Stance.get_effective_priority_order(Stance.STANCE_FOOD, player_order) + assert_eq(food_order.size(), 4, "food stance: has 4 entries (gather_food + 3)") + assert_eq(food_order[0], "gather_food", "food stance: first is gather_food") + assert_eq(food_order[1], "build", "food stance: second is build") # ── Test 5: Build stance with different player order ── var alt_player_order: Array[String] = ["gather", "haul", "build"] - var build_alt := stance_script.get_effective_priority_order(stance_script.STANCE_BUILD, alt_player_order) - _assert_eq(build_alt[0], "build", "build stance (alt): first is build") + var build_alt := Stance.get_effective_priority_order(Stance.STANCE_BUILD, alt_player_order) + assert_eq(build_alt[0], "build", "build stance (alt): first is build") # gather should follow (not duplicated) - _assert(build_alt.has("gather"), "build stance (alt): still has gather") + assert_true(build_alt.has("gather"), "build stance (alt): still has gather") # ── Test 6: Food stance with gather already in player order ── - var food_alt := stance_script.get_effective_priority_order(stance_script.STANCE_FOOD, alt_player_order) - _assert_eq(food_alt[0], "gather_food", "food stance (alt): first is gather_food") + var food_alt := Stance.get_effective_priority_order(Stance.STANCE_FOOD, alt_player_order) + assert_eq(food_alt[0], "gather_food", "food stance (alt): first is gather_food") # gather should not be duplicated — it's already in player order var gather_count := 0 for item in food_alt: if item == "gather": gather_count += 1 - _assert_eq(gather_count, 1, "food stance (alt): gather appears exactly once") + assert_eq(gather_count, 1, "food stance (alt): gather appears exactly once") # ── Test 7: is_food_gather_task ── print("") @@ -67,34 +63,37 @@ func _initialize() -> void: var food_task := {"kind": "gather", "resource": "food"} var wood_task := {"kind": "gather", "resource": "wood"} var haul_task := {"kind": "haul", "resource": "wood"} - - _assert(stance_script.is_food_gather_task(food_task), "food gather: food task detected") - _assert(not stance_script.is_food_gather_task(wood_task), "food gather: wood task not detected") - _assert(not stance_script.is_food_gather_task(haul_task), "food gather: haul task not detected") + + assert_true(Stance.is_food_gather_task(food_task), "food gather: food task detected") + assert_true(not Stance.is_food_gather_task(wood_task), "food gather: wood task not detected") + assert_true(not Stance.is_food_gather_task(haul_task), "food gather: haul task not detected") # ── Test 8: All stances defined ── print("") print("--- stance: catalog ---") - _assert_eq(stance_script.ALL_STANCES.size(), 4, "all stances: has 4 entries") - _assert(stance_script.ALL_STANCES.has(stance_script.STANCE_BALANCED), "all stances: balanced present") - _assert(stance_script.ALL_STANCES.has(stance_script.STANCE_BUILD), "all stances: build present") - _assert(stance_script.ALL_STANCES.has(stance_script.STANCE_GATHER), "all stances: gather present") - _assert(stance_script.ALL_STANCES.has(stance_script.STANCE_FOOD), "all stances: food present") + assert_eq(Stance.ALL_STANCES.size(), 4, "all stances: has 4 entries") + assert_true(Stance.ALL_STANCES.has(Stance.STANCE_BALANCED), "all stances: balanced present") + assert_true(Stance.ALL_STANCES.has(Stance.STANCE_BUILD), "all stances: build present") + assert_true(Stance.ALL_STANCES.has(Stance.STANCE_GATHER), "all stances: gather present") + assert_true(Stance.ALL_STANCES.has(Stance.STANCE_FOOD), "all stances: food present") # ── Test 9: STANCE_INFO has all labels ── - for stance_key in stance_script.ALL_STANCES: - _assert(stance_script.STANCE_INFO.has(stance_key), "info: %s has info" % stance_key) - _assert(not String(stance_script.STANCE_INFO[stance_key].label).is_empty(), "info: %s label not empty" % stance_key) - _assert(not String(stance_script.STANCE_INFO[stance_key].description).is_empty(), "info: %s description not empty" % stance_key) + for stance_key in Stance.ALL_STANCES: + assert_true(Stance.STANCE_INFO.has(stance_key), "info: %s has info" % stance_key) + assert_true(not String(Stance.STANCE_INFO[stance_key].label).is_empty(), "info: %s label not empty" % stance_key) + assert_true(not String(Stance.STANCE_INFO[stance_key].description).is_empty(), "info: %s description not empty" % stance_key) # ── Test 10: Main game integration — stance affects task choice ── print("") print("--- stance: integration with choose_task ---") - var main: Control = main_script.new() + # main.gd references the GameState autoload, so it must be load()ed at + # runtime — preload() compiles before autoloads are registered in --script mode. + var main_script: GDScript = load("res://scripts/main.gd") + var main = main_script.new() main.grid_w = 5 main.grid_h = 5 main.priority_order = ["build", "haul", "gather"] as Array[String] - main.colony_stance = stance_script.STANCE_BALANCED + main.colony_stance = Stance.STANCE_BALANCED main.state = { "tick": 0, "resources": {"wood": 8, "stone": 4, "food": 2}, @@ -131,15 +130,15 @@ func _initialize() -> void: # With balanced stance and default priority_order (build first), no builds exist # so gather should be the fallback — but build tasks come first in priority # Actually with empty builds and no haul targets, gather is the only option - var task_balanced := main.choose_task(main.state.workers[0]) - _assert(not task_balanced.is_empty(), "balanced: worker gets a task") + var task_balanced: Dictionary = main.choose_task(main.state.workers[0]) + assert_true(not task_balanced.is_empty(), "balanced: worker gets a task") # ── Test 11: persist includes colony_stance ── print("") print("--- stance: persistence ---") - main.colony_stance = stance_script.STANCE_FOOD + main.colony_stance = Stance.STANCE_FOOD main.state["colony_stance"] = main.colony_stance # Simulate what persist() does - _assert_eq(main.state.get("colony_stance", ""), stance_script.STANCE_FOOD, "persist: colony_stance saved") + assert_eq(main.state.get("colony_stance", ""), Stance.STANCE_FOOD, "persist: colony_stance saved") # ── Test 12: load_saved_game restores colony_stance ── var loaded_state := { @@ -154,10 +153,10 @@ func _initialize() -> void: "next_build_id": 1, "reserved_resources": {}, "events": [], - "colony_stance": stance_script.STANCE_GATHER, + "colony_stance": Stance.STANCE_GATHER, } - var restored_stance := String(loaded_state.get("colony_stance", stance_script.STANCE_BALANCED)) - _assert_eq(restored_stance, stance_script.STANCE_GATHER, "load: colony_stance restored from save") + var restored_stance := String(loaded_state.get("colony_stance", Stance.STANCE_BALANCED)) + assert_eq(restored_stance, Stance.STANCE_GATHER, "load: colony_stance restored from save") # ── Test 13: Default colony_stance is BALANCED when missing from save ── var legacy_state := { @@ -174,31 +173,5 @@ func _initialize() -> void: "events": [], # No colony_stance field — old save format } - var default_stance := String(legacy_state.get("colony_stance", stance_script.STANCE_BALANCED)) - _assert_eq(default_stance, stance_script.STANCE_BALANCED, "load: defaults to balanced for legacy saves") - - # Summary - print("") - print("=== test_colony_stance summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED — CI should fail") - quit(1) - else: - print("test_colony_stance: ok") - quit(0) - - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) + var default_stance := String(legacy_state.get("colony_stance", Stance.STANCE_BALANCED)) + assert_eq(default_stance, Stance.STANCE_BALANCED, "load: defaults to balanced for legacy saves") diff --git a/tests/test_constants.gd b/tests/test_constants.gd index 1b5a1df..4a4310b 100644 --- a/tests/test_constants.gd +++ b/tests/test_constants.gd @@ -5,88 +5,74 @@ ## Run: godot --headless --quit ## Or: godot --headless --main-pack windowstead.pck --script tests/test_constants.gd -extends SceneTree +extends "res://tests/test_case.gd" const C := preload("res://scripts/constants.gd") -func _initialize() -> void: - var pass_count := 0 - var fail_count := 0 - var test_count := 0 - +func run_tests() -> void: # --- Worker names --- - test_count += 1; pass_count += test("WORKER_NAMES has exactly 10 entries", _test_worker_names_count) - test_count += 1; pass_count += test("WORKER_NAMES[0] is Jun", _test_worker_names_first) - test_count += 1; pass_count += test("WORKER_NAMES[1] is Mara", _test_worker_names_second) - test_count += 1; pass_count += test("WORKER_NAMES contains all expected names", _test_worker_names_all) + check("WORKER_NAMES has exactly 10 entries", _test_worker_names_count) + check("WORKER_NAMES[0] is Jun", _test_worker_names_first) + check("WORKER_NAMES[1] is Mara", _test_worker_names_second) + check("WORKER_NAMES contains all expected names", _test_worker_names_all) # --- Timing constants --- - test_count += 1; pass_count += test("BASE_TICK_SECONDS is 0.9", _test_base_tick_seconds) - test_count += 1; pass_count += test("EVENT_INTERVAL_TICKS is 66", _test_event_interval_ticks) + check("BASE_TICK_SECONDS is 0.9", _test_base_tick_seconds) + check("EVENT_INTERVAL_TICKS is 66", _test_event_interval_ticks) # --- Resource colors --- - test_count += 1; pass_count += test("RESOURCE_COLORS has wood key", _test_resource_color_wood) - test_count += 1; pass_count += test("RESOURCE_COLORS has stone key", _test_resource_color_stone) - test_count += 1; pass_count += test("RESOURCE_COLORS has food key", _test_resource_color_food) - test_count += 1; pass_count += test("RESOURCE_COLORS has exactly 3 entries", _test_resource_color_count) + check("RESOURCE_COLORS has wood key", _test_resource_color_wood) + check("RESOURCE_COLORS has stone key", _test_resource_color_stone) + check("RESOURCE_COLORS has food key", _test_resource_color_food) + check("RESOURCE_COLORS has exactly 3 entries", _test_resource_color_count) # --- Structure colors --- - test_count += 1; pass_count += test("STRUCTURE_COLORS has hut key", _test_structure_color_hut) - test_count += 1; pass_count += test("STRUCTURE_COLORS has workshop key", _test_structure_color_workshop) - test_count += 1; pass_count += test("STRUCTURE_COLORS has garden key", _test_structure_color_garden) - test_count += 1; pass_count += test("STRUCTURE_COLORS has exactly 3 entries", _test_structure_color_count) + check("STRUCTURE_COLORS has hut key", _test_structure_color_hut) + check("STRUCTURE_COLORS has workshop key", _test_structure_color_workshop) + check("STRUCTURE_COLORS has garden key", _test_structure_color_garden) + check("STRUCTURE_COLORS has exactly 3 entries", _test_structure_color_count) # --- Tile backdrops --- - test_count += 1; pass_count += test("TILE_BACKDROPS has ground key", _test_tile_backdrop_ground) - test_count += 1; pass_count += test("TILE_BACKDROPS has tree key", _test_tile_backdrop_tree) - test_count += 1; pass_count += test("TILE_BACKDROPS has rock key", _test_tile_backdrop_rock) - test_count += 1; pass_count += test("TILE_BACKDROPS has berries key", _test_tile_backdrop_berries) - test_count += 1; pass_count += test("TILE_BACKDROPS has foundation key", _test_tile_backdrop_foundation) - test_count += 1; pass_count += test("TILE_BACKDROPS has stockpile key", _test_tile_backdrop_stockpile) - test_count += 1; pass_count += test("TILE_BACKDROPS has exactly 9 entries", _test_tile_backdrop_count) + check("TILE_BACKDROPS has ground key", _test_tile_backdrop_ground) + check("TILE_BACKDROPS has tree key", _test_tile_backdrop_tree) + check("TILE_BACKDROPS has rock key", _test_tile_backdrop_rock) + check("TILE_BACKDROPS has berries key", _test_tile_backdrop_berries) + check("TILE_BACKDROPS has foundation key", _test_tile_backdrop_foundation) + check("TILE_BACKDROPS has stockpile key", _test_tile_backdrop_stockpile) + check("TILE_BACKDROPS has exactly 9 entries", _test_tile_backdrop_count) # --- Worker badge colors --- - test_count += 1; pass_count += test("WORKER_BADGE_COLORS has Jun", _test_badge_color_jun) - test_count += 1; pass_count += test("WORKER_BADGE_COLORS has Mara", _test_badge_color_mara) - test_count += 1; pass_count += test("WORKER_BADGE_COLORS has exactly 10 entries", _test_badge_color_count) - test_count += 1; pass_count += test("Every WORKER_NAMES entry has a badge color", _test_badge_colors_complete) + check("WORKER_BADGE_COLORS has Jun", _test_badge_color_jun) + check("WORKER_BADGE_COLORS has Mara", _test_badge_color_mara) + check("WORKER_BADGE_COLORS has exactly 10 entries", _test_badge_color_count) + check("Every WORKER_NAMES entry has a badge color", _test_badge_colors_complete) # --- Build costs --- - test_count += 1; pass_count += test("BUILD_COSTS hut: 6 wood, 2 stone", _test_build_cost_hut) - test_count += 1; pass_count += test("BUILD_COSTS workshop: 4 wood, 6 stone", _test_build_cost_workshop) - test_count += 1; pass_count += test("BUILD_COSTS garden: 3 wood, 1 stone", _test_build_cost_garden) - test_count += 1; pass_count += test("BUILD_COSTS has exactly 3 entries", _test_build_cost_count) + check("BUILD_COSTS hut: 6 wood, 2 stone", _test_build_cost_hut) + check("BUILD_COSTS workshop: 4 wood, 6 stone", _test_build_cost_workshop) + check("BUILD_COSTS garden: 3 wood, 1 stone", _test_build_cost_garden) + check("BUILD_COSTS has exactly 3 entries", _test_build_cost_count) # --- Build effects --- - test_count += 1; pass_count += test("BUILD_EFFECTS hut describes housing support", _test_build_effect_hut) - test_count += 1; pass_count += test("BUILD_EFFECTS workshop describes unlock/build speed", _test_build_effect_workshop) - test_count += 1; pass_count += test("BUILD_EFFECTS garden describes food supply", _test_build_effect_garden) - test_count += 1; pass_count += test("BUILD_EFFECTS has exactly 3 entries", _test_build_effect_count) + check("BUILD_EFFECTS hut describes housing support", _test_build_effect_hut) + check("BUILD_EFFECTS workshop describes unlock/build speed", _test_build_effect_workshop) + check("BUILD_EFFECTS garden describes food supply", _test_build_effect_garden) + check("BUILD_EFFECTS has exactly 3 entries", _test_build_effect_count) # --- Build unlocks --- - test_count += 1; pass_count += test("BUILD_UNLOCKS hut is true (unlocked)", _test_unlock_hut) - test_count += 1; pass_count += test("BUILD_UNLOCKS workshop requires hut", _test_unlock_workshop) - test_count += 1; pass_count += test("BUILD_UNLOCKS garden requires workshop", _test_unlock_garden) - test_count += 1; pass_count += test("BUILD_UNLOCKS has exactly 3 entries", _test_unlock_count) + check("BUILD_UNLOCKS hut is true (unlocked)", _test_unlock_hut) + check("BUILD_UNLOCKS workshop requires hut", _test_unlock_workshop) + check("BUILD_UNLOCKS garden requires workshop", _test_unlock_garden) + check("BUILD_UNLOCKS has exactly 3 entries", _test_unlock_count) # --- Consistency: all build costs have wood+stone --- - test_count += 1; pass_count += test("All BUILD_COSTS entries have wood and stone keys", _test_build_costs_complete) - test_count += 1; pass_count += test("All BUILD_COSTS entries have effect copy", _test_build_effects_complete) - - fail_count = test_count - pass_count - print("\n=== Constants Regression Tests ===") - print("Passed: %d" % pass_count) - print("Failed: %d" % fail_count) - - if fail_count > 0: - print("REGRESSION FAILURES DETECTED") - quit(1) - else: - print("All constants regression tests passed.") - quit(0) + check("All BUILD_COSTS entries have wood and stone keys", _test_build_costs_complete) + check("All BUILD_COSTS entries have effect copy", _test_build_effects_complete) -func test(name: String, fn: Callable) -> int: +## Runs a check callable and reports it through the shared assertion API. +## The callable may return a bool or a {ok, msg} Dictionary. +func check(name: String, fn: Callable) -> void: var ok := true var error_msg := "" var result: Variant = fn.call() @@ -97,12 +83,7 @@ func test(name: String, fn: Callable) -> int: ok = false error_msg = "returned false" - if ok: - print(" ✓ %s" % name) - return 1 - else: - print(" ✗ %s: %s" % [name, error_msg]) - return 0 + assert_true(ok, name, error_msg) # --- Individual tests --- diff --git a/tests/test_dirty_state_tracking.gd b/tests/test_dirty_state_tracking.gd index 76e60b5..bb5f1b7 100644 --- a/tests/test_dirty_state_tracking.gd +++ b/tests/test_dirty_state_tracking.gd @@ -1,45 +1,34 @@ ## Tests for dirty-state tracking in persist() (issue #179). ## -## These tests verify that the _dirty flag pattern correctly prevents +## These tests verify that the dirty-flag pattern correctly prevents ## unnecessary saves when game state has not changed since the last -## persist(). They exercise the same logic flow as main.gd's persist() -## using game_state.gd's save/load functions. +## persist(). The flag now lives on the sim (`main.sim.dirty`, set by sim +## mutations and main._mark_dirty(), cleared by main.persist()); persist() +## itself is additionally debounced to at most once every +## PERSIST_INTERVAL_TICKS on the tick path, with persist(true) forcing an +## immediate save. Flows 1-6 exercise the same logic flow using +## game_state.gd's save/load functions; Flow 7 checks the real sim flag. ## -## Run: godot --headless --quit --script res://tests/test_dirty_state_tracking.gd +## Run: godot --headless --path . --script res://tests/test_dirty_state_tracking.gd -extends SceneTree +extends "res://tests/test_case.gd" -var tests_run := 0 -var tests_passed := 0 -var tests_failed := 0 -var failures := [] - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -func _assert(condition: bool, msg: String) -> void: - tests_run += 1 - if condition: - tests_passed += 1 - else: - failures.append("FAIL: %s" % msg) - - -func _assert_eq(actual, expected, msg: String) -> void: - tests_run += 1 - if actual == expected: - tests_passed += 1 - else: - failures.append("FAIL: %s (expected %s, got %s)" % [msg, str(expected), str(actual)]) +func run_tests() -> void: + # Autoloads are not running in --script mode — instantiate game_state.gd + # manually (same pattern as test_runner.gd). + var game_state_script := load("res://scripts/game_state.gd") + var gs = game_state_script.new() + root.add_child(gs) + await process_frame -func _assert_ne(actual, expected, msg: String) -> void: - tests_run += 1 - if actual != expected: - tests_passed += 1 - else: - failures.append("FAIL: %s (expected != %s)" % [msg, str(expected)]) + flow_dirty_state_triggers_save(gs) + flow_clean_state_skips_save(gs) + flow_persist_resets_dirty_flag(gs) + flow_multiple_mutations_single_persist(gs) + flow_idle_tick_skips_persist(gs) + flow_dirty_flag_covers_all_mutation_categories(gs) + flow_sim_dirty_flag_semantics() # --------------------------------------------------------------------------- @@ -77,9 +66,8 @@ func seed_tiles(state: Dictionary) -> void: # Flow 1: Initial save — dirty state triggers persist # --------------------------------------------------------------------------- -func flow_dirty_state_triggers_save() -> void: +func flow_dirty_state_triggers_save(gs: Node) -> void: print("\n=== Flow 1: Dirty state triggers save ===") - var gs := load_game_state() gs.clear_game() # Simulate: _mark_dirty() was called (state changed) @@ -88,22 +76,23 @@ func flow_dirty_state_triggers_save() -> void: state["tick"] = 10 # Simulate persist(): dirty is true, so save happens + # (on the real tick path the save may be deferred up to + # PERSIST_INTERVAL_TICKS; persist(true) forces it immediately) gs.save_game(state) # Verify the save was written - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 10, "saved tick matches") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "saved wood matches") - _assert_eq(loaded.get("tiles", []).size(), 150, "saved tile count matches") + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 10, "saved tick matches") + assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "saved wood matches") + assert_eq(loaded.get("tiles", []).size(), 150, "saved tile count matches") # --------------------------------------------------------------------------- # Flow 2: Clean state — no dirty flag means persist is skipped # --------------------------------------------------------------------------- -func flow_clean_state_skips_save() -> void: +func flow_clean_state_skips_save(gs: Node) -> void: print("\n=== Flow 2: Clean state skips save (persist returns early) ===") - var gs := load_game_state() gs.clear_game() # Initial save with dirty state @@ -113,29 +102,28 @@ func flow_clean_state_skips_save() -> void: gs.save_game(state) # Verify initial save - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 10, "initial tick is 10") + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 10, "initial tick is 10") - # Simulate: _dirty is false (no changes since last persist). - # In main.gd, persist() returns early when _dirty is false. + # Simulate: sim.dirty is false (no changes since last persist). + # In main.gd, persist() returns early when sim.dirty is false. # We simulate this by NOT calling save_game — the file on disk should remain unchanged. # Load again and verify tick is still 10 (not advanced to a new value). loaded = gs.load_game() - _assert_eq(loaded.get("tick", -1), 10, "tick remains 10 after no-dirty persist") + assert_eq(loaded.get("tick", -1), 10, "tick remains 10 after no-dirty persist") # Also verify the save file content hasn't been overwritten with stale data - var backup := loaded.duplicate() + var backup: Dictionary = loaded.duplicate() loaded = gs.load_game() - _assert_eq(loaded.get("tick"), backup.get("tick"), "tick unchanged across double load (no dirty)") + assert_eq(loaded.get("tick"), backup.get("tick"), "tick unchanged across double load (no dirty)") # --------------------------------------------------------------------------- # Flow 3: Mutation resets dirty flag after save # --------------------------------------------------------------------------- -func flow_persist_resets_dirty_flag() -> void: - print("\n=== Flow 3: persist() resets _dirty flag ===") - var gs := load_game_state() +func flow_persist_resets_dirty_flag(gs: Node) -> void: + print("\n=== Flow 3: persist() resets sim.dirty flag ===") gs.clear_game() # Initial save (dirty=true) @@ -145,25 +133,24 @@ func flow_persist_resets_dirty_flag() -> void: gs.save_game(state) # Verify saved - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 5, "saved tick is 5") + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 5, "saved tick is 5") - # Simulate mutation: _mark_dirty() sets dirty=true + # Simulate mutation: _mark_dirty() sets sim.dirty=true state["tick"] = 20 gs.save_game(state) # Verify updated loaded = gs.load_game() - _assert_eq(loaded.get("tick", -1), 20, "tick updated to 20 after mutation+save") + assert_eq(loaded.get("tick", -1), 20, "tick updated to 20 after mutation+save") # --------------------------------------------------------------------------- # Flow 4: Multiple mutations before single persist — only one save # --------------------------------------------------------------------------- -func flow_multiple_mutations_single_persist() -> void: +func flow_multiple_mutations_single_persist(gs: Node) -> void: print("\n=== Flow 4: Multiple mutations → single persist ===") - var gs := load_game_state() gs.clear_game() # Initial state @@ -176,24 +163,25 @@ func flow_multiple_mutations_single_persist() -> void: state["resources"]["wood"] = 10 state["resources"]["stone"] = 6 state["tick"] = 2 - # In main.gd, each mutation calls _mark_dirty(), but only the final persist() saves + # In main.gd, each mutation calls _mark_dirty(), but only the final + # persist() saves — and the tick path debounces to one save per + # PERSIST_INTERVAL_TICKS window. # Single persist after all mutations gs.save_game(state) - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 2, "final tick is 2") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 10, "wood updated to 10") - _assert_eq(loaded.get("resources", {}).get("stone", -1), 6, "stone updated to 6") + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 2, "final tick is 2") + assert_eq(loaded.get("resources", {}).get("wood", -1), 10, "wood updated to 10") + assert_eq(loaded.get("resources", {}).get("stone", -1), 6, "stone updated to 6") # --------------------------------------------------------------------------- # Flow 5: Idle tick — no mutations means persist is skipped # --------------------------------------------------------------------------- -func flow_idle_tick_skips_persist() -> void: +func flow_idle_tick_skips_persist(gs: Node) -> void: print("\n=== Flow 5: Idle tick (no mutations) skips persist ===") - var gs := load_game_state() gs.clear_game() # Save initial state @@ -202,24 +190,23 @@ func flow_idle_tick_skips_persist() -> void: state["tick"] = 100 gs.save_game(state) - # Simulate idle tick: no mutations occur, so _dirty stays false + # Simulate idle tick: no mutations occur, so sim.dirty stays false # In main.gd: _on_tick() increments tick but does NOT call _mark_dirty() - # The tick value is only captured in persist() when dirty=true - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 100, "idle tick does not overwrite save") + # The tick value is only captured in persist() when sim.dirty=true + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 100, "idle tick does not overwrite save") # Verify that without any _mark_dirty(), the persisted data is unchanged loaded = gs.load_game() - _assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "resources unchanged after idle ticks") + assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "resources unchanged after idle ticks") # --------------------------------------------------------------------------- # Flow 6: Dirty flag covers all mutation categories # --------------------------------------------------------------------------- -func flow_dirty_flag_covers_all_mutation_categories() -> void: +func flow_dirty_flag_covers_all_mutation_categories(gs: Node) -> void: print("\n=== Flow 6: Dirty flag covers all mutation categories ===") - var gs := load_game_state() gs.clear_game() # Initial state with workers and builds @@ -254,47 +241,42 @@ func flow_dirty_flag_covers_all_mutation_categories() -> void: state.tiles[0] = {"kind": "stockpile", "amount": 0, "resource": "", "build_kind": ""} gs.save_game(state) - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 1, "tick preserved") - _assert_eq(loaded.get("resources", {}).get("food", -1), 1, "food updated (resource mutation)") - _assert_eq(loaded.get("workers", []).size(), 2, "worker added (worker mutation)") - _assert_eq(loaded.get("priority_order", []), ["build", "gather", "haul"], "priority changed") - _assert_eq(loaded.get("events", []).size(), 1, "event logged (event mutation)") - _assert_eq(loaded.get("tiles", [])[0].get("kind"), "stockpile", "tile modified (tile mutation)") + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 1, "tick preserved") + assert_eq(loaded.get("resources", {}).get("food", -1), 1, "food updated (resource mutation)") + assert_eq(loaded.get("workers", []).size(), 2, "worker added (worker mutation)") + assert_eq(loaded.get("priority_order", []), ["build", "gather", "haul"], "priority changed") + assert_eq(loaded.get("events", []).size(), 1, "event logged (event mutation)") + assert_eq(loaded.get("tiles", [])[0].get("kind"), "stockpile", "tile modified (tile mutation)") # --------------------------------------------------------------------------- -# Main +# Flow 7: Real sim.dirty flag semantics (post sim-extraction refactor) # --------------------------------------------------------------------------- +# The flag moved from main's old `_dirty` var to `main.sim.dirty`. We check it +# directly on a scene-free Main instance. main.persist() itself is not called +# here because it writes through the GameState autoload, which is unavailable +# in --script mode. + +func flow_sim_dirty_flag_semantics() -> void: + print("\n=== Flow 7: sim.dirty flag semantics ===") + # main.gd references the GameState autoload — load at runtime, not preload. + var main_script: GDScript = load("res://scripts/main.gd") + var main: Control = main_script.new() + + assert_false(main.sim.dirty, "sim.dirty starts false on a fresh sim") + + main._mark_dirty() + assert_true(main.sim.dirty, "main._mark_dirty() sets sim.dirty") + + # Simulate the clear performed by persist() once it saves + main.sim.dirty = false + assert_false(main.sim.dirty, "sim.dirty clear observed") + + # A sim mutation (push_event) marks the state dirty again + main.state = {"tick": 0, "events": []} + main.push_event("dirty-flag test event") + assert_true(main.sim.dirty, "sim mutation (push_event) sets sim.dirty") + assert_eq(main.state.events.size(), 1, "push_event recorded the event") -func _initialize() -> void: - print("===========================================") - print(" Windowstead Dirty-State Tracking Tests") - print(" (issue #179 — persist optimization)") - print("===========================================") - - flow_dirty_state_triggers_save() - flow_clean_state_skips_save() - flow_persist_resets_dirty_flag() - flow_multiple_mutations_single_persist() - flow_idle_tick_skips_persist() - flow_dirty_flag_covers_all_mutation_categories() - - print("\n===========================================") - print(" Results: %d/%d passed, %d failed" % [tests_passed, tests_run, tests_failed]) - print("===========================================") - - for f in failures: - print(" " + f) - - if tests_failed > 0: - print("\nDirty-state tracking tests FAILED") - quit(1) - else: - print("\nAll dirty-state tracking tests passed") - quit(0) - - -func load_game_state() -> Node: - var gs_script := load("res://scripts/game_state.gd") - return gs_script.new() + main.free() diff --git a/tests/test_e2e.gd b/tests/test_e2e.gd index 613e121..c0aa845 100644 --- a/tests/test_e2e.gd +++ b/tests/test_e2e.gd @@ -1,4 +1,4 @@ -extends SceneTree +extends "res://tests/test_case.gd" # ============================================================================= # End-to-end gameplay flow tests for Windowstead core colony interactions. @@ -16,60 +16,6 @@ extends SceneTree # 7. Save compatibility checks (version, grid mismatch) # ============================================================================= -var tests_run := 0 -var tests_passed := 0 -var tests_failed := 0 -var failures := [] - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -func _assert(condition: bool, msg: String) -> void: - tests_run += 1 - if condition: - tests_passed += 1 - else: - tests_failed += 1 - failures.append("FAIL: %s" % msg) - - -func _assert_eq(actual, expected, msg: String) -> void: - tests_run += 1 - if actual == expected: - tests_passed += 1 - else: - tests_failed += 1 - failures.append("FAIL: %s (expected %s, got %s)" % [msg, str(expected), str(actual)]) - - -func _assert_not_empty(d, msg: String) -> void: - tests_run += 1 - if not d.is_empty(): - tests_passed += 1 - else: - tests_failed += 1 - failures.append("FAIL: %s" % msg) - - -func _assert_has_key(d: Dictionary, key, msg: String) -> void: - tests_run += 1 - if d.has(key): - tests_passed += 1 - else: - tests_failed += 1 - failures.append("FAIL: %s (key '%s' missing)" % [msg, str(key)]) - - -func _assert_array_has(arr: Array, val, msg: String) -> void: - tests_run += 1 - if arr.has(val): - tests_passed += 1 - else: - tests_failed += 1 - failures.append("FAIL: %s (array does not contain %s)" % [msg, str(val)]) - - # --------------------------------------------------------------------------- # Flow 1: Boot → bootstrap → verify initial colony # --------------------------------------------------------------------------- @@ -116,17 +62,17 @@ func flow_boot_and_bootstrap() -> void: state.tiles[2 * 5 + 1] = {"kind": "stockpile", "amount": 0, "resource": "", "build_kind": ""} # Verify initial state - _assert_eq(state.tick, 0, "tick starts at 0") - _assert_eq(state.resources.wood, 8, "initial wood") - _assert_eq(state.resources.stone, 4, "initial stone") - _assert_eq(state.resources.food, 2, "initial food") - _assert_eq(state.workers.size(), 2, "two workers") - _assert_eq(state.builds.size(), 0, "no builds yet") - _assert_eq(state.events.size(), 2, "initial events") - _assert_eq(state.priority_order.size(), 3, "priority order has 3 items") - _assert_array_has(state.priority_order, "build", "build in priority order") - _assert_array_has(state.priority_order, "haul", "haul in priority order") - _assert_array_has(state.priority_order, "gather", "gather in priority order") + assert_eq(state.tick, 0, "tick starts at 0") + assert_eq(state.resources.wood, 8, "initial wood") + assert_eq(state.resources.stone, 4, "initial stone") + assert_eq(state.resources.food, 2, "initial food") + assert_eq(state.workers.size(), 2, "two workers") + assert_eq(state.builds.size(), 0, "no builds yet") + assert_eq(state.events.size(), 2, "initial events") + assert_eq(state.priority_order.size(), 3, "priority order has 3 items") + assert_true(state.priority_order.has("build"), "build in priority order") + assert_true(state.priority_order.has("haul"), "haul in priority order") + assert_true(state.priority_order.has("gather"), "gather in priority order") # Verify tile distribution var trees := 0 @@ -137,17 +83,17 @@ func flow_boot_and_bootstrap() -> void: "tree": trees += 1 "rock": rocks += 1 "berries": berries += 1 - _assert(trees > 0, "seeded tiles have trees (%d)" % trees) - _assert(rocks > 0, "seeded tiles have rocks (%d)" % rocks) - _assert(berries > 0, "seeded tiles have berries (%d)" % berries) + assert_true(trees > 0, "seeded tiles have trees (%d)" % trees) + assert_true(rocks > 0, "seeded tiles have rocks (%d)" % rocks) + assert_true(berries > 0, "seeded tiles have berries (%d)" % berries) # Save and verify persistence gs.save_game(state) var loaded = gs.load_game() - _assert_not_empty(loaded, "save/load round-trip returns data") - _assert_eq(loaded.get("tick", -1), 0, "loaded tick matches") - _assert_eq(loaded.get("save_version", -1), 2, "loaded save_version matches") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "loaded resources.wood matches") + assert_not_empty(loaded, "save/load round-trip returns data") + assert_eq(loaded.get("tick", -1), 0, "loaded tick matches") + assert_eq(loaded.get("save_version", -1), 2, "loaded save_version matches") + assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "loaded resources.wood matches") # --------------------------------------------------------------------------- @@ -194,28 +140,28 @@ func flow_save_and_reload() -> void: # Reload var loaded = gs.load_game() - _assert_not_empty(loaded, "save exists after reload") - _assert_eq(loaded.get("tick", -1), 15, "tick preserved") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 6, "wood preserved") - _assert_eq(loaded.get("resources", {}).get("stone", -1), 5, "stone preserved") - _assert_eq(loaded.get("resources", {}).get("food", -1), 3, "food preserved") - _assert_eq(loaded.get("harvested", {}).get("wood", -1), 3, "harvested wood preserved") - _assert_eq(loaded.get("priority_order", []).size(), 3, "priority order preserved") - _assert_eq(loaded.get("workers", []).size(), 2, "workers preserved") - _assert_eq(loaded.get("events", []).size(), 1, "events preserved") + assert_not_empty(loaded, "save exists after reload") + assert_eq(loaded.get("tick", -1), 15, "tick preserved") + assert_eq(loaded.get("resources", {}).get("wood", -1), 6, "wood preserved") + assert_eq(loaded.get("resources", {}).get("stone", -1), 5, "stone preserved") + assert_eq(loaded.get("resources", {}).get("food", -1), 3, "food preserved") + assert_eq(loaded.get("harvested", {}).get("wood", -1), 3, "harvested wood preserved") + assert_eq(loaded.get("priority_order", []).size(), 3, "priority order preserved") + assert_eq(loaded.get("workers", []).size(), 2, "workers preserved") + assert_eq(loaded.get("events", []).size(), 1, "events preserved") # Verify worker state var workers = loaded.get("workers", []) var jun = workers.filter(func(w): return w.name == "Jun") - _assert(not jun.is_empty(), "Jun found in loaded save") + assert_true(not jun.is_empty(), "Jun found in loaded save") if not jun.is_empty(): - _assert_eq(jun[0].get("carrying", {}).get("wood", 0), 1, "Jun carrying wood") - _assert_eq(jun[0].get("task", {}).get("kind", ""), "haul", "Jun task is haul") + assert_eq(jun[0].get("carrying", {}).get("wood", 0), 1, "Jun carrying wood") + assert_eq(jun[0].get("task", {}).get("kind", ""), "haul", "Jun task is haul") # Clear and reload again — should still have data gs.clear_game() var empty = gs.load_game() - _assert(empty.is_empty(), "after clear, load returns empty") + assert_true(empty.is_empty(), "after clear, load returns empty") # --------------------------------------------------------------------------- @@ -275,9 +221,9 @@ func flow_tick_simulation() -> void: gs.save_game(state) var loaded = gs.load_game() - _assert_eq(loaded.get("tick", -1), 5, "tick advanced to 5") - _assert(loaded.get("events", []).size() > 0, "events populated after ticks") - _assert_eq(loaded.get("save_version", -1), 2, "save_version still 2") + assert_eq(loaded.get("tick", -1), 5, "tick advanced to 5") + assert_true(loaded.get("events", []).size() > 0, "events populated after ticks") + assert_eq(loaded.get("save_version", -1), 2, "save_version still 2") # --------------------------------------------------------------------------- @@ -353,11 +299,11 @@ func flow_build_placement() -> void: # Verify build completion var loaded = gs.load_game() var builds = loaded.get("builds", []) - _assert_eq(builds.size(), 1, "one build in save") - _assert_eq(builds[0].get("kind", ""), "hut", "build is a hut") - _assert(bool(builds[0].get("complete", false)), "hut is complete") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 6, "wood spent on build (12-6)") - _assert_eq(loaded.get("resources", {}).get("stone", -1), 6, "stone spent on build (8-2)") + assert_eq(builds.size(), 1, "one build in save") + assert_eq(builds[0].get("kind", ""), "hut", "build is a hut") + assert_true(bool(builds[0].get("complete", false)), "hut is complete") + assert_eq(loaded.get("resources", {}).get("wood", -1), 6, "wood spent on build (12-6)") + assert_eq(loaded.get("resources", {}).get("stone", -1), 6, "stone spent on build (8-2)") # Queue a second build (workshop: 4 wood, 6 stone) — but we don't have enough stone # Verify state is still consistent @@ -374,9 +320,9 @@ func flow_build_placement() -> void: gs.save_game(state) var loaded2 = gs.load_game() - _assert_eq(loaded2.get("builds", []).size(), 2, "two builds in save") - _assert(bool(loaded2.get("builds", [{}])[0].get("complete", false)), "first build still complete") - _assert(not bool(loaded2.get("builds", [{}])[1].get("complete", false)), "second build not yet complete") + assert_eq(loaded2.get("builds", []).size(), 2, "two builds in save") + assert_true(bool(loaded2.get("builds", [{}])[0].get("complete", false)), "first build still complete") + assert_true(not bool(loaded2.get("builds", [{}])[1].get("complete", false)), "second build not yet complete") # --------------------------------------------------------------------------- @@ -426,15 +372,15 @@ func flow_anchor_switching() -> void: # core state (resources, workers, builds) should persist. # We verify this by checking that saved data survives a reload. var loaded = gs.load_game() - _assert_eq(loaded.get("tick", -1), 20, "tick preserved after anchor switch") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 10, "wood preserved") - _assert_eq(loaded.get("workers", []).size(), 2, "workers preserved") - _assert_eq(loaded.get("save_version", -1), 2, "save_version preserved") + assert_eq(loaded.get("tick", -1), 20, "tick preserved after anchor switch") + assert_eq(loaded.get("resources", {}).get("wood", -1), 10, "wood preserved") + assert_eq(loaded.get("workers", []).size(), 2, "workers preserved") + assert_eq(loaded.get("save_version", -1), 2, "save_version preserved") # Simulate anchor switch to "bottom" gs.save_game(state) loaded = gs.load_game() - _assert_eq(loaded.get("tick", -1), 20, "tick preserved after bottom anchor") + assert_eq(loaded.get("tick", -1), 20, "tick preserved after bottom anchor") # --------------------------------------------------------------------------- @@ -481,10 +427,10 @@ func flow_priority_order() -> void: # Reload and verify priority order var loaded = gs.load_game() var priority = loaded.get("priority_order", []) - _assert_eq(priority.size(), 3, "priority order has 3 items") - _assert_eq(priority[0], "gather", "gather is first priority") - _assert_eq(priority[1], "haul", "haul is second priority") - _assert_eq(priority[2], "build", "build is third priority") + assert_eq(priority.size(), 3, "priority order has 3 items") + assert_eq(priority[0], "gather", "gather is first priority") + assert_eq(priority[1], "haul", "haul is second priority") + assert_eq(priority[2], "build", "build is third priority") # Change priority order (simulating UI drag) state.priority_order = ["build", "gather", "haul"] @@ -492,9 +438,9 @@ func flow_priority_order() -> void: loaded = gs.load_game() priority = loaded.get("priority_order", []) - _assert_eq(priority[0], "build", "build moved to first priority") - _assert_eq(priority[1], "gather", "gather moved to second priority") - _assert_eq(priority[2], "haul", "haul moved to third priority") + assert_eq(priority[0], "build", "build moved to first priority") + assert_eq(priority[1], "gather", "gather moved to second priority") + assert_eq(priority[2], "haul", "haul moved to third priority") # --------------------------------------------------------------------------- @@ -518,7 +464,7 @@ func flow_save_compatibility() -> void: } gs.save_game(old_state) var loaded = gs.load_game() - _assert(loaded.is_empty(), "v0 save rejected as invalid") + assert_true(loaded.is_empty(), "v0 save rejected as invalid") # Test 2: Save with worker missing break_ticks (migration case) var legacy_state := { @@ -534,35 +480,35 @@ func flow_save_compatibility() -> void: } gs.save_game(legacy_state) loaded = gs.load_game() - _assert_not_empty(loaded, "legacy save without break_ticks loads") + assert_not_empty(loaded, "legacy save without break_ticks loads") # Test 3: Clear and verify empty gs.clear_game() loaded = gs.load_game() - _assert(loaded.is_empty(), "after clear, save is empty") + assert_true(loaded.is_empty(), "after clear, save is empty") # Test 4: Settings save/load var settings := {"dock_anchor": "left", "tick_speed": 1} gs.save_settings(settings) var loaded_settings = gs.load_settings() - _assert_eq(loaded_settings.get("dock_anchor", ""), "left", "settings dock_anchor saved") - _assert_eq(loaded_settings.get("tick_speed", -1), 1, "settings tick_speed saved") + assert_eq(loaded_settings.get("dock_anchor", ""), "left", "settings dock_anchor saved") + assert_eq(loaded_settings.get("tick_speed", -1), 1, "settings tick_speed saved") # Test 5: Settings with focus mode and zoom var settings2 := {"dock_anchor": "bottom", "tick_speed": 2, "focus_mode": true, "zoom_factor": 1.5} gs.save_settings(settings2) loaded_settings = gs.load_settings() - _assert_eq(loaded_settings.get("dock_anchor", ""), "bottom", "settings dock_anchor updated") - _assert_eq(loaded_settings.get("tick_speed", -1), 2, "settings tick_speed updated") - _assert_eq(loaded_settings.get("focus_mode", false), true, "focus_mode saved") - _assert_eq(loaded_settings.get("zoom_factor", -1), 1.5, "zoom_factor saved") + assert_eq(loaded_settings.get("dock_anchor", ""), "bottom", "settings dock_anchor updated") + assert_eq(loaded_settings.get("tick_speed", -1), 2, "settings tick_speed updated") + assert_eq(loaded_settings.get("focus_mode", false), true, "focus_mode saved") + assert_eq(loaded_settings.get("zoom_factor", -1), 1.5, "zoom_factor saved") # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- -func _initialize() -> void: +func run_tests() -> void: print("===========================================") print(" Windowstead E2E Gameplay Flow Tests") print("===========================================") @@ -575,20 +521,6 @@ func _initialize() -> void: flow_priority_order() flow_save_compatibility() - print("\n===========================================") - print(" Results: %d/%d passed, %d failed" % [tests_passed, tests_run, tests_failed]) - print("===========================================") - - for f in failures: - print(" " + f) - - if tests_failed > 0: - print("\nE2E tests FAILED") - quit(1) - else: - print("\nAll E2E tests passed") - quit(0) - func load_game_state() -> Node: var gs_script := load("res://scripts/game_state.gd") diff --git a/tests/test_food_upkeep.gd b/tests/test_food_upkeep.gd index 9fe3841..ee84659 100644 --- a/tests/test_food_upkeep.gd +++ b/tests/test_food_upkeep.gd @@ -1,32 +1,30 @@ -extends SceneTree +extends "res://tests/test_case.gd" # Tests for food upkeep model (issue #147, links to #133). # Validates: base workers no pressure, extra workers create pressure, # low-food slowdown, starvation pause, and food-gathering bias. -# Harness: extends SceneTree — instantiate Main and call actual methods. const Constants := preload("res://scripts/constants.gd") -var test_pass := 0 -var test_fail := 0 - -func _initialize() -> void: - var main_script: GDScript = preload("res://scripts/main.gd") - var main := main_script.new() +func run_tests() -> void: + # main.gd references the GameState autoload, so it must be load()ed at + # runtime — preload() compiles before autoloads are registered in --script mode. + var main_script: GDScript = load("res://scripts/main.gd") + var main = main_script.new() # ── Test: base workers do not create food pressure ──────────────────────── print("") print("--- base workers no upkeep ---") main.state = {"workers": []} # empty = 0 workers, below BASE_WORKERS_NO_UPKEEP - var extra_0 := main.get_extra_workers_count() - _assert_eq(extra_0, 0, "0 workers should produce 0 extra") + var extra_0: int = main.get_extra_workers_count() + assert_eq(extra_0, 0, "0 workers should produce 0 extra") main.state = {"workers": [ {"name": "A", "pos": {"x": 0, "y": 0}, "carrying": {}, "task": {}}, {"name": "B", "pos": {"x": 1, "y": 0}, "carrying": {}, "task": {}}, ]} # 2 workers = base - var extra_2 := main.get_extra_workers_count() - _assert_eq(extra_2, 0, "Base 2 workers should produce 0 extra") + var extra_2: int = main.get_extra_workers_count() + assert_eq(extra_2, 0, "Base 2 workers should produce 0 extra") # ── Test: extra workers create clear food pressure ──────────────────────── print("") @@ -37,12 +35,12 @@ func _initialize() -> void: {"name": "C", "pos": {"x": 2, "y": 0}, "carrying": {}, "task": {}}, {"name": "D", "pos": {"x": 3, "y": 0}, "carrying": {}, "task": {}}, ]} # 4 workers = 2 extra - var extra_4 := main.get_extra_workers_count() - _assert_eq(extra_4, 2, "4 workers should produce 2 extra") + var extra_4: int = main.get_extra_workers_count() + assert_eq(extra_4, 2, "4 workers should produce 2 extra") # Food cost for 2 extra = 2 * FOOD_PER_EXTRA_WORKER var food_cost := extra_4 * Constants.FOOD_PER_EXTRA_WORKER - _assert_eq(food_cost, 2, "4 workers should cost 2 food per upkeep cycle (1 per extra)") + assert_eq(food_cost, 2, "4 workers should cost 2 food per upkeep cycle (1 per extra)") # ── Test: one extra worker costs exactly one food per interval ──────────── print("") @@ -52,8 +50,8 @@ func _initialize() -> void: {"name": "B", "pos": {"x": 1, "y": 0}, "carrying": {}, "task": {}}, {"name": "C", "pos": {"x": 2, "y": 0}, "carrying": {}, "task": {}}, ]} # 3 workers = 1 extra - var extra_3 := main.get_extra_workers_count() - _assert_eq(extra_3, 1, "3 workers (1 extra) should cost 1 food") + var extra_3: int = main.get_extra_workers_count() + assert_eq(extra_3, 1, "3 workers (1 extra) should cost 1 food") # ── Test: upkeep never drives food negative ─────────────────────────────── print("") @@ -63,32 +61,32 @@ func _initialize() -> void: {"name": "B", "pos": {"x": 1, "y": 0}, "carrying": {}, "task": {}}, {"name": "C", "pos": {"x": 2, "y": 0}, "carrying": {}, "task": {}}, {"name": "D", "pos": {"x": 3, "y": 0}, "carrying": {}, "task": {}}, - ], "resources": {"food": 2}} # 4 workers = 4 extra = 4 food cost, but only 2 food + ], "resources": {"food": 2}} # 4 workers = 2 extra = 2 food cost, exactly the 2 food on hand main.apply_food_upkeep() var remaining := int(main.state.resources.get("food", -1)) - _assert_eq(remaining, 0, "Upkeep should clamp to 0, not go negative") + assert_eq(remaining, 0, "Upkeep should clamp to 0, not go negative") # ── Test: no slowdown when food is above threshold ──────────────────────── print("") print("--- no slowdown when food ok ---") main.state = {"resources": {"food": 10}} - var factor_ok := main.get_food_slowdown_factor() - _assert_eq(factor_ok, 1.0, "High food should give full speed (1.0)") + var factor_ok: float = main.get_food_slowdown_factor() + assert_eq(factor_ok, 1.0, "High food should give full speed (1.0)") # ── Test: low-food slowdown at threshold ────────────────────────────────── print("") print("--- low food slowdown at threshold ---") main.state = {"resources": {"food": Constants.LOW_FOOD_THRESHOLD}} - var factor_low := main.get_food_slowdown_factor() - _assert_eq(factor_low, Constants.LOW_FOOD_SPEED_FACTOR, + var factor_low: float = main.get_food_slowdown_factor() + assert_eq(factor_low, Constants.LOW_FOOD_SPEED_FACTOR, "At low food threshold, speed should be 50%") # ── Test: starvation pause at starvation threshold ──────────────────────── print("") print("--- starvation pause ---") main.state = {"resources": {"food": Constants.STARVATION_FOOD_THRESHOLD}} - var factor_starve := main.get_food_slowdown_factor() - _assert_eq(factor_starve, Constants.STARVATION_SPEED_FACTOR, + var factor_starve: float = main.get_food_slowdown_factor() + assert_eq(factor_starve, Constants.STARVATION_SPEED_FACTOR, "At starvation threshold, speed should be 0%") # ── Test: linear interpolation between starvation and low ──────────────── @@ -96,114 +94,73 @@ func _initialize() -> void: print("--- linear interpolation ---") # STARVATION=1, LOW=3, so food=2 is exactly in the middle main.state = {"resources": {"food": 2}} - var factor_mid := main.get_food_slowdown_factor() + var factor_mid: float = main.get_food_slowdown_factor() var expected = lerp(Constants.STARVATION_SPEED_FACTOR, Constants.LOW_FOOD_SPEED_FACTOR, 0.5) - _assert_eq(factor_mid, expected, "Food at midpoint should give interpolated slowdown") + assert_eq(factor_mid, expected, "Food at midpoint should give interpolated slowdown") # ── Test: food level classification ─────────────────────────────────────── print("") print("--- food level classification ---") main.state = {"resources": {"food": 0}} - _assert_eq(main.get_low_food_level(), "starving", "Zero food is starving") + assert_eq(main.get_low_food_level(), "starving", "Zero food is starving") main.state = {"resources": {"food": Constants.STARVATION_FOOD_THRESHOLD}} - _assert_eq(main.get_low_food_level(), "starving", + assert_eq(main.get_low_food_level(), "starving", "At starvation threshold, still starving") main.state = {"resources": {"food": Constants.STARVATION_FOOD_THRESHOLD + 1}} - _assert_eq(main.get_low_food_level(), "low", + assert_eq(main.get_low_food_level(), "low", "One above starvation is low") main.state = {"resources": {"food": Constants.LOW_FOOD_THRESHOLD}} - _assert_eq(main.get_low_food_level(), "low", + assert_eq(main.get_low_food_level(), "low", "At low threshold, still low") main.state = {"resources": {"food": Constants.LOW_FOOD_THRESHOLD + 1}} - _assert_eq(main.get_low_food_level(), "ok", + assert_eq(main.get_low_food_level(), "ok", "One above low threshold is ok") # ── Test: bias to food gathering when low ──────────────────────────────── print("") print("--- bias to food when low ---") main.state = {"resources": {"food": Constants.STARVATION_FOOD_THRESHOLD}} - _assert(main.should_bias_to_food_gathering(), "Should bias when starving") + assert_true(main.should_bias_to_food_gathering(), "Should bias when starving") main.state = {"resources": {"food": Constants.LOW_FOOD_THRESHOLD}} - _assert(main.should_bias_to_food_gathering(), "Should bias when low") + assert_true(main.should_bias_to_food_gathering(), "Should bias when low") main.state = {"resources": {"food": Constants.LOW_FOOD_THRESHOLD + 1}} - _assert(not main.should_bias_to_food_gathering(), "Should not bias when ok") + assert_true(not main.should_bias_to_food_gathering(), "Should not bias when ok") # ── Test: upkeep interval constant ──────────────────────────────────────── print("") print("--- upkeep interval ---") - _assert_eq(Constants.FOOD_UPKEEP_INTERVAL_TICKS, 10, + assert_eq(Constants.FOOD_UPKEEP_INTERVAL_TICKS, 10, "Upkeep should trigger every 10 ticks") # ── Test: base workers constant ─────────────────────────────────────────── print("") print("--- base workers constant ---") - _assert_eq(Constants.BASE_WORKERS_NO_UPKEEP, 2, + assert_eq(Constants.BASE_WORKERS_NO_UPKEEP, 2, "Base workers without upkeep should be 2") # ── Test: food per extra worker constant ────────────────────────────────── print("") print("--- food per extra worker ---") - _assert_eq(Constants.FOOD_PER_EXTRA_WORKER, 1, + assert_eq(Constants.FOOD_PER_EXTRA_WORKER, 1, "Each extra worker consumes 1 food per interval") # ── Test: constants are consistent with acceptance criteria ─────────────── print("") print("--- constants consistency ---") # STARVATION < LOW ensures interpolation range exists - _assert_lt(Constants.STARVATION_FOOD_THRESHOLD, Constants.LOW_FOOD_THRESHOLD, - "Starvation threshold must be below low threshold") + assert_true(Constants.STARVATION_FOOD_THRESHOLD < Constants.LOW_FOOD_THRESHOLD, + "Starvation threshold must be below low threshold", + "%s < %s should be true" % [str(Constants.STARVATION_FOOD_THRESHOLD), str(Constants.LOW_FOOD_THRESHOLD)]) # Speed factors are in [0, 1] - _assert_gte(Constants.STARVATION_SPEED_FACTOR, 0.0, "Starvation factor >= 0") - _assert_lte(Constants.STARVATION_SPEED_FACTOR, 1.0, "Starvation factor <= 1") - _assert_gte(Constants.LOW_FOOD_SPEED_FACTOR, 0.0, "Low food factor >= 0") - _assert_lte(Constants.LOW_FOOD_SPEED_FACTOR, 1.0, "Low food factor <= 1") - - # Summary - print("") - print("=== test_runner summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED — CI should fail") - quit(1) - else: - print("test_runner: ok") - quit(0) - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) - - -func _assert_lt(a: Variant, b: Variant, name: String) -> void: - _assert(a < b, name, "%s < %s should be true" % [str(a), str(b)]) - - -func _assert_gt(a: Variant, b: Variant, name: String) -> void: - _assert(a > b, name, "%s > %s should be true" % [str(a), str(b)]) - - -func _assert_lte(a: Variant, b: Variant, name: String) -> void: - _assert(a <= b, name, "%s <= %s should be true" % [str(a), str(b)]) - + assert_true(Constants.STARVATION_SPEED_FACTOR >= 0.0, "Starvation factor >= 0") + assert_true(Constants.STARVATION_SPEED_FACTOR <= 1.0, "Starvation factor <= 1") + assert_true(Constants.LOW_FOOD_SPEED_FACTOR >= 0.0, "Low food factor >= 0") + assert_true(Constants.LOW_FOOD_SPEED_FACTOR <= 1.0, "Low food factor <= 1") -func _assert_gte(a: Variant, b: Variant, name: String) -> void: - _assert(a >= b, name, "%s >= %s should be true" % [str(a), str(b)]) + main.free() diff --git a/tests/test_goal_progression.gd b/tests/test_goal_progression.gd index 185855c..23c58f2 100644 --- a/tests/test_goal_progression.gd +++ b/tests/test_goal_progression.gd @@ -2,238 +2,169 @@ ## Tests the extracted domain controller for goal lifecycle management. ## No DisplayServer or scene node required — fully deterministic. ## -## Run: godot --headless --quit -## Or: godot --headless --main-pack windowstead.pck --script tests/test_goal_progression.gd +## Run: godot --headless --path . --script res://tests/test_goal_progression.gd -extends SceneTree +extends "res://tests/test_case.gd" const GP := preload("res://scripts/goal_progression.gd") const RG := preload("res://scripts/rotating_goal.gd") -func _initialize() -> void: - var pass_count := 0 - var fail_count := 0 +func run_tests() -> void: # --- init_goals --- - pass_count += test("init_goals returns first non-completed goal", _test_init_goals_first) - pass_count += test("init_goals skips completed goals", _test_init_goals_skip_completed) - pass_count += test("init_goals returns empty dict when all done", _test_init_goals_all_done) + _test_init_goals_first() + _test_init_goals_skip_completed() + _test_init_goals_all_done() # --- compute_progress (resource) --- - pass_count += test("compute_progress updates resource progress from game_state", _test_compute_resource_progress) - pass_count += test("compute_progress does nothing for non-resource goals", _test_compute_resource_no_op) + _test_compute_resource_progress() + _test_compute_resource_no_op() # --- compute_progress (build) --- - pass_count += test("compute_progress updates build progress from builds array", _test_compute_build_progress) - pass_count += test("compute_progress ignores non-build goals", _test_compute_build_no_op) + _test_compute_build_progress() + _test_compute_build_no_op() # --- check_and_rotate (no completion) --- - pass_count += test("check_and_rotate returns unchanged when not complete", _test_check_rotate_not_complete) + _test_check_rotate_not_complete() # --- check_and_rotate (completion + rotation) --- - pass_count += test("check_and_rotate marks completed and rotates to next goal", _test_check_rotate_completes) - pass_count += test("check_and_rotate appends completed ID to completed_ids", _test_check_rotate_appends_id) - pass_count += test("check_and_rotate returns was_completed=true on rotation", _test_check_rotate_was_completed) + _test_check_rotate_completes() + _test_check_rotate_appends_id() + _test_check_rotate_was_completed() # --- process_tick (full flow) --- - pass_count += test("process_tick computes progress and detects completion in one call", _test_process_tick_flow) - pass_count += test("process_tick with empty goal returns unchanged result", _test_process_tick_empty_goal) + _test_process_tick_flow() + _test_process_tick_empty_goal() # --- Integration: multi-goal rotation --- - pass_count += test("multi-goal rotation: completes first, rotates to second", _test_multi_goal_rotation) - - fail_count = 14 - pass_count - print("\n=== Goal Progression Tests ===") - print("Passed: %d" % pass_count) - print("Failed: %d" % fail_count) - - if fail_count > 0: - print("REGRESSION FAILURES DETECTED") - quit(1) - else: - print("All goal progression tests passed.") - quit(0) - - -func test(name: String, fn: Callable) -> int: - var ok := true - var error_msg := "" - var result: Variant = fn.call() - if result is Dictionary: - ok = result.get("ok", false) - error_msg = result.get("msg", "no detail") - elif result == false: - ok = false - error_msg = "returned false" - - if ok: - print(" ✓ %s" % name) - return 1 - else: - print(" ✗ %s: %s" % [name, error_msg]) - return 0 + _test_multi_goal_rotation() # --- Individual tests --- -func _test_init_goals_first() -> Dictionary: +func _test_init_goals_first() -> void: var result := GP.init_goals([]) - if result.is_empty(): - return {"ok": false, "msg": "expected first goal, got empty"} - if result["id"] != "gather_wood": - return {"ok": false, "msg": "expected gather_wood, got %s" % result.get("id", "?")} - return {"ok": true} + assert_eq(result.get("id", ""), "gather_wood", "init_goals returns first non-completed goal") -func _test_init_goals_skip_completed() -> Dictionary: +func _test_init_goals_skip_completed() -> void: var result := GP.init_goals(["gather_wood"]) - if result.is_empty(): - return {"ok": false, "msg": "expected second goal, got empty"} - if result["id"] != "gather_stone": - return {"ok": false, "msg": "expected gather_stone, got %s" % result.get("id", "?")} - return {"ok": true} + assert_eq(result.get("id", ""), "gather_stone", "init_goals skips completed goals") -func _test_init_goals_all_done() -> Dictionary: +func _test_init_goals_all_done() -> void: var all_ids := [] for t in RG.GOAL_CATALOG: all_ids.append(t["id"]) var result := GP.init_goals(all_ids) - if not result.is_empty(): - return {"ok": false, "msg": "expected empty dict when all goals done, got %s" % result} - return {"ok": true} + assert_true(result.is_empty(), "init_goals returns empty dict when all done", "expected empty dict, got %s" % str(result)) -func _test_compute_resource_progress() -> Dictionary: +func _test_compute_resource_progress() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) # gather_wood, target 10 var game_state := {"harvested": {"wood": 5}} GP.compute_progress(goal, game_state) - if goal["current_progress"] != 5: - return {"ok": false, "msg": "expected progress 5, got %d" % goal["current_progress"]} - return {"ok": true} + assert_eq(goal["current_progress"], 5, "compute_progress updates resource progress from game_state") -func _test_compute_resource_no_op() -> Dictionary: +func _test_compute_resource_no_op() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[3]) # build_hut (non-resource) var game_state := {"harvested": {"wood": 100}} GP.compute_progress(goal, game_state) - if goal["current_progress"] != 0: - return {"ok": false, "msg": "resource progress should not change for build goal, got %d" % goal["current_progress"]} - return {"ok": true} + assert_eq(goal["current_progress"], 0, "compute_progress does nothing for non-resource goals") -func _test_compute_build_progress() -> Dictionary: +func _test_compute_build_progress() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[3]) # build_hut var game_state := {"builds": [{"kind": "hut"}, {"kind": "hut"}, {"kind": "workshop"}]} GP.compute_progress(goal, game_state) - if goal["current_progress"] != 2: - return {"ok": false, "msg": "expected build progress 2, got %d" % goal["current_progress"]} - return {"ok": true} + assert_eq(goal["current_progress"], 2, "compute_progress updates build progress from builds array") -func _test_compute_build_no_op() -> Dictionary: +func _test_compute_build_no_op() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) # gather_wood (non-build) var game_state := {"builds": [{"kind": "hut"}]} GP.compute_progress(goal, game_state) - if goal["current_progress"] != 0: - return {"ok": false, "msg": "build progress should not change for resource goal, got %d" % goal["current_progress"]} - return {"ok": true} + assert_eq(goal["current_progress"], 0, "compute_progress ignores non-build goals") -func _test_check_rotate_not_complete() -> Dictionary: +func _test_check_rotate_not_complete() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) # gather_wood, target 10 goal["current_progress"] = 5 # not complete yet var completed_ids := [] var result := GP.check_and_rotate(goal, completed_ids) - if result["was_completed"]: - return {"ok": false, "msg": "expected was_completed=false when not done"} - if result["goal_id"] != "": - return {"ok": false, "msg": "expected empty goal_id when not complete"} - if result["active_goal"]["id"] != "gather_wood": - return {"ok": false, "msg": "expected active_goal unchanged"} - return {"ok": true} + assert_false(result["was_completed"], "check_and_rotate returns unchanged when not complete", "expected was_completed=false when not done") + assert_eq(result["goal_id"], "", "check_and_rotate not complete: empty goal_id") + # check_and_rotate returns the ORIGINAL goal reference in the not-completed case. + assert_eq(result["active_goal"]["id"], "gather_wood", "check_and_rotate not complete: active_goal unchanged") -func _test_check_rotate_completes() -> Dictionary: +func _test_check_rotate_completes() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) # gather_wood, target 10 goal["current_progress"] = 10 # complete var completed_ids := [] var result := GP.check_and_rotate(goal, completed_ids) - if not result["was_completed"]: - return {"ok": false, "msg": "expected was_completed=true"} - if result["goal_id"] != "gather_wood": - return {"ok": false, "msg": "expected goal_id=gather_wood, got %s" % result["goal_id"]} - if result["active_goal"]["id"] != "gather_stone": - return {"ok": false, "msg": "expected rotation to gather_stone, got %s" % result["active_goal"]["id"]} - return {"ok": true} + assert_true(result["was_completed"], "check_and_rotate marks completed and rotates to next goal") + assert_eq(result["goal_id"], "gather_wood", "check_and_rotate completes: goal_id is gather_wood") + assert_eq(result["active_goal"].get("id", ""), "gather_stone", "check_and_rotate completes: rotates to gather_stone") -func _test_check_rotate_appends_id() -> Dictionary: +func _test_check_rotate_appends_id() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) # gather_wood goal["current_progress"] = 10 var completed_ids := ["some_old_id"] var result := GP.check_and_rotate(goal, completed_ids) - if not result["completed_ids"].has("gather_wood"): - return {"ok": false, "msg": "expected completed_ids to include gather_wood"} - if result["completed_ids"].size() != 2: - return {"ok": false, "msg": "expected 2 completed IDs, got %d" % result["completed_ids"].size()} - return {"ok": true} + assert_true(result["completed_ids"].has("gather_wood"), "check_and_rotate appends completed ID to completed_ids") + assert_eq(result["completed_ids"].size(), 2, "check_and_rotate appends: 2 completed IDs") -func _test_check_rotate_was_completed() -> Dictionary: +func _test_check_rotate_was_completed() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) goal["current_progress"] = 10 var completed_ids := [] var result := GP.check_and_rotate(goal, completed_ids) - if not result["was_completed"]: - return {"ok": false, "msg": "expected was_completed=true on rotation"} - return {"ok": true} + assert_true(result["was_completed"], "check_and_rotate returns was_completed=true on rotation") -func _test_process_tick_flow() -> Dictionary: +func _test_process_tick_flow() -> void: var goal = RG.apply_goal_template(RG.GOAL_CATALOG[0]) # gather_wood, target 10 - goal["current_progress"] = 10 # complete var completed_ids := [] + # process_tick recomputes progress from game_state, so completion must + # come from harvested amounts. var game_state := {"harvested": {"wood": 10}} var result := GP.process_tick(goal, completed_ids, game_state) - if not result["was_completed"]: - return {"ok": false, "msg": "expected was_completed=true after full tick"} - if result["active_goal"]["id"] != "gather_stone": - return {"ok": false, "msg": "expected rotation to gather_stone, got %s" % result["active_goal"]["id"]} - return {"ok": true} + assert_true(result["was_completed"], "process_tick computes progress and detects completion in one call") + assert_eq(result["active_goal"].get("id", ""), "gather_stone", "process_tick flow: rotates to gather_stone") -func _test_process_tick_empty_goal() -> Dictionary: +func _test_process_tick_empty_goal() -> void: var goal := {} var completed_ids := [] var game_state := {"harvested": {}} var result := GP.process_tick(goal, completed_ids, game_state) - if result["was_completed"]: - return {"ok": false, "msg": "expected was_completed=false for empty goal"} - if not result["active_goal"].is_empty(): - return {"ok": false, "msg": "expected empty active_goal for empty input"} - return {"ok": true} + assert_false(result["was_completed"], "process_tick with empty goal returns unchanged result", "expected was_completed=false for empty goal") + assert_true(result["active_goal"].is_empty(), "process_tick empty goal: active_goal stays empty") -func _test_multi_goal_rotation() -> Dictionary: - # Simulate completing two goals in sequence via process_tick +func _test_multi_goal_rotation() -> void: + # Simulate completing two goals in sequence via process_tick. var completed_ids := [] - # First tick: get gather_wood, complete it + # First tick: gather_wood. process_tick recomputes progress from + # game_state, so drive completion through harvested amounts (the old + # manually-set current_progress was overwritten by compute_progress and + # the test aborted via bare assert()). var goal = GP.init_goals(completed_ids) - assert(not goal.is_empty(), "should have initial goal") - goal["current_progress"] = 10 # simulate completion - var game_state := {"harvested": {}} + if not assert_true(not goal.is_empty(), "multi-goal rotation: should have initial goal"): + return + var game_state := {"harvested": {"wood": 10, "stone": 5}} var result1 := GP.process_tick(goal, completed_ids, game_state) - assert(result1["was_completed"], "first tick should complete") - assert(result1["active_goal"]["id"] == "gather_stone", "should rotate to gather_stone") + assert_true(result1["was_completed"], "multi-goal rotation: completes first, rotates to second", "first tick should complete") + assert_eq(result1["active_goal"].get("id", ""), "gather_stone", "multi-goal rotation: should rotate to gather_stone") completed_ids = result1["completed_ids"] - # Second tick: get gather_stone, complete it + # Second tick: gather_stone completes (target 5, harvested stone = 5). goal = result1["active_goal"] - goal["current_progress"] = 5 # simulate completion (target is 5) var result2 := GP.process_tick(goal, completed_ids, game_state) - assert(result2["was_completed"], "second tick should complete") - assert(result2["active_goal"]["id"] == "gather_food", "should rotate to gather_food") - - return {"ok": true} + assert_true(result2["was_completed"], "multi-goal rotation: second tick should complete") + assert_eq(result2["active_goal"].get("id", ""), "gather_food", "multi-goal rotation: should rotate to gather_food") diff --git a/tests/test_goal_reward.gd b/tests/test_goal_reward.gd index ac841f6..e23dc5a 100644 --- a/tests/test_goal_reward.gd +++ b/tests/test_goal_reward.gd @@ -1,52 +1,24 @@ -extends SceneTree +extends "res://tests/test_case.gd" # Tests for goal_reward.gd — misospace/windowstead#134 -# Run with: godot --headless --no-window --script tests/test_goal_reward.gd - -var test_pass := 0 -var test_fail := 0 - - -func assert_true(condition: bool, msg: String) -> void: - if not condition: - test_fail += 1 - print("FAIL: %s" % msg) - else: - test_pass += 1 - - -func assert_eq(actual, expected, msg: String) -> void: - if actual != expected: - test_fail += 1 - print("FAIL: %s (got %s, expected %s)" % [msg, str(actual), str(expected)]) - else: - test_pass += 1 - - -func _initialize() -> void: - var reward_script := load("res://scripts/goal_reward.gd") - - test_apply_reward_returns_dict(reward_script) - test_apply_reward_returns_empty_for_unknown(reward_script) - test_resource_trickle_payouts(reward_script) - test_reward_expiration(reward_script) - test_ambient_improve_consumption(reward_script) - test_recruit_discount_consumption(reward_script) - test_build_speed_bonus(reward_script) - test_gather_speed_multiplier(reward_script) - test_haul_speed_multiplier(reward_script) - test_format_active_rewards(reward_script) - test_get_reward_label(reward_script) - test_multiple_trickle_rewards(reward_script) - test_tick_returns_surviving(reward_script) - - print("") - print("=== test_goal_reward summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED — CI should fail") - quit(1) - else: - print("test_goal_reward: ok") - quit(0) +# Run with: godot --headless --path . --script res://tests/test_goal_reward.gd + +const GR := preload("res://scripts/goal_reward.gd") + + +func run_tests() -> void: + test_apply_reward_returns_dict(GR) + test_apply_reward_returns_empty_for_unknown(GR) + test_resource_trickle_payouts(GR) + test_reward_expiration(GR) + test_ambient_improve_consumption(GR) + test_recruit_discount_consumption(GR) + test_build_speed_bonus(GR) + test_gather_speed_multiplier(GR) + test_haul_speed_multiplier(GR) + test_format_active_rewards(GR) + test_get_reward_label(GR) + test_multiple_trickle_rewards(GR) + test_tick_returns_surviving(GR) func test_apply_reward_returns_dict(_script: Script) -> void: @@ -91,13 +63,18 @@ func test_reward_expiration(_script: Script) -> void: var game_state := {"resources": {"food": 0}} var rewards := [reward] - var result + # tick_rewards reports only the labels that expired on that specific tick + # (later ticks return a fresh empty "expired" list), so accumulate across + # the loop instead of inspecting only the final result — the old check on + # the last result was stale. + var all_expired := [] for i in range(5): - result = GoalReward.tick_rewards(rewards, game_state) + var result = GoalReward.tick_rewards(rewards, game_state) rewards = result["new_rewards"] + all_expired.append_array(result["expired"]) assert_true(rewards.is_empty(), "Reward expired after ticks") - assert_true(result.expired.size() > 0, "Has expired label") + assert_true(all_expired.size() > 0, "Has expired label") func test_ambient_improve_consumption(_script: Script) -> void: @@ -203,11 +180,3 @@ func test_tick_returns_surviving(_script: Script) -> void: assert_eq(result.new_rewards.size(), 1, "Only reward1 survives") assert_true(result.expired.size() > 0, "Has one expired reward") - - -func assert_false(condition: bool, msg: String) -> void: - if condition: - test_fail += 1 - print("FAIL: %s (expected false)" % msg) - else: - test_pass += 1 diff --git a/tests/test_layout_math.gd b/tests/test_layout_math.gd index c525459..7723fd0 100644 --- a/tests/test_layout_math.gd +++ b/tests/test_layout_math.gd @@ -5,80 +5,67 @@ ## Run: godot --headless --quit ## Or: godot --headless --main-pack windowstead.pck --script tests/test_layout_math.gd -extends SceneTree +extends "res://tests/test_case.gd" const LM := preload("res://scripts/layout_math.gd") -func _initialize() -> void: - var pass_count := 0 - var fail_count := 0 - +func run_tests() -> void: # --- Anchor family selection --- - pass_count += test("anchor_family maps bottom correctly", _test_anchor_family_bottom) - pass_count += test("anchor_family maps side anchors to vertical", _test_anchor_family_side) + check("anchor_family maps bottom correctly", _test_anchor_family_bottom) + check("anchor_family maps side anchors to vertical", _test_anchor_family_side) # --- Tile sizing --- - pass_count += test("tile_px bottom at zoom 1.0", _test_tile_px_bottom) - pass_count += test("tile size bottom is square", _test_tile_size_bottom_square) - pass_count += test("tile_px vertical at zoom 1.0", _test_tile_px_vertical) - pass_count += test("tile_px scales with zoom factor", _test_tile_px_zoom) - pass_count += test("tile_px never returns zero or negative", _test_tile_px_floor) - pass_count += test("vertical tile sizes are square", _test_tile_square) + check("tile_px bottom at zoom 1.0", _test_tile_px_bottom) + check("tile size bottom is square", _test_tile_size_bottom_square) + check("tile_px vertical at zoom 1.0", _test_tile_px_vertical) + check("tile_px scales with zoom factor", _test_tile_px_zoom) + check("tile_px never returns zero or negative", _test_tile_px_floor) + check("vertical tile sizes are square", _test_tile_square) # --- Grid dimensions --- - pass_count += test("grid dims bottom: 32x5", _test_grid_dims_bottom) - pass_count += test("grid dims vertical: 10x24", _test_grid_dims_vertical) + check("grid dims bottom: 32x5", _test_grid_dims_bottom) + check("grid dims vertical: 10x24", _test_grid_dims_vertical) # --- World panel size --- - pass_count += test("world size bottom at zoom 1.0", _test_world_size_bottom) - pass_count += test("world size vertical at zoom 1.0", _test_world_size_vertical) - pass_count += test("world size scales with tile size", _test_world_size_scales) + check("world size bottom at zoom 1.0", _test_world_size_bottom) + check("world size vertical at zoom 1.0", _test_world_size_vertical) + check("world size scales with tile size", _test_world_size_scales) # --- Dock padding --- - pass_count += test("dock padding bottom: (48, 170)", _test_dock_padding_bottom) - pass_count += test("dock padding vertical: (60, 300)", _test_dock_padding_vertical) + check("dock padding bottom: (48, 170)", _test_dock_padding_bottom) + check("dock padding vertical: (60, 300)", _test_dock_padding_vertical) # --- Dock window size --- - pass_count += test("dock size bottom does not widen for sidebar", _test_dock_size_bottom_no_sidebar_width) - pass_count += test("dock size vertical excludes sidebar width", _test_dock_size_vertical_no_sidebar) + check("dock size bottom does not widen for sidebar", _test_dock_size_bottom_no_sidebar_width) + check("dock size vertical excludes sidebar width", _test_dock_size_vertical_no_sidebar) # --- Dock position --- - pass_count += test("dock position bottom is centered", _test_dock_pos_bottom_centered) - pass_count += test("dock position left is bottom-left aligned", _test_dock_pos_left_bottom) - pass_count += test("dock position right is bottom-right aligned", _test_dock_pos_right_bottom) + check("dock position bottom is centered", _test_dock_pos_bottom_centered) + check("dock position left is bottom-left aligned", _test_dock_pos_left_bottom) + check("dock position right is bottom-right aligned", _test_dock_pos_right_bottom) # --- Popup position --- - pass_count += test("popup position bottom is top-right", _test_popup_pos_bottom) - pass_count += test("popup position right is top-left", _test_popup_pos_right) - pass_count += test("popup position left is top-left", _test_popup_pos_left) + check("popup position bottom is top-right", _test_popup_pos_bottom) + check("popup position right is top-left", _test_popup_pos_right) + check("popup position left is top-left", _test_popup_pos_left) # --- Popup bounds --- - pass_count += test("popup stays within bounds at normal size", _test_popup_in_bounds_normal) - pass_count += test("popup stays within bounds at max zoom", _test_popup_in_bounds_max_zoom) - pass_count += test("popup out of bounds when sidebar exceeds screen", _test_popup_out_of_bounds) + check("popup stays within bounds at normal size", _test_popup_in_bounds_normal) + check("popup stays within bounds at max zoom", _test_popup_in_bounds_max_zoom) + check("popup out of bounds when sidebar exceeds screen", _test_popup_out_of_bounds) # --- Stockpile position --- - pass_count += test("stockpile bottom: (11, 2)", _test_stockpile_bottom) - pass_count += test("stockpile vertical: (2, 9)", _test_stockpile_vertical) + check("stockpile bottom: (11, 2)", _test_stockpile_bottom) + check("stockpile vertical: (2, 9)", _test_stockpile_vertical) # --- Integration: anchor change round-trip --- - pass_count += test("anchor change: grid dims swap correctly", _test_anchor_swap) - pass_count += test("anchor change: tile size swaps correctly", _test_tile_swap) - - fail_count = 30 - pass_count - print("\n=== Layout Regression Tests ===") - print("Passed: %d" % pass_count) - print("Failed: %d" % fail_count) - - if fail_count > 0: - print("REGRESSION FAILURES DETECTED") - quit(1) - else: - print("All layout regression tests passed.") - quit(0) - - -func test(name: String, fn: Callable) -> int: + check("anchor change: grid dims swap correctly", _test_anchor_swap) + check("anchor change: tile size swaps correctly", _test_tile_swap) + + +## Runs a check callable and reports it through the shared assertion API. +## The callable may return a bool, a {ok, msg} Dictionary, or null (assert-based). +func check(name: String, fn: Callable) -> void: var ok := true var error_msg := "" var result: Variant = fn.call() @@ -88,13 +75,8 @@ func test(name: String, fn: Callable) -> int: elif result == false: ok = false error_msg = "returned false" - - if ok: - print(" ✓ %s" % name) - return 1 - else: - print(" ✗ %s: %s" % [name, error_msg]) - return 0 + + assert_true(ok, name, error_msg) # --- Individual tests --- diff --git a/tests/test_milestone_goals.gd b/tests/test_milestone_goals.gd index 9a45220..3351ce6 100644 --- a/tests/test_milestone_goals.gd +++ b/tests/test_milestone_goals.gd @@ -1,51 +1,20 @@ -extends SceneTree +extends "res://tests/test_case.gd" # Tests for milestone_manager.gd — misospace/windowstead#132 -var test_pass := 0 -var test_fail := 0 +const MS := preload("res://scripts/milestone_manager.gd") -func _initialize() -> void: - var ms_script := load("res://scripts/milestone_manager.gd") - test_catalog_exists(ms_script) - test_make_goal_state(ms_script) - test_get_current_milestone(ms_script) - test_evaluate_build_milestone(ms_script) - test_evaluate_stockpile_milestone(ms_script) - test_evaluate_worker_milestone(ms_script) - test_is_milestone_complete(ms_script) - test_advance_to_next(ms_script) - test_milestone_description(ms_script) - test_save_load_compatibility(ms_script) - - print("") - print("=== test_milestone_goals summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED — CI should fail") - quit(1) - else: - print("test_milestone_goals: ok") - quit(0) - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) - -func _assert_str_eq(actual: String, expected: String, name: String) -> void: - _assert(actual == expected, name, "expected '%s', got '%s'" % [expected, actual]) +func run_tests() -> void: + test_catalog_exists(MS) + test_make_goal_state(MS) + test_get_current_milestone(MS) + test_evaluate_build_milestone(MS) + test_evaluate_stockpile_milestone(MS) + test_evaluate_worker_milestone(MS) + test_is_milestone_complete(MS) + test_advance_to_next(MS) + test_milestone_description(MS) + test_save_load_compatibility(MS) # ── Tests ───────────────────────────────────────────────────────────────────── @@ -55,36 +24,36 @@ func test_catalog_exists(ms: Variant) -> void: print("--- catalog ---") var catalog = ms.MILESTONE_CATALOG - _assert(catalog.size() > 0, "catalog_not_empty") - _assert_eq(catalog.size(), 5, "catalog_has_5_entries") + assert_true(catalog.size() > 0, "catalog_not_empty") + assert_eq(catalog.size(), 5, "catalog_has_5_entries") # Check expected types are present var types := {} for entry in catalog: types[entry["type"]] = true - _assert(types.has(ms.MILESTONE_TYPE_BUILD), "catalog_has_build_type") - _assert(types.has(ms.MILESTONE_TYPE_STOCKPILE), "catalog_has_stockpile_type") - _assert(types.has(ms.MILESTONE_TYPE_WORKER), "catalog_has_worker_type") + assert_true(types.has(ms.MILESTONE_TYPE_BUILD), "catalog_has_build_type") + assert_true(types.has(ms.MILESTONE_TYPE_STOCKPILE), "catalog_has_stockpile_type") + assert_true(types.has(ms.MILESTONE_TYPE_WORKER), "catalog_has_worker_type") # Check all 5 milestone IDs from the issue examples var expected_ids := ["build_hut", "stockpile_food", "build_workshop", "build_garden", "support_third_worker"] for eid in expected_ids: - _assert(catalog.any(func(e): return e["id"] == eid), "catalog_has_id_%s" % eid) + assert_true(catalog.any(func(e): return e["id"] == eid), "catalog_has_id_%s" % eid) # Check each entry has required fields for entry in catalog: - _assert(entry.has("id"), "entry_%s_has_id" % entry.get("id", "?")) - _assert(entry.has("name"), "entry_%s_has_name" % entry.get("id", "?")) - _assert(entry.has("type"), "entry_%s_has_type" % entry.get("id", "?")) - _assert(entry.has("target"), "entry_%s_has_target" % entry.get("id", "?")) - _assert(entry.has("description"), "entry_%s_has_description" % entry.get("id", "?")) + assert_true(entry.has("id"), "entry_%s_has_id" % entry.get("id", "?")) + assert_true(entry.has("name"), "entry_%s_has_name" % entry.get("id", "?")) + assert_true(entry.has("type"), "entry_%s_has_type" % entry.get("id", "?")) + assert_true(entry.has("target"), "entry_%s_has_target" % entry.get("id", "?")) + assert_true(entry.has("description"), "entry_%s_has_description" % entry.get("id", "?")) # Verify order matches the issue example chain - _assert_str_eq(catalog[0]["id"], "build_hut", "first_milestone_is_build_hut") - _assert_str_eq(catalog[1]["id"], "stockpile_food", "second_milestone_is_stockpile_food") - _assert_str_eq(catalog[2]["id"], "build_workshop", "third_milestone_is_build_workshop") - _assert_str_eq(catalog[3]["id"], "build_garden", "fourth_milestone_is_build_garden") - _assert_str_eq(catalog[4]["id"], "support_third_worker", "fifth_milestone_is_support_third_worker") + assert_eq(catalog[0]["id"], "build_hut", "first_milestone_is_build_hut") + assert_eq(catalog[1]["id"], "stockpile_food", "second_milestone_is_stockpile_food") + assert_eq(catalog[2]["id"], "build_workshop", "third_milestone_is_build_workshop") + assert_eq(catalog[3]["id"], "build_garden", "fourth_milestone_is_build_garden") + assert_eq(catalog[4]["id"], "support_third_worker", "fifth_milestone_is_support_third_worker") func test_make_goal_state(ms: Variant) -> void: @@ -92,15 +61,15 @@ func test_make_goal_state(ms: Variant) -> void: print("--- make_goal_state ---") var state = ms.make_goal_state() - _assert(state.has("milestone_id"), "state_has_milestone_id") - _assert(state.has("completed_ids"), "state_has_completed_ids") - _assert_str_eq(state["milestone_id"], "build_hut", "goal_starts_at_first_milestone") - _assert_eq(state["completed_ids"].size(), 0, "goal_has_empty_completed_ids") + assert_true(state.has("milestone_id"), "state_has_milestone_id") + assert_true(state.has("completed_ids"), "state_has_completed_ids") + assert_eq(state["milestone_id"], "build_hut", "goal_starts_at_first_milestone") + assert_eq(state["completed_ids"].size(), 0, "goal_has_empty_completed_ids") # Verify completed_ids is a fresh array (not shared) state["completed_ids"].append("fake") var state2 = ms.make_goal_state() - _assert_eq(state2["completed_ids"].size(), 0, "fresh_state_has_no_contamination") + assert_eq(state2["completed_ids"].size(), 0, "fresh_state_has_no_contamination") func test_get_current_milestone(ms: Variant) -> void: @@ -108,12 +77,12 @@ func test_get_current_milestone(ms: Variant) -> void: print("--- get_current_milestone ---") var current = ms.get_current_milestone(ms.MILESTONE_CATALOG, "build_hut") - _assert_str_eq(current["id"], "build_hut", "get_returns_correct_id") - _assert_str_eq(current["name"], "Build a hut", "get_returns_correct_name") + assert_eq(current["id"], "build_hut", "get_returns_correct_id") + assert_eq(current["name"], "Build a hut", "get_returns_correct_name") # Non-existent milestone returns empty dict var missing = ms.get_current_milestone(ms.MILESTONE_CATALOG, "nonexistent") - _assert(missing.is_empty(), "get_nonexistent_returns_empty") + assert_true(missing.is_empty(), "get_nonexistent_returns_empty") func test_evaluate_build_milestone(ms: Variant) -> void: @@ -129,8 +98,8 @@ func test_evaluate_build_milestone(ms: Variant) -> void: } var hut_milestone = ms.MILESTONE_CATALOG[0] # build_hut var result = ms.evaluate_milestone(hut_milestone, game_state) - _assert_eq(result["progress"], 0, "hut_not_built_yet_progress_0") - _assert_eq(result["total"], 1, "hut_total_is_1") + assert_eq(result["progress"], 0, "hut_not_built_yet_progress_0") + assert_eq(result["total"], 1, "hut_total_is_1") # Build hut completed game_state = { @@ -140,7 +109,7 @@ func test_evaluate_build_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(hut_milestone, game_state) - _assert_eq(result["progress"], 1, "hut_built_progress_1") + assert_eq(result["progress"], 1, "hut_built_progress_1") # Build workshop not yet built var workshop_milestone = ms.MILESTONE_CATALOG[2] # build_workshop @@ -150,7 +119,7 @@ func test_evaluate_build_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(workshop_milestone, game_state) - _assert_eq(result["progress"], 0, "workshop_not_built_progress_0") + assert_eq(result["progress"], 0, "workshop_not_built_progress_0") game_state = { "builds": [ @@ -159,12 +128,12 @@ func test_evaluate_build_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(workshop_milestone, game_state) - _assert_eq(result["progress"], 1, "workshop_built_progress_1") + assert_eq(result["progress"], 1, "workshop_built_progress_1") # No builds at all game_state = {"builds": []} result = ms.evaluate_milestone(hut_milestone, game_state) - _assert_eq(result["progress"], 0, "no_builds_progress_0") + assert_eq(result["progress"], 0, "no_builds_progress_0") func test_evaluate_stockpile_milestone(ms: Variant) -> void: @@ -176,28 +145,28 @@ func test_evaluate_stockpile_milestone(ms: Variant) -> void: # No food harvested var game_state := {"harvested": {}} var result = ms.evaluate_milestone(stockpile_milestone, game_state) - _assert_eq(result["progress"], 0, "stockpile_no_harvest_progress_0") - _assert_eq(result["total"], 10, "stockpile_total_is_10") + assert_eq(result["progress"], 0, "stockpile_no_harvest_progress_0") + assert_eq(result["total"], 10, "stockpile_total_is_10") # Partial progress game_state = {"harvested": {"food": 4}} result = ms.evaluate_milestone(stockpile_milestone, game_state) - _assert_eq(result["progress"], 4, "stockpile_partial_progress_4") + assert_eq(result["progress"], 4, "stockpile_partial_progress_4") # Exactly at target game_state = {"harvested": {"food": 10}} result = ms.evaluate_milestone(stockpile_milestone, game_state) - _assert_eq(result["progress"], 10, "stockpile_at_target_progress_10") + assert_eq(result["progress"], 10, "stockpile_at_target_progress_10") # Over target (should clamp) game_state = {"harvested": {"food": 15, "wood": 3}} result = ms.evaluate_milestone(stockpile_milestone, game_state) - _assert_eq(result["progress"], 10, "stockpile_clamped_at_target") + assert_eq(result["progress"], 10, "stockpile_clamped_at_target") # Other resources shouldn't interfere game_state = {"harvested": {"wood": 50}} result = ms.evaluate_milestone(stockpile_milestone, game_state) - _assert_eq(result["progress"], 0, "stockpile_other_resource_ignored") + assert_eq(result["progress"], 0, "stockpile_other_resource_ignored") func test_evaluate_worker_milestone(ms: Variant) -> void: @@ -209,8 +178,8 @@ func test_evaluate_worker_milestone(ms: Variant) -> void: # No workers var game_state := {"workers": []} var result = ms.evaluate_milestone(worker_milestone, game_state) - _assert_eq(result["progress"], 0, "worker_no_workers_progress_0") - _assert_eq(result["total"], 3, "worker_total_is_3") + assert_eq(result["progress"], 0, "worker_no_workers_progress_0") + assert_eq(result["total"], 3, "worker_total_is_3") # One active worker (break_ticks == 0 means active) game_state = { @@ -219,7 +188,7 @@ func test_evaluate_worker_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(worker_milestone, game_state) - _assert_eq(result["progress"], 1, "worker_one_active") + assert_eq(result["progress"], 1, "worker_one_active") # Two active workers game_state = { @@ -229,7 +198,7 @@ func test_evaluate_worker_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(worker_milestone, game_state) - _assert_eq(result["progress"], 2, "worker_two_active") + assert_eq(result["progress"], 2, "worker_two_active") # Three active workers (milestone complete) game_state = { @@ -240,7 +209,7 @@ func test_evaluate_worker_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(worker_milestone, game_state) - _assert_eq(result["progress"], 3, "worker_three_active_progress_3") + assert_eq(result["progress"], 3, "worker_three_active_progress_3") # Worker with break_ticks > 0 is not active game_state = { @@ -251,7 +220,7 @@ func test_evaluate_worker_milestone(ms: Variant) -> void: ], } result = ms.evaluate_milestone(worker_milestone, game_state) - _assert_eq(result["progress"], 2, "worker_excludes_broken_out") + assert_eq(result["progress"], 2, "worker_excludes_broken_out") func test_is_milestone_complete(ms: Variant) -> void: @@ -270,27 +239,27 @@ func test_is_milestone_complete(ms: Variant) -> void: # Build hut should be complete var hut_milestone = ms.MILESTONE_CATALOG[0] - _assert(ms.is_milestone_complete(hut_milestone, game_state), "hut_complete_when_built") + assert_true(ms.is_milestone_complete(hut_milestone, game_state), "hut_complete_when_built") # Stockpile food should be complete var stockpile_milestone = ms.MILESTONE_CATALOG[1] - _assert(ms.is_milestone_complete(stockpile_milestone, game_state), "stockpile_complete_at_target") + assert_true(ms.is_milestone_complete(stockpile_milestone, game_state), "stockpile_complete_at_target") # Support third worker should be complete var worker_milestone = ms.MILESTONE_CATALOG[4] - _assert(ms.is_milestone_complete(worker_milestone, game_state), "worker_complete_at_three") + assert_true(ms.is_milestone_complete(worker_milestone, game_state), "worker_complete_at_three") # Workshop should NOT be complete var workshop_milestone = ms.MILESTONE_CATALOG[2] - _assert(not ms.is_milestone_complete(workshop_milestone, game_state), "workshop_not_complete_without_build") + assert_true(not ms.is_milestone_complete(workshop_milestone, game_state), "workshop_not_complete_without_build") # Garden should NOT be complete var garden_milestone = ms.MILESTONE_CATALOG[3] - _assert(not ms.is_milestone_complete(garden_milestone, game_state), "garden_not_complete_without_build") + assert_true(not ms.is_milestone_complete(garden_milestone, game_state), "garden_not_complete_without_build") # Empty game state — nothing should be complete var empty_state := {} - _assert(not ms.is_milestone_complete(hut_milestone, empty_state), "hut_not_complete_empty_state") + assert_true(not ms.is_milestone_complete(hut_milestone, empty_state), "hut_not_complete_empty_state") func test_advance_to_next(ms: Variant) -> void: @@ -299,26 +268,26 @@ func test_advance_to_next(ms: Variant) -> void: # Advance from build_hut → stockpile_food var next = ms.advance_to_next([], "build_hut") - _assert_str_eq(next, "stockpile_food", "advance_from_hut_to_stockpile") + assert_eq(next, "stockpile_food", "advance_from_hut_to_stockpile") # Advance from stockpile_food → build_workshop next = ms.advance_to_next(["build_hut"], "stockpile_food") - _assert_str_eq(next, "build_workshop", "advance_from_stockpile_to_workshop") + assert_eq(next, "build_workshop", "advance_from_stockpile_to_workshop") # Advance through the chain next = ms.advance_to_next(["build_hut", "stockpile_food"], "build_workshop") - _assert_str_eq(next, "build_garden", "advance_from_workshop_to_garden") + assert_eq(next, "build_garden", "advance_from_workshop_to_garden") next = ms.advance_to_next(["build_hut", "stockpile_food", "build_workshop"], "build_garden") - _assert_str_eq(next, "support_third_worker", "advance_from_garden_to_worker") + assert_eq(next, "support_third_worker", "advance_from_garden_to_worker") # Last milestone — should return itself (no next) next = ms.advance_to_next(["build_hut", "stockpile_food", "build_workshop", "build_garden"], "support_third_worker") - _assert_str_eq(next, "support_third_worker", "last_milestone_returns_itself") + assert_eq(next, "support_third_worker", "last_milestone_returns_itself") # Unknown milestone ID — should return itself (defensive) next = ms.advance_to_next([], "nonexistent") - _assert_str_eq(next, "nonexistent", "unknown_id_returns_itself") + assert_eq(next, "nonexistent", "unknown_id_returns_itself") # Full chain traversal var completed_ids := [] @@ -328,12 +297,14 @@ func test_advance_to_next(ms: Variant) -> void: current_id = ms.advance_to_next(completed_ids, current_id) completed_ids.append(current_id) chain.append(current_id) - _assert_eq(chain.size(), 6, "full_chain_has_5_transitions") - _assert_str_eq(chain[0], "build_hut", "chain_starts_with_hut") - _assert_str_eq(chain[1], "stockpile_food", "chain_step_2_stockpile") - _assert_str_eq(chain[2], "build_workshop", "chain_step_3_workshop") - _assert_str_eq(chain[3], "build_garden", "chain_step_4_garden") - _assert_str_eq(chain[4], "support_third_worker", "chain_step_5_worker") + # MILESTONE_CATALOG has 5 entries, so the full chain visits 5 milestones + # (4 transitions) — the old expectation of 6 was stale. + assert_eq(chain.size(), 5, "full_chain_has_5_transitions") + assert_eq(chain[0], "build_hut", "chain_starts_with_hut") + assert_eq(chain[1], "stockpile_food", "chain_step_2_stockpile") + assert_eq(chain[2], "build_workshop", "chain_step_3_workshop") + assert_eq(chain[3], "build_garden", "chain_step_4_garden") + assert_eq(chain[4], "support_third_worker", "chain_step_5_worker") func test_milestone_description(ms: Variant) -> void: @@ -342,16 +313,16 @@ func test_milestone_description(ms: Variant) -> void: var hut_milestone = ms.MILESTONE_CATALOG[0] var desc = ms.milestone_description(hut_milestone) - _assert_str_eq(desc, "Your first shelter. The crew gets a roof.", "hut_description_matches") + assert_eq(desc, "Your first shelter. The crew gets a roof.", "hut_description_matches") var worker_milestone = ms.MILESTONE_CATALOG[4] desc = ms.milestone_description(worker_milestone) - _assert_str_eq(desc, "A full crew. The colony is growing.", "worker_description_matches") + assert_eq(desc, "A full crew. The colony is growing.", "worker_description_matches") # Milestone without description field returns default var no_desc := {"id": "test", "description": ""} desc = ms.milestone_description(no_desc) - _assert_str_eq(desc, "", "empty_description_returns_empty") + assert_eq(desc, "", "empty_description_returns_empty") func test_save_load_compatibility(ms: Variant) -> void: @@ -362,9 +333,9 @@ func test_save_load_compatibility(ms: Variant) -> void: var fresh_state = ms.make_goal_state() var serialized = JSON.stringify(fresh_state) var parsed = JSON.parse_string(serialized) - _assert(parsed is Dictionary, "fresh_state_serializes_to_dict") - _assert_str_eq(parsed["milestone_id"], "build_hut", "round_trip_milestone_id") - _assert_eq(parsed["completed_ids"].size(), 0, "round_trip_empty_completed") + assert_true(parsed is Dictionary, "fresh_state_serializes_to_dict") + assert_eq(parsed["milestone_id"], "build_hut", "round_trip_milestone_id") + assert_eq(parsed["completed_ids"].size(), 0, "round_trip_empty_completed") # Test 2: State with completed milestones round-trips var state_with_progress = { @@ -373,9 +344,9 @@ func test_save_load_compatibility(ms: Variant) -> void: } serialized = JSON.stringify(state_with_progress) parsed = JSON.parse_string(serialized) - _assert_str_eq(parsed["milestone_id"], "stockpile_food", "progress_milestone_id_preserved") - _assert_eq(parsed["completed_ids"].size(), 1, "progress_completed_count_preserved") - _assert_str_eq(parsed["completed_ids"][0], "build_hut", "progress_completed_entry_preserved") + assert_eq(parsed["milestone_id"], "stockpile_food", "progress_milestone_id_preserved") + assert_eq(parsed["completed_ids"].size(), 1, "progress_completed_count_preserved") + assert_eq(parsed["completed_ids"][0], "build_hut", "progress_completed_entry_preserved") # Test 3: All milestones completed — state round-trips var all_complete = { @@ -384,8 +355,8 @@ func test_save_load_compatibility(ms: Variant) -> void: } serialized = JSON.stringify(all_complete) parsed = JSON.parse_string(serialized) - _assert_str_eq(parsed["milestone_id"], "support_third_worker", "all_complete_milestone_id") - _assert_eq(parsed["completed_ids"].size(), 4, "all_complete_completed_count") + assert_eq(parsed["milestone_id"], "support_third_worker", "all_complete_milestone_id") + assert_eq(parsed["completed_ids"].size(), 4, "all_complete_completed_count") # Test 4: State integrates with game_state save schema (harvested/builds/workers) var full_save := { @@ -399,7 +370,7 @@ func test_save_load_compatibility(ms: Variant) -> void: } serialized = JSON.stringify(full_save) parsed = JSON.parse_string(serialized) - _assert(parsed.has("milestone_id") == false, "full_save_no_milestone_keys_yet") + assert_true(parsed.has("milestone_id") == false, "full_save_no_milestone_keys_yet") # Test 5: Milestone state can be embedded in a full save and round-trips var full_save_with_milestones := { @@ -414,10 +385,10 @@ func test_save_load_compatibility(ms: Variant) -> void: } serialized = JSON.stringify(full_save_with_milestones) parsed = JSON.parse_string(serialized) - _assert(parsed.has("milestone_state"), "full_save_has_milestone_state") + assert_true(parsed.has("milestone_state"), "full_save_has_milestone_state") var ms_state = parsed["milestone_state"] - _assert_str_eq(ms_state["milestone_id"], "build_hut", "embedded_milestone_id") - _assert_eq(ms_state["completed_ids"].size(), 0, "embedded_empty_completed") + assert_eq(ms_state["milestone_id"], "build_hut", "embedded_milestone_id") + assert_eq(ms_state["completed_ids"].size(), 0, "embedded_empty_completed") # Test 6: Milestone state survives save/load with completed milestones var saved_ms_state := { @@ -428,8 +399,8 @@ func test_save_load_compatibility(ms: Variant) -> void: serialized = JSON.stringify(full_save_with_milestones) parsed = JSON.parse_string(serialized) ms_state = parsed["milestone_state"] - _assert_str_eq(ms_state["milestone_id"], "build_workshop", "saved_milestone_id") - _assert_eq(ms_state["completed_ids"].size(), 2, "saved_completed_count") + assert_eq(ms_state["milestone_id"], "build_workshop", "saved_milestone_id") + assert_eq(ms_state["completed_ids"].size(), 2, "saved_completed_count") # Test 7: Progress evaluation with round-tripped state gives correct results var game_state := { @@ -441,6 +412,6 @@ func test_save_load_compatibility(ms: Variant) -> void: } var stockpile_milestone = ms.MILESTONE_CATALOG[1] var eval_result = ms.evaluate_milestone(stockpile_milestone, game_state) - _assert_eq(eval_result["progress"], 7, "stockpile_progress_from_harvested") - _assert_eq(eval_result["total"], 10, "stockpile_total_10") - _assert(not ms.is_milestone_complete(stockpile_milestone, game_state), "stockpile_not_yet_complete_at_7") + assert_eq(eval_result["progress"], 7, "stockpile_progress_from_harvested") + assert_eq(eval_result["total"], 10, "stockpile_total_10") + assert_true(not ms.is_milestone_complete(stockpile_milestone, game_state), "stockpile_not_yet_complete_at_7") diff --git a/tests/test_recruit_worker.gd b/tests/test_recruit_worker.gd index 46d455c..25d0b84 100644 --- a/tests/test_recruit_worker.gd +++ b/tests/test_recruit_worker.gd @@ -1,15 +1,17 @@ +extends "res://tests/test_case.gd" + ## Tests for recruit worker decision logic (issue #149, links to #133, #135). ## Verifies: successful recruit, blocked recruit at cap, name cycling, food impact messaging. -extends SceneTree +# A completed hut raises the cap to 4 (base 2 + hut bonus 2). +const HUT_BUILD := {"id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, "complete": true, "delivered": {"wood": 6, "stone": 2}, "progress": 1.0} -var test_pass := 0 -var test_fail := 0 -func _initialize() -> void: - # Load main.gd and create an instance (no UI nodes needed for logic tests) - var main_script: GDScript = preload("res://scripts/main.gd") - var main: Control = main_script.new() +func run_tests() -> void: + # main.gd references the GameState autoload, so it must be load()ed at + # runtime — preload() compiles before autoloads are registered in --script mode. + var main_script: GDScript = load("res://scripts/main.gd") + var main = main_script.new() test_can_recruit_with_capacity(main) test_cannot_recruit_at_cap(main) @@ -20,47 +22,21 @@ func _initialize() -> void: test_food_impact_messaging_for_extra_workers(main) test_food_impact_no_upkeep_when_under_threshold(main) - # Summary - print("") - print("=== test_recruit_worker summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED") - quit(1) - else: - print("test_recruit_worker: ok") - quit(0) - - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) + main.free() # ── Test 1: can_recruit returns true when under cap ── -func test_can_recruit_with_capacity(main: Control) -> void: +func test_can_recruit_with_capacity(main) -> void: print("") print("--- recruit with capacity ---") - var builds = [ - {"id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, "complete": true, "delivered": {"wood": 6, "stone": 2}, "progress": 1.0}, - ] + var builds = [HUT_BUILD.duplicate(true)] _setup_state(main, builds, [{"name": "Jun", "task": {"kind": "", "data": {}}}]) # Cap is 4 (base 2 + hut bonus 2), 1 worker → can recruit - _assert(main.can_recruit_worker(), "can_recruit: returns true when under cap (1/4)") + assert_true(main.can_recruit_worker(), "can_recruit: returns true when under cap (1/4)") # ── Test 2: can_recruit returns false at cap ── -func test_cannot_recruit_at_cap(main: Control) -> void: +func test_cannot_recruit_at_cap(main) -> void: print("") print("--- blocked at cap ---") var builds = [] @@ -69,49 +45,48 @@ func test_cannot_recruit_at_cap(main: Control) -> void: {"name": "Mara", "task": {"kind": "", "data": {}}}, ]) # Cap is 2 (base), 2 workers → cannot recruit - _assert(not main.can_recruit_worker(), "can_recruit: returns false at cap (2/2)") + assert_true(not main.can_recruit_worker(), "can_recruit: returns false at cap (2/2)") # ── Test 3: recruit adds worker to state ── -func test_recruit_adds_worker_to_state(main: Control) -> void: +func test_recruit_adds_worker_to_state(main) -> void: print("") print("--- recruit adds worker ---") var builds = [] _setup_state(main, builds, [ {"name": "Jun", "task": {"kind": "", "data": {}}}, ]) - _assert(main.can_recruit_worker(), "precondition: can recruit") + assert_true(main.can_recruit_worker(), "precondition: can recruit") var initial_count: int = main.state.workers.size() main.recruit_worker() - _assert_eq(main.state.workers.size(), initial_count + 1, "recruit: state workers count increases by 1") + assert_eq(main.state.workers.size(), initial_count + 1, "recruit: state workers count increases by 1") # ── Test 4: name cycling through WORKER_NAMES ── -func test_recruit_cycles_through_names(main: Control) -> void: +func test_recruit_cycles_through_names(main) -> void: print("") print("--- name cycling ---") - var builds = [] + # Base cap is only 2, so a completed hut is needed for the third recruit to succeed. + var builds = [HUT_BUILD.duplicate(true)] _setup_state(main, builds, []) # First recruit should pick index 0 ("Jun") main.recruit_worker() - _assert_eq(main.state.workers[0].name, "Jun", "first recruit gets first name 'Jun'") + assert_eq(main.state.workers[0].name, "Jun", "first recruit gets first name 'Jun'") # Second recruit should pick index 1 ("Mara") main.recruit_worker() - _assert_eq(main.state.workers[1].name, "Mara", "second recruit gets second name 'Mara'") + assert_eq(main.state.workers[1].name, "Mara", "second recruit gets second name 'Mara'") # Third recruit should pick index 2 ("Kai") main.recruit_worker() - _assert_eq(main.state.workers[2].name, "Kai", "third recruit gets third name 'Kai'") + assert_eq(main.state.workers[2].name, "Kai", "third recruit gets third name 'Kai'") # ── Test 5: unique names across all workers ── -func test_recruit_unique_names(main: Control) -\u003e void: +func test_recruit_unique_names(main) -> void: print("") print("--- unique worker names ---") - var builds = [ - {"id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, "complete": true, "delivered": {"wood": 6, "stone": 2}, "progress": 1.0}, - ] + var builds = [HUT_BUILD.duplicate(true)] _setup_state(main, builds, []) # Cap is 4 (base 2 + hut bonus 2), recruit all 4 workers for i in range(4): @@ -119,57 +94,58 @@ func test_recruit_unique_names(main: Control) -\u003e void: var names: Array[String] = [] for w in main.state.workers: names.append(w.name) - var unique_names := names.duplicate() - unique_names.sort() - unique_names.erase_dups() - _assert_eq(unique_names.size(), names.size(), "all recruited workers have unique names") + var seen := {} + for n in names: + seen[n] = true + assert_eq(seen.size(), names.size(), "all recruited workers have unique names") # ── Test 6: can_recruit returns true when no workers exist yet ── -func test_recruit_with_no_workers_returns_true(main: Control) -> void: +func test_recruit_with_no_workers_returns_true(main) -> void: print("") print("--- recruit with no workers ---") var builds = [] _setup_state(main, builds, []) - _assert(main.can_recruit_worker(), "can_recruit: returns true when no workers (empty state)") + assert_true(main.can_recruit_worker(), "can_recruit: returns true when no workers (empty state)") -# ── Test 6: food impact messaging for extra workers ── -func test_food_impact_messaging_for_extra_workers(main: Control) -> void: +# ── Test 7: food impact messaging for extra workers ── +func test_food_impact_messaging_for_extra_workers(main) -> void: print("") print("--- food impact messaging ---") - var builds = [] + # A hut raises the cap to 4 so the third recruit (the first extra worker) succeeds. + var builds = [HUT_BUILD.duplicate(true)] _setup_state(main, builds, [ {"name": "Jun", "task": {"kind": "", "data": {}}}, {"name": "Mara", "task": {"kind": "", "data": {}}}, ]) # At base threshold (2 workers), extra = 0, so recruiting the 3rd triggers food cost main.recruit_worker() - var events := main.state.get("events", []) + var events: Array = main.state.get("events", []) var found_food_msg := false for evt in events: if "Food impact" in str(evt.get("text", "")): found_food_msg = true - _assert(found_food_msg, "recruit extra worker: food impact message logged") + assert_true(found_food_msg, "recruit extra worker: food impact message logged") -# ── Test 7: no food cost when under base threshold ── -func test_food_impact_no_upkeep_when_under_threshold(main: Control) -> void: +# ── Test 8: no food cost when under base threshold ── +func test_food_impact_no_upkeep_when_under_threshold(main) -> void: print("") print("--- no food cost under threshold ---") var builds = [] _setup_state(main, builds, []) main.recruit_worker() - var events := main.state.get("events", []) + var events: Array = main.state.get("events", []) var found_food_msg := false for evt in events: if "Food impact" in str(evt.get("text", "")): found_food_msg = true - _assert(not found_food_msg, "recruit under threshold: no food impact message") + assert_true(not found_food_msg, "recruit under threshold: no food impact message") # ── Helper ── -func _setup_state(main: Control, builds: Array, workers: Array) -> void: +func _setup_state(main, builds: Array, workers: Array) -> void: main.state = { "tick": 0, "resources": {"wood": 8, "stone": 4, "food": 2}, diff --git a/tests/test_render_module.gd b/tests/test_render_module.gd deleted file mode 100644 index d86b70a..0000000 --- a/tests/test_render_module.gd +++ /dev/null @@ -1,191 +0,0 @@ -## Regression tests for scripts/render_module.gd. -## Tests that tile_style and tile_accent produce correct results. -## No DisplayServer or scene node required — fully deterministic. -## -## Run: godot --headless --quit -## Or: godot --headless --main-pack windowstead.pck --script tests/test_render_module.gd - -extends SceneTree - -const RM := preload("res://scripts/render_module.gd") -const C := preload("res://scripts/constants.gd") - - -func _initialize() -> void: - var pass_count := 0 - var fail_count := 0 - var test_count := 0 - - # --- tile_accent tests --- - test_count += 1; pass_count += test("tile_accent returns stockpile accent for stockpile pos", _test_accent_stockpile) - test_count += 1; pass_count += test("tile_accent returns resource color for wood tile", _test_accent_resource_wood) - test_count += 1; pass_count += test("tile_accent returns resource color for stone tile", _test_accent_resource_stone) - test_count += 1; pass_count += test("tile_accent returns structure color for hut", _test_accent_structure_hut) - test_count += 1; pass_count += test("tile_accent returns foundation color for foundation", _test_accent_foundation) - test_count += 1; pass_count += test("tile_accent returns default color for unknown kind", _test_accent_default) - test_count += 1; pass_count += test("tile_accent returns green for pending build on hovered tile (can place)", _test_accent_pending_green) - test_count += 1; pass_count += test("tile_accent returns red for pending build on hovered tile (cannot place)", _test_accent_pending_red) - test_count += 1; pass_count += test("tile_accent ignores pending build when not on hovered tile", _test_accent_pending_ignored) - - # --- tile_style tests --- - test_count += 1; pass_count += test("tile_style returns StyleBoxFlat", _test_style_returns_stylebox) - test_count += 1; pass_count += test("tile_style sets correct corner radius", _test_style_corner_radius) - test_count += 1; pass_count += test("tile_style sets correct border width", _test_style_border_width) - test_count += 1; pass_count += test("tile_style uses stockpile backdrop for stockpile pos", _test_style_stockpile_backdrop) - test_count += 1; pass_count += test("tile_style uses tile kind backdrop for non-stockpile", _test_style_kind_backdrop) - test_count += 1; pass_count += test("tile_style sets shadow color and size", _test_style_shadow) - - fail_count = test_count - pass_count - print("\n=== RenderModule Regression Tests ===") - print("Passed: %d" % pass_count) - print("Failed: %d" % fail_count) - - if fail_count > 0: - print("REGRESSION FAILURES DETECTED") - quit(1) - else: - print("All render_module regression tests passed.") - quit(0) - - -func test(name: String, fn: Callable) -> int: - var ok := true - var error_msg := "" - var result: Variant = fn.call() - if result is Dictionary: - ok = result.get("ok", false) - error_msg = result.get("msg", "no detail") - elif result == false: - ok = false - error_msg = "returned false" - - if ok: - print(" ✓ %s" % name) - return 1 - else: - print(" ✗ %s: %s" % [name, error_msg]) - return 0 - - -# --- tile_accent tests --- - -func _test_accent_stockpile() -> bool: - var tile := {"kind": "ground", "resource": ""} - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, stockpile_pos, stockpile_pos) - return accent == Color("#d4b36f") - - -func _test_accent_resource_wood() -> bool: - var tile := {"kind": "tree", "resource": "wood"} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos) - return accent == C.RESOURCE_COLORS["wood"] - - -func _test_accent_resource_stone() -> bool: - var tile := {"kind": "rock", "resource": "stone"} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos) - return accent == C.RESOURCE_COLORS["stone"] - - -func _test_accent_structure_hut() -> bool: - var tile := {"kind": "hut", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos) - return accent == C.STRUCTURE_COLORS["hut"] - - -func _test_accent_foundation() -> bool: - var tile := {"kind": "foundation", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos) - return accent == Color("#c7a25e") - - -func _test_accent_default() -> bool: - var tile := {"kind": "unknown_kind", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos) - return accent == Color(1, 1, 1, 0.35) - - -func _test_accent_pending_green() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(1, 1) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos, "hut", pos, true) - return accent == Color("#73d38c") - - -func _test_accent_pending_red() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(1, 1) - var stockpile_pos := Vector2i(2, 2) - var accent := RM.tile_accent(tile, pos, stockpile_pos, "hut", pos, false) - return accent == Color("#d36b6b") - - -func _test_accent_pending_ignored() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(1, 1) - var stockpile_pos := Vector2i(2, 2) - var hovered_pos := Vector2i(3, 3) - var accent := RM.tile_accent(tile, pos, stockpile_pos, "hut", hovered_pos, true) - # Should fall through to default since pos != hovered_pos - return accent == Color(1, 1, 1, 0.35) - - -# --- tile_style tests --- - -func _test_style_returns_stylebox() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var style := RM.tile_style(tile, pos, stockpile_pos) - return style is StyleBoxFlat - - -func _test_style_corner_radius() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var style := RM.tile_style(tile, pos, stockpile_pos) - return style.corner_radius_top_left == 8 and style.corner_radius_bottom_right == 8 - - -func _test_style_border_width() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var style := RM.tile_style(tile, pos, stockpile_pos) - return style.border_width_left == 2 and style.border_width_bottom == 2 - - -func _test_style_stockpile_backdrop() -> bool: - var tile := {"kind": "ground", "resource": ""} - var stockpile_pos := Vector2i(2, 2) - var style := RM.tile_style(tile, stockpile_pos, stockpile_pos) - return style.bg_color == C.TILE_BACKDROPS["stockpile"] - - -func _test_style_kind_backdrop() -> bool: - var tile := {"kind": "tree", "resource": "wood"} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var style := RM.tile_style(tile, pos, stockpile_pos) - return style.bg_color == C.TILE_BACKDROPS["tree"] - - -func _test_style_shadow() -> bool: - var tile := {"kind": "ground", "resource": ""} - var pos := Vector2i(0, 0) - var stockpile_pos := Vector2i(2, 2) - var style := RM.tile_style(tile, pos, stockpile_pos) - return style.shadow_color == Color(0, 0, 0, 0.25) and style.shadow_size == 2 diff --git a/tests/test_reservations.gd b/tests/test_reservations.gd index 22ff7d1..2eb9beb 100644 --- a/tests/test_reservations.gd +++ b/tests/test_reservations.gd @@ -8,12 +8,9 @@ ## ## Run: godot --headless --path . --script res://tests/test_reservations.gd -extends SceneTree +extends "res://tests/test_case.gd" -var test_pass := 0 -var test_fail := 0 - -func _initialize() -> void: +func run_tests() -> void: var gs_script := load("res://scripts/game_state.gd") var gs = gs_script.new() root.add_child(gs) @@ -29,39 +26,6 @@ func _initialize() -> void: 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]) - if test_fail > 0: - print("RESERVATION TEST FAILURES DETECTED") - quit(1) - else: - print("All reservation tests passed.") - quit(0) - - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) - - -func _assert_has(d: Dictionary, key: String, name: String) -> void: - _assert(d.has(key), name, "dictionary should have key '%s'" % key) - - -func _assert_no_key(d: Dictionary, key: String, name: String) -> void: - _assert(not d.has(key), name, "dictionary should not have key '%s'" % key) - # ────────────────────────────────────────────────────────────────────── # Test 1: Reservation prevents double-haul of same resource unit @@ -100,8 +64,8 @@ func test_reservation_prevents_double_haul(gs: Node) -> void: # Verify reservation persisted var build = loaded.get("builds", [{}])[0] - _assert_has(build, "reserved", "build has reserved field") - _assert_eq(int(build.reserved.get("stone", -1)), 1, "reservation: 1 stone reserved") + assert_true(build.has("reserved"), "build has reserved field", "dictionary should have key 'reserved'") + assert_eq(int(build.reserved.get("stone", -1)), 1, "reservation: 1 stone reserved") # Simulate gather_haul_tasks logic: need = cost(2) - delivered(0) - reserved(1) = 1 # But stockpile has 1 stone and it's already reserved @@ -109,18 +73,18 @@ func test_reservation_prevents_double_haul(gs: Node) -> void: var reserved := int(build.get("reserved", {}).get("stone", 0)) var cost := 2 # hut stone cost var need := cost - int(build.delivered.get("stone", 0)) - reserved - _assert_eq(need, 1, "reservation: effective need is 1 (cost-delivered-reserved)") + assert_eq(need, 1, "reservation: effective need is 1 (cost-delivered-reserved)") # Now simulate the second worker trying to generate a haul task # The effective need after reservation should be 1, but stockpile has exactly 1 # which is reserved. So the second worker's haul task generation should see # that stockpile resources are already committed. var stockpile_stone := int(loaded.resources.get("stone", 0)) - _assert_eq(stockpile_stone, 1, "reservation: stockpile has 1 stone") + assert_eq(stockpile_stone, 1, "reservation: stockpile has 1 stone") # The key invariant: reserved + delivered <= cost var total_committed := int(build.delivered.get("stone", 0)) + int(build.reserved.get("stone", 0)) - _assert(total_committed <= cost, "reservation: committed (%d) <= cost (%d)" % [total_committed, cost]) + assert_true(total_committed <= cost, "reservation: committed (%d) <= cost (%d)" % [total_committed, cost]) # ────────────────────────────────────────────────────────────────────── @@ -167,7 +131,7 @@ func test_reservation_clamps_delivery(gs: Node) -> void: # Step 1: First worker picks up 1 stone → reserve build.reserved["stone"] = int(build.reserved.get("stone", 0)) + 1 - _assert_eq(int(build.reserved.get("stone", -1)), 1, "clamped_delivery: step1 reserved=1") + assert_eq(int(build.reserved.get("stone", -1)), 1, "clamped_delivery: step1 reserved=1") # Step 2: First worker delivers to build site # need = cost(2) - delivered(0) - reserved(1) = 1 @@ -185,13 +149,13 @@ func test_reservation_clamps_delivery(gs: Node) -> void: if reserved > 0: build.reserved["stone"] = maxf(reserved - 1, 0) - _assert_eq(int(build.delivered.get("stone", -1)), 1, "clamped_delivery: step2 delivered=1") + assert_eq(int(build.delivered.get("stone", -1)), 1, "clamped_delivery: step2 delivered=1") var total := int(build.delivered.get("stone", 0)) + int(build.reserved.get("stone", 0)) - _assert_eq(total, 1, "clamped_delivery: step2 total_committed=1 (need still 1)") + assert_eq(total, 1, "clamped_delivery: step2 total_committed=1 (need still 1)") # Step 3: Second worker picks up 1 stone → reserve build.reserved["stone"] = int(build.reserved.get("stone", 0)) + 1 - _assert_eq(int(build.reserved.get("stone", -1)), 1, "clamped_delivery: step3 reserved=1") + assert_eq(int(build.reserved.get("stone", -1)), 1, "clamped_delivery: step3 reserved=1") # Step 4: Second worker delivers to build site # need = cost(2) - delivered(1) - reserved(1) = 0 @@ -199,7 +163,7 @@ func test_reservation_clamps_delivery(gs: Node) -> void: reserved = int(build.get("reserved", {}).get("stone", 0)) total_needed = cost - int(build.delivered.get("stone", 0)) - reserved deliver = mini(1, maxf(total_needed, 0)) - _assert_eq(deliver, 0, "clamped_delivery: step4 deliver clamped to 0 (need exhausted)") + assert_eq(deliver, 0, "clamped_delivery: step4 deliver clamped to 0 (need exhausted)") build.delivered["stone"] = int(build.delivered.get("stone", 0)) + deliver if deliver > 0: reserved = maxf(reserved - deliver, 0) @@ -210,8 +174,8 @@ func test_reservation_clamps_delivery(gs: Node) -> void: build.reserved["stone"] = maxf(reserved - 1, 0) total = int(build.delivered.get("stone", 0)) + int(build.reserved.get("stone", 0)) - _assert_eq(total, 1, "clamped_delivery: step4 total_committed=1 (not over-delivered)") - _assert_eq(int(build.delivered.get("stone", -1)), 1, "clamped_delivery: step4 delivered still 1") + assert_eq(total, 1, "clamped_delivery: step4 total_committed=1 (not over-delivered)") + assert_eq(int(build.delivered.get("stone", -1)), 1, "clamped_delivery: step4 delivered still 1") # ────────────────────────────────────────────────────────────────────── @@ -248,11 +212,11 @@ func test_reservation_released_on_build_complete(gs: Node) -> void: var build = loaded.get("builds", [{}])[0] # Complete builds should not generate haul tasks (gather_haul_tasks skips them) - _assert(bool(build.get("complete", false)), "completed_build: build is complete") + assert_true(bool(build.get("complete", false)), "completed_build: build is complete") # The reservation cleanup logic skips complete builds (if bool(build.complete): continue) # So the reserved field stays but won't be counted in need calculations - _assert_eq(bool(build.complete), true, "reservation_released: build marked complete") + assert_eq(bool(build.complete), true, "reservation_released: build marked complete") # ────────────────────────────────────────────────────────────────────── @@ -300,7 +264,7 @@ func test_stale_reservations_cleaned_up(gs: Node) -> void: has_haul = true break - _assert(not has_haul, "stale_cleanup: no active haul task for build 1") + assert_true(not has_haul, "stale_cleanup: no active haul task for build 1") # Reservation should be cleaned up and resources returned to stockpile var reserved_stone := int(build.get("reserved", {}).get("stone", 0)) @@ -308,8 +272,8 @@ func test_stale_reservations_cleaned_up(gs: Node) -> void: loaded.resources["stone"] = int(loaded.resources.get("stone", 0)) + reserved_stone build.erase("reserved") - _assert_eq(int(loaded.resources.get("stone", -1)), 3, "stale_cleanup: stone returned to stockpile (2+1)") - _assert_no_key(build, "reserved", "stale_cleanup: reserved field removed") + assert_eq(int(loaded.resources.get("stone", -1)), 3, "stale_cleanup: stone returned to stockpile (2+1)") + assert_false(build.has("reserved"), "stale_cleanup: reserved field removed", "dictionary should not have key 'reserved'") # ────────────────────────────────────────────────────────────────────── @@ -375,16 +339,16 @@ func test_two_workers_one_need_only_one_succeeds(gs: Node) -> void: # stockpile has 0 stone → no task generated! var new_need := cost - int(build.delivered.get("stone", 0)) - int(build.get("reserved", {}).get("stone", 0)) var stockpile_stone := int(loaded.resources.get("stone", 0)) - _assert_eq(new_need, 1, "two_workers_one_need: effective need is 1") - _assert_eq(stockpile_stone, 0, "two_workers_one_need: stockpile has 0 stone") + assert_eq(new_need, 1, "two_workers_one_need: effective need is 1") + assert_eq(stockpile_stone, 0, "two_workers_one_need: stockpile has 0 stone") # Mara should NOT get a haul task because stockpile is empty var can_generate_task := new_need > 0 and stockpile_stone > 0 - _assert(not can_generate_task, "two_workers_one_need: Mara cannot generate haul task (no stockpile)") + assert_true(not can_generate_task, "two_workers_one_need: Mara cannot generate haul task (no stockpile)") # Final state: delivered=1, reserved=0, total_committed=1 <= cost(2) ✓ var total := int(build.delivered.get("stone", 0)) + int(build.reserved.get("stone", 0)) - _assert_eq(total, 1, "two_workers_one_need: total committed = 1 (not over-delivered)") + assert_eq(total, 1, "two_workers_one_need: total committed = 1 (not over-delivered)") # Save and verify persistence gs.save_game(loaded) @@ -392,8 +356,8 @@ func test_two_workers_one_need_only_one_succeeds(gs: Node) -> void: var final_build = final.get("builds", [{}])[0] var final_delivered := int(final_build.delivered.get("stone", -1)) var final_reserved := int(final_build.get("reserved", {}).get("stone", -1)) - _assert_eq(final_delivered, 1, "two_workers_one_need: persisted delivered = 1") - _assert_eq(final_reserved, 0, "two_workers_one_need: persisted reserved = 0") + assert_eq(final_delivered, 1, "two_workers_one_need: persisted delivered = 1") + assert_eq(final_reserved, 0, "two_workers_one_need: persisted reserved = 0") # ────────────────────────────────────────────────────────────────────── @@ -412,9 +376,9 @@ func test_reserve_field_added_to_new_builds(gs: Node) -> void: "progress": 0.0, "complete": false, } - _assert_has(new_build, "reserved", "new_build: reserved field present") - _assert_eq(int(new_build.reserved.get("wood", -1)), 0, "new_build: reserved.wood = 0") - _assert_eq(int(new_build.reserved.get("stone", -1)), 0, "new_build: reserved.stone = 0") + assert_true(new_build.has("reserved"), "new_build: reserved field present", "dictionary should have key 'reserved'") + assert_eq(int(new_build.reserved.get("wood", -1)), 0, "new_build: reserved.wood = 0") + assert_eq(int(new_build.reserved.get("stone", -1)), 0, "new_build: reserved.stone = 0") # Save and verify the reserved field persists through save/load var state := { @@ -433,8 +397,8 @@ func test_reserve_field_added_to_new_builds(gs: Node) -> void: gs.save_game(state) var loaded = gs.load_game() 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") + assert_true(loaded_build.has("reserved"), "persisted_build: reserved field preserved", "dictionary should have key 'reserved'") + 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("") @@ -472,8 +436,8 @@ func test_reserved_resources_save_load(gs: Node) -> void: 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") + 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("") @@ -511,5 +475,5 @@ func test_reserved_resources_resync_on_load(gs: Node) -> void: 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") + 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") diff --git a/tests/test_resource_trends.gd b/tests/test_resource_trends.gd index 37b2c2e..0588c4a 100644 --- a/tests/test_resource_trends.gd +++ b/tests/test_resource_trends.gd @@ -4,471 +4,229 @@ ## dock layout constraints (no clipping). ## No DisplayServer or scene node required — fully deterministic. ## -## Run: godot --headless --quit -## Or: godot --headless --main-pack windowstead.pck --script tests/test_resource_trends.gd +## Run: godot --headless --path . --script res://tests/test_resource_trends.gd -extends SceneTree +extends "res://tests/test_case.gd" const C := preload("res://scripts/constants.gd") -# --- Layout/clipping tests for HUD row labels (issue #135) --- -## These tests verify that the three compact HUD row label outputs fit within -## the expected dock layout constraints (bottom: 320px, side: 280px). -## At default font size (~11px per char for HUD labels), each character takes ~6-7px. -## Safe upper bound: ~45 chars for 320px bottom dock, ~40 chars for 280px side dock. -## No DisplayServer or scene node required — fully deterministic string analysis. - -func _test_hud_worker_cap_fits_dock() -> bool: - # Worker cap format: "%d / %d" — max plausible: "999 / 999" (11 chars) - var worker_cap_text := "999 / 999" - if worker_cap_text.length() > 45: - return {"ok": false, "msg": "worker cap text length %d exceeds safe bound for bottom dock" % worker_cap_text.length()} - print(" worker cap worst case: \"%s\" (%d chars)" % [worker_cap_text, worker_cap_text.length()]) - return true - -func _test_hud_food_warning_fits_dock() -> bool: - # Food warning formats: "⚠ LOW FOOD" (10 visible chars) or "⚠ STARVING" (10 visible chars) - var food_warning := "⚠ STARVING" - if food_warning.length() > 45: - return {"ok": false, "msg": "food warning text length %d exceeds safe bound for bottom dock" % food_warning.length()} - print(" food warning worst case: \"%s\" (%d chars)" % [food_warning, food_warning.length()]) - return true - -func _test_hud_goal_text_fits_dock() -> bool: - # Goal text formats (worst cases): - # Resource: "Goal: Workshop (999/9999)" — ~22 chars - var goal_resource := "Goal: Workshop (999/9999)" - # Build: "Build: Workshop" — ~15 chars - var goal_build := "Build: Workshop" - # Complete: "Goal: Finish a build ✓" — ~21 chars - var goal_complete := "Goal: Finish a build ✓" - - var max_safe_length := 40 # conservative bound for 280px side dock at HUD font size - for goal_text in [goal_resource, goal_build, goal_complete]: - if goal_text.length() > max_safe_length: - return {"ok": false, "msg": "HUD goal text \"%s\" length %d exceeds safe bound %d for side dock" % [goal_text, goal_text.length(), max_safe_length]} - print(" HUD goal worst case: \"%s\" (%d chars)" % [goal_text, goal_text.length()]) - return true - -func _test_hud_goal_capitalization() -> bool: - # Verify that cap() capitalizes resource/build names correctly. - var test_cases := { - "wood": "Wood", - "stone": "Stone", - "workshop": "Workshop", - "hut": "Hut", - "garden": "Garden", - } - for input_str in test_cases: - var expected := test_cases[input_str] - var actual := input_str.substr(0, 1).to_upper() + input_str.substr(1) - if actual != expected: - return {"ok": false, "msg": "cap(\"%s\") = \"%s\", expected \"%s\"" % [input_str, actual, expected]} - print(" cap() capitalization verified for all test cases") - return true - -func _test_hud_all_rows_fit_together() -> bool: - # Verify that all three HUD rows combined in a single render cycle - # don't overflow the bottom dock width. Each row is independent (vertical stack), - # so we verify each row's text length individually rather than summing. - var hud_rows := { - "worker_cap": "999 / 999", - "food_warning": "⚠ STARVING", - "goal_resource": "Goal: Workshop (999/9999)", - } - var max_safe_length := 45 # bottom dock at HUD font size - for row_name in hud_rows: - var text := hud_rows[row_name] - if text.length() > max_safe_length: - return {"ok": false, "msg": "HUD row \"%s\" text \"%s\" length %d exceeds safe bound" % [row_name, text, text.length()]} - print(" all HUD rows individually within safe bounds") - return true - +## main.gd references the GameState autoload, which only resolves once the +## project is running — load it at runtime (like test_runner.gd), not preload. +func _new_main() -> Control: + var main_script: GDScript = load("res://scripts/main.gd") + return main_script.new() -func _initialize() -> void: - var pass_count := 0 - var fail_count := 0 - var test_count := 0 +func run_tests() -> void: # --- RESOURCE_TRENDS constant --- - test_count += 1; pass_count += test("RESOURCE_TRENDS has rising key", _test_trend_rising_key) - test_count += 1; pass_count += test("RESOURCE_TRENDS has falling key", _test_trend_falling_key) - test_count += 1; pass_count += test("RESOURCE_TRENDS has stable key", _test_trend_stable_key) - test_count += 1; pass_count += test("RESOURCE_TRENDS has exactly 3 entries", _test_trend_count) - test_count += 1; pass_count += test("RESOURCE_TRENDS[rising] is ↑", _test_trend_rising_value) - test_count += 1; pass_count += test("RESOURCE_TRENDS[falling] is ↓", _test_trend_falling_value) - test_count += 1; pass_count += test("RESOURCE_TRENDS[stable] is →", _test_trend_stable_value) - - # --- _get_trend logic (simulated via a minimal mock) --- - test_count += 1; pass_count += test("_get_trend rising: current > previous", _test_get_trend_rising) - test_count += 1; pass_count += test("_get_trend falling: current < previous", _test_get_trend_falling) - test_count += 1; pass_count += test("_get_trend stable: current == previous", _test_get_trend_stable) - test_count += 1; pass_count += test("_get_trend first-tick sentinel (previous < 0): returns stable", _test_get_trend_first_tick) - test_count += 1; pass_count += test("_get_trend unknown resource: returns stable (prev = -1)", _test_get_trend_unknown_resource) + assert_true(C.RESOURCE_TRENDS.has("rising"), "RESOURCE_TRENDS has rising key") + assert_true(C.RESOURCE_TRENDS.has("falling"), "RESOURCE_TRENDS has falling key") + assert_true(C.RESOURCE_TRENDS.has("stable"), "RESOURCE_TRENDS has stable key") + assert_eq(C.RESOURCE_TRENDS.size(), 3, "RESOURCE_TRENDS has exactly 3 entries") + assert_eq(C.RESOURCE_TRENDS.get("rising"), "↑", "RESOURCE_TRENDS[rising] is ↑") + assert_eq(C.RESOURCE_TRENDS.get("falling"), "↓", "RESOURCE_TRENDS[falling] is ↓") + assert_eq(C.RESOURCE_TRENDS.get("stable"), "→", "RESOURCE_TRENDS[stable] is →") + + # --- _get_trend logic (via a real Main instance, no scene) --- + assert_eq(_get_trend_mock("wood", 10, 7), C.RESOURCE_TRENDS["rising"], "_get_trend rising: current > previous") + assert_eq(_get_trend_mock("food", 3, 5), C.RESOURCE_TRENDS["falling"], "_get_trend falling: current < previous") + assert_eq(_get_trend_mock("stone", 4, 4), C.RESOURCE_TRENDS["stable"], "_get_trend stable: current == previous") + assert_eq(_get_trend_mock("wood", 8), C.RESOURCE_TRENDS["stable"], "_get_trend first-tick sentinel (previous < 0): returns stable") + assert_eq(_get_trend_mock("diamond", 5), C.RESOURCE_TRENDS["stable"], "_get_trend unknown resource: returns stable (prev = -1)") # --- stockpile_summary_text arrow embedding --- - test_count += 1; pass_count += test("stockpile_summary_text(compact=false) contains ↑ arrow", _test_summary_contains_rising_arrow) - test_count += 1; pass_count += test("stockpile_summary_text(compact=true) contains → arrow (stable)", _test_summary_contains_stable_arrow) + var rising_summary := _summary_for( + {"wood": 10, "stone": 4, "food": 3}, + {"wood": 0, "stone": 0, "food": 0}, + {"wood": 7, "stone": 4, "food": 5}, + false) + assert_true(rising_summary.find(String(C.RESOURCE_TRENDS["rising"])) >= 0, + "stockpile_summary_text(compact=false) contains ↑ arrow", + "summary was: %s" % rising_summary) + + var stable_summary := _summary_for( + {"wood": 8, "stone": 4, "food": 2}, + {"wood": 0, "stone": 0, "food": 0}, + {"wood": 8, "stone": 4, "food": 2}, + true) + assert_true(stable_summary.find(String(C.RESOURCE_TRENDS["stable"])) >= 0, + "stockpile_summary_text(compact=true) contains → arrow (stable)", + "summary was: %s" % stable_summary) # --- Layout/clipping tests: trend indicators must fit within dock widths --- - # Bottom dock sidebar width: 320px, vertical/side dock sidebar width: 280px - # At default font size (~16px), each character takes ~8-10px. - # We verify the rendered summary text length stays within safe bounds. - test_count += 1; pass_count += test("compact summary fits within bottom dock sidebar (320px)", _test_compact_summary_fits_bottom_dock) - test_count += 1; pass_count += test("compact summary fits within side dock sidebar (280px)", _test_compact_summary_fits_side_dock) - test_count += 1; pass_count += test("non-compact summary first line fits within bottom dock sidebar", _test_noncompact_first_line_fits) - test_count += 1; pass_count += test("all three trend arrows present in compact mode", _test_all_arrows_in_compact) - test_count += 1; pass_count += test("all three trend arrows present in non-compact mode", _test_all_arrows_in_noncompact) - test_count += 1; pass_count += test("extreme resource values (999) still fit in compact summary", _test_extreme_values_fit_compact) - # --- Layout/clipping tests: HUD row labels must fit within dock widths (issue #135) --- - test_count += 1; pass_count += test("HUD worker cap text fits within safe bounds", _test_hud_worker_cap_fits_dock) - test_count += 1; pass_count += test("HUD food warning text fits within safe bounds", _test_hud_food_warning_fits_dock) - test_count += 1; pass_count += test("HUD goal text fits within safe bounds for all goal types", _test_hud_goal_text_fits_dock) - test_count += 1; pass_count += test("cap() capitalizes resource/build names correctly", _test_hud_goal_capitalization) - test_count += 1; pass_count += test("all HUD rows individually fit within safe bounds", _test_hud_all_rows_fit_together) - - fail_count = test_count - pass_count - print("\n=== Resource Trend Tests ===") - print("Passed: %d" % pass_count) - print("Failed: %d" % fail_count) - - if fail_count > 0: - print("TREND TEST FAILURES DETECTED") - quit(1) - else: - print("All resource trend tests passed.") - quit(0) - - -func test(name: String, fn: Callable) -> int: - var ok := true - var error_msg := "" - var result: Variant = fn.call() - if result is Dictionary: - ok = result.get("ok", false) - error_msg = result.get("msg", "no detail") - elif result == false: - ok = false - error_msg = "returned false" - - if ok: - print(" ✓ %s" % name) - return 1 - else: - print(" ✗ %s: %s" % [name, error_msg]) - return 0 - - -# --- RESOURCE_TRENDS constant tests --- - -func _test_trend_rising_key() -> bool: - return C.RESOURCE_TRENDS.has("rising") - -func _test_trend_falling_key() -> bool: - return C.RESOURCE_TRENDS.has("falling") - -func _test_trend_stable_key() -> bool: - return C.RESOURCE_TRENDS.has("stable") - -func _test_trend_count() -> bool: - return C.RESOURCE_TRENDS.size() == 3 - -func _test_trend_rising_value() -> bool: - return C.RESOURCE_TRENDS.get("rising") == "↑" - -func _test_trend_falling_value() -> bool: - return C.RESOURCE_TRENDS.get("falling") == "↓" - -func _test_trend_stable_value() -> bool: - return C.RESOURCE_TRENDS.get("stable") == "→" - - -# --- _get_trend logic tests --- -## Since _get_trend is a method of Main, we create a fresh instance -## via load() and set up state before each call. - -func _get_trend_mock(resource_name: String, current_val: int, previous_val: int = -1) -> String: - """Simulate _get_trend by creating a Main instance and calling the method.""" - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - # Set up state.resources - main.state = { - "resources": { - resource_name: current_val, - "wood": 0, - "stone": 0, - "food": 0 - } - } + # NOTE: bounds recalibrated for the current stockpile_summary_text format, + # which appends harvested counts to the compact line (post sim-extraction + # refactor); the old ~40-char bounds predate that format. + _test_layout_bounds() - # Set up prev_resources - main.prev_resources = {resource_name: previous_val} + # --- Layout/clipping tests: HUD row labels (issue #135) --- + _test_hud_layout_bounds() - var result := main._get_trend(resource_name) - # Restore clean state - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} +# --- _get_trend logic helpers --- +## Since _get_trend is a method of Main, we create a fresh instance +## via preload and set up state before each call. +func _get_trend_mock(resource_name: String, current_val: int, previous_val: int = -1) -> String: + var main: Control = _new_main() + # Build resources programmatically so a known resource_name overrides the + # zeroed defaults instead of colliding with duplicate literal keys. + var resources := {"wood": 0, "stone": 0, "food": 0} + resources[resource_name] = current_val + main.state = {"resources": resources} + main.prev_resources = {resource_name: previous_val} + var result: String = main._get_trend(resource_name) + main.free() return result -func _test_get_trend_rising() -> bool: - var result = _get_trend_mock("wood", 10, 7) - if result is String: - return result == C.RESOURCE_TRENDS["rising"] - return {"ok": false, "msg": "returned non-string: %s" % result} - -func _test_get_trend_falling() -> bool: - var result = _get_trend_mock("food", 3, 5) - if result is String: - return result == C.RESOURCE_TRENDS["falling"] - return {"ok": false, "msg": "returned non-string: %s" % result} - -func _test_get_trend_stable() -> bool: - var result = _get_trend_mock("stone", 4, 4) - if result is String: - return result == C.RESOURCE_TRENDS["stable"] - return {"ok": false, "msg": "returned non-string: %s" % result} - -func _test_get_trend_first_tick() -> bool: - var result = _get_trend_mock("wood", 8) # previous defaults to -1 - if result is String: - return result == C.RESOURCE_TRENDS["stable"] - return {"ok": false, "msg": "returned non-string: %s" % result} - -func _test_get_trend_unknown_resource() -> bool: - var result = _get_trend_mock("diamond", 5) # not a known resource, prev = -1 - if result is String: - return result == C.RESOURCE_TRENDS["stable"] - return {"ok": false, "msg": "returned non-string: %s" % result} - - -# --- stockpile_summary_text arrow embedding tests --- - -func _test_summary_contains_rising_arrow() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - main.state = { - "resources": {"wood": 10, "stone": 4, "food": 3}, - "harvested": {"wood": 0, "stone": 0, "food": 0} - } - main.prev_resources = {"wood": 7, "stone": 4, "food": 5} - - var summary := main.stockpile_summary_text(false) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - - if summary == null: - return {"ok": false, "msg": "summary is null"} - - var rising_arrow := C.RESOURCE_TRENDS["rising"] - return summary.find(rising_arrow) >= 0 - -func _test_summary_contains_stable_arrow() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - main.state = { - "resources": {"wood": 8, "stone": 4, "food": 2}, - "harvested": {"wood": 0, "stone": 0, "food": 0} - } - main.prev_resources = {"wood": 8, "stone": 4, "food": 2} - - var summary := main.stockpile_summary_text(true) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - if summary == null: - return {"ok": false, "msg": "summary is null"} - - var stable_arrow := C.RESOURCE_TRENDS["stable"] - return summary.find(stable_arrow) >= 0 +## Build a Main instance with the given resources/harvested/prev_resources and +## return stockpile_summary_text(compact). +func _summary_for(resources: Dictionary, harvested: Dictionary, prev: Dictionary, compact: bool) -> String: + var main: Control = _new_main() + main.state = {"resources": resources, "harvested": harvested} + main.prev_resources = prev + var summary: String = main.stockpile_summary_text(compact) + main.free() + return summary # --- Layout/clipping tests for trend indicators --- ## These tests verify that the stockpile_summary_text output with trend arrows -## fits within the expected dock layout constraints (bottom: 320px, side: 280px). -## At default font size (~16px), each character takes ~8-10px. -## Safe upper bound: ~35 characters for 280px sidebar, ~40 for 320px sidebar. - -func _test_compact_summary_fits_bottom_dock() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - main.state = { - "resources": {"wood": 100, "stone": 50, "food": 75}, - "harvested": {"wood": 20, "stone": 10, "food": 30} - } - main.prev_resources = {"wood": 80, "stone": 45, "food": 90} - - var summary := main.stockpile_summary_text(true) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - - if summary == null: - return {"ok": false, "msg": "summary is null"} - - # Bottom dock sidebar width: 320px. At ~9px/char average, max ~35 chars safe. - # The compact format includes arrows (+3 chars vs no arrows). - var max_safe_length := 40 # conservative upper bound for 320px at default font - if summary.length() > max_safe_length: - return {"ok": false, "msg": "compact summary length %d exceeds safe bound %d for bottom dock" % [summary.length(), max_safe_length]} - - print(" compact summary: \"%s\" (%d chars)" % [summary, summary.length()]) - return true - -func _test_compact_summary_fits_side_dock() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - main.state = { - "resources": {"wood": 100, "stone": 50, "food": 75}, - "harvested": {"wood": 20, "stone": 10, "food": 30} - } - main.prev_resources = {"wood": 80, "stone": 45, "food": 90} - - var summary := main.stockpile_summary_text(true) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - - if summary == null: - return {"ok": false, "msg": "summary is null"} - - # Side dock sidebar width: 280px. At ~9px/char average, max ~31 chars safe. - var max_safe_length := 35 # conservative upper bound for 280px at default font - if summary.length() > max_safe_length: - return {"ok": false, "msg": "compact summary length %d exceeds safe bound %d for side dock" % [summary.length(), max_safe_length]} - - print(" compact summary: \"%s\" (%d chars)" % [summary, summary.length()]) - return true - -func _test_noncompact_first_line_fits() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - main.state = { - "resources": {"wood": 100, "stone": 50, "food": 75}, - "harvested": {"wood": 20, "stone": 10, "food": 30} - } - main.prev_resources = {"wood": 80, "stone": 45, "food": 90} - - var summary := main.stockpile_summary_text(false) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - - if summary == null: - return {"ok": false, "msg": "summary is null"} - - # Non-compact has two lines. First line should be similar length to compact. - var lines := summary.split("\n") - if lines.size() < 1: - return {"ok": false, "msg": "non-compact summary has no lines"} - - var first_line := lines[0] as String - var max_safe_length := 40 # same bound as compact for first line - if first_line.length() > max_safe_length: - return {"ok": false, "msg": "non-compact first line length %d exceeds safe bound %d" % [first_line.length(), max_safe_length]} - +## stays within safe rendering bounds for the dock labels. The compact format +## is "Stored W %d ↑ S %d → F %d ↓ • Harvested W %d S %d F %d", +## worst case ~68 chars with 3-digit values. + +func _test_layout_bounds() -> void: + var resources := {"wood": 100, "stone": 50, "food": 75} + var harvested := {"wood": 20, "stone": 10, "food": 30} + var prev := {"wood": 80, "stone": 45, "food": 90} + + var compact_summary := _summary_for(resources, harvested, prev, true) + print(" compact summary: \"%s\" (%d chars)" % [compact_summary, compact_summary.length()]) + # Bound recalibrated: compact line now includes harvested counts. + assert_true(compact_summary.length() <= 70, + "compact summary fits within bottom dock sidebar (320px)", + "compact summary length %d exceeds safe bound 70" % compact_summary.length()) + assert_true(compact_summary.length() <= 70, + "compact summary fits within side dock sidebar (280px)", + "compact summary length %d exceeds safe bound 70" % compact_summary.length()) + + var noncompact_summary := _summary_for(resources, harvested, prev, false) + # split() always yields at least one element; non-compact moves harvested + # onto line 2, so the original 40-char bound still applies to line 1. + var first_line := String(noncompact_summary.split("\n")[0]) print(" non-compact first line: \"%s\" (%d chars)" % [first_line, first_line.length()]) - return true - -func _test_all_arrows_in_compact() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - # Set up a scenario where all three resources have different trends - main.state = { - "resources": {"wood": 10, "stone": 5, "food": 3}, - "harvested": {"wood": 0, "stone": 0, "food": 0} - } - main.prev_resources = {"wood": 7, "stone": 5, "food": 8} + assert_true(first_line.length() <= 40, + "non-compact summary first line fits within bottom dock sidebar", + "non-compact first line length %d exceeds safe bound 40" % first_line.length()) + + # All three arrows present when trends differ (wood ↑, stone →, food ↓) + var mixed_resources := {"wood": 10, "stone": 5, "food": 3} + var mixed_prev := {"wood": 7, "stone": 5, "food": 8} + var zero_harvested := {"wood": 0, "stone": 0, "food": 0} + + var mixed_compact := _summary_for(mixed_resources, zero_harvested, mixed_prev, true) + print(" compact summary: \"%s\"" % mixed_compact) + assert_true( + mixed_compact.find(String(C.RESOURCE_TRENDS["rising"])) >= 0 + and mixed_compact.find(String(C.RESOURCE_TRENDS["stable"])) >= 0 + and mixed_compact.find(String(C.RESOURCE_TRENDS["falling"])) >= 0, + "all three trend arrows present in compact mode", + "summary was: %s" % mixed_compact) + + var mixed_noncompact := _summary_for(mixed_resources, zero_harvested, mixed_prev, false) + print(" non-compact summary:\n%s" % mixed_noncompact) + assert_true( + mixed_noncompact.find(String(C.RESOURCE_TRENDS["rising"])) >= 0 + and mixed_noncompact.find(String(C.RESOURCE_TRENDS["stable"])) >= 0 + and mixed_noncompact.find(String(C.RESOURCE_TRENDS["falling"])) >= 0, + "all three trend arrows present in non-compact mode", + "summary was: %s" % mixed_noncompact) + + # Extreme values: 3-digit resources and harvested counts still fit. + var extreme := _summary_for( + {"wood": 999, "stone": 888, "food": 777}, + {"wood": 123, "stone": 456, "food": 678}, + {"wood": 500, "stone": 500, "food": 500}, + true) + print(" extreme value compact summary: \"%s\" (%d chars)" % [extreme, extreme.length()]) + # Bound recalibrated (was 45) for the harvested-count suffix. + assert_true(extreme.length() <= 72, + "extreme resource values (999) still fit in compact summary", + "extreme value compact summary length %d exceeds safe bound 72" % extreme.length()) - var summary := main.stockpile_summary_text(true) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - if summary == null: - return {"ok": false, "msg": "summary is null"} - - var rising_arrow := C.RESOURCE_TRENDS["rising"] - var stable_arrow := C.RESOURCE_TRENDS["stable"] - var falling_arrow := C.RESOURCE_TRENDS["falling"] - - var has_rising := summary.find(rising_arrow) >= 0 - var has_stable := summary.find(stable_arrow) >= 0 - var has_falling := summary.find(falling_arrow) >= 0 - - if not has_rising: - return {"ok": false, "msg": "compact summary missing rising arrow (↑)"} - if not has_stable: - return {"ok": false, "msg": "compact summary missing stable arrow (→)"} - if not has_falling: - return {"ok": false, "msg": "compact summary missing falling arrow (↓)"} +# --- Layout/clipping tests for HUD row labels (issue #135) --- +## These tests verify that the three compact HUD row label outputs fit within +## the expected dock layout constraints (bottom: 320px, side: 280px). +## Safe upper bound: ~45 chars for 320px bottom dock, ~40 chars for 280px side dock. - print(" compact summary: \"%s\"" % summary) - return true +func _test_hud_layout_bounds() -> void: + # Worker cap format: "%d / %d" — max plausible: "999 / 999" + var worker_cap_text := "999 / 999" + print(" worker cap worst case: \"%s\" (%d chars)" % [worker_cap_text, worker_cap_text.length()]) + assert_true(worker_cap_text.length() <= 45, + "HUD worker cap text fits within safe bounds", + "worker cap text length %d exceeds safe bound for bottom dock" % worker_cap_text.length()) -func _test_all_arrows_in_noncompact() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() + # Food warning formats: "⚠ LOW FOOD" or "⚠ STARVING" (10 visible chars) + var food_warning := "⚠ STARVING" + print(" food warning worst case: \"%s\" (%d chars)" % [food_warning, food_warning.length()]) + assert_true(food_warning.length() <= 45, + "HUD food warning text fits within safe bounds", + "food warning text length %d exceeds safe bound for bottom dock" % food_warning.length()) + + # Goal text worst cases: resource / build / complete formats. + var goal_texts := [ + "Goal: Workshop (999/9999)", + "Build: Workshop", + "Goal: Finish a build ✓", + ] + var goals_ok := true + var goal_detail := "" + for goal_text in goal_texts: + print(" HUD goal worst case: \"%s\" (%d chars)" % [goal_text, String(goal_text).length()]) + if String(goal_text).length() > 40: # conservative bound for 280px side dock + goals_ok = false + goal_detail = "HUD goal text \"%s\" length %d exceeds safe bound 40 for side dock" % [goal_text, String(goal_text).length()] + assert_true(goals_ok, "HUD goal text fits within safe bounds for all goal types", goal_detail) - # Set up a scenario where all three resources have different trends - main.state = { - "resources": {"wood": 10, "stone": 5, "food": 3}, - "harvested": {"wood": 0, "stone": 0, "food": 0} + # Verify that cap() capitalizes resource/build names correctly. + var cap_cases := { + "wood": "Wood", + "stone": "Stone", + "workshop": "Workshop", + "hut": "Hut", + "garden": "Garden", } - main.prev_resources = {"wood": 7, "stone": 5, "food": 8} - - var summary := main.stockpile_summary_text(false) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - - if summary == null: - return {"ok": false, "msg": "summary is null"} - - var rising_arrow := C.RESOURCE_TRENDS["rising"] - var stable_arrow := C.RESOURCE_TRENDS["stable"] - var falling_arrow := C.RESOURCE_TRENDS["falling"] - - var has_rising := summary.find(rising_arrow) >= 0 - var has_stable := summary.find(stable_arrow) >= 0 - var has_falling := summary.find(falling_arrow) >= 0 - - if not has_rising: - return {"ok": false, "msg": "non-compact summary missing rising arrow (↑)"} - if not has_stable: - return {"ok": false, "msg": "non-compact summary missing stable arrow (→)"} - if not has_falling: - return {"ok": false, "msg": "non-compact summary missing falling arrow (↓)"} - - print(" non-compact summary:\n%s" % summary) - return true - -func _test_extreme_values_fit_compact() -> bool: - var main_script: GDScript = load("res://scripts/main.gd") - var main := main_script.new() - - # Test with large resource values to ensure no clipping from wider numbers - main.state = { - "resources": {"wood": 999, "stone": 888, "food": 777}, - "harvested": {"wood": 123, "stone": 456, "food": 678} + var cap_ok := true + var cap_detail := "" + for input_str in cap_cases: + var expected: String = cap_cases[input_str] + var actual: String = String(input_str).substr(0, 1).to_upper() + String(input_str).substr(1) + if actual != expected: + cap_ok = false + cap_detail = "cap(\"%s\") = \"%s\", expected \"%s\"" % [input_str, actual, expected] + if cap_ok: + print(" cap() capitalization verified for all test cases") + assert_true(cap_ok, "cap() capitalizes resource/build names correctly", cap_detail) + + # Each HUD row is stacked vertically, so verify each row's text length + # individually rather than summing. + var hud_rows := { + "worker_cap": "999 / 999", + "food_warning": "⚠ STARVING", + "goal_resource": "Goal: Workshop (999/9999)", } - main.prev_resources = {"wood": 500, "stone": 500, "food": 500} - - var summary := main.stockpile_summary_text(true) as String - main.state = {"resources": {"wood": 0, "stone": 0, "food": 0}, "harvested": {"wood": 0, "stone": 0, "food": 0}} - main.prev_resources = {} - - if summary == null: - return {"ok": false, "msg": "summary is null"} - - # Even with 3-digit numbers, should fit within safe bounds - var max_safe_length := 45 # slightly higher for extreme values - if summary.length() > max_safe_length: - return {"ok": false, "msg": "extreme value compact summary length %d exceeds safe bound %d" % [summary.length(), max_safe_length]} - - print(" extreme value compact summary: \"%s\" (%d chars)" % [summary, summary.length()]) - return true + var rows_ok := true + var rows_detail := "" + for row_name in hud_rows: + var text: String = hud_rows[row_name] + if text.length() > 45: # bottom dock at HUD font size + rows_ok = false + rows_detail = "HUD row \"%s\" text \"%s\" length %d exceeds safe bound" % [row_name, text, text.length()] + if rows_ok: + print(" all HUD rows individually within safe bounds") + assert_true(rows_ok, "all HUD rows individually fit within safe bounds", rows_detail) diff --git a/tests/test_rotating_goal.gd b/tests/test_rotating_goal.gd index de2c95c..39e3bf0 100644 --- a/tests/test_rotating_goal.gd +++ b/tests/test_rotating_goal.gd @@ -1,53 +1,22 @@ -extends SceneTree +extends "res://tests/test_case.gd" # Tests for rotating_goal.gd — misospace/windowstead#142 # Fixes for test_rotating_goal.gd bugs tracked in issue #183 -var test_pass := 0 -var test_fail := 0 +const RG := preload("res://scripts/rotating_goal.gd") -func _initialize() -> void: - var goal_script := load("res://scripts/rotating_goal.gd") - test_catalog_exists(goal_script) - test_apply_goal_template(goal_script) - test_select_next_active_goal(goal_script) - test_update_resource_progress(goal_script) - test_compute_resource_progress(goal_script) - test_compute_build_progress(goal_script) - test_compute_build_complete_progress(goal_script) - test_is_goal_complete(goal_script) - test_complete_goal_noop_reward(goal_script) - test_rotate_after_completion(goal_script) - test_reward_preview_text(goal_script) - - print("") - print("=== test_rotating_goal summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED — CI should fail") - quit(1) - else: - print("test_rotating_goal: ok") - quit(0) - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) - -func _assert_str_eq(actual: String, expected: String, name: String) -> void: - _assert(actual == expected, name, "expected '%s', got '%s'" % [expected, actual]) +func run_tests() -> void: + test_catalog_exists(RG) + test_apply_goal_template(RG) + test_select_next_active_goal(RG) + test_update_resource_progress(RG) + test_compute_resource_progress(RG) + test_compute_build_progress(RG) + test_compute_build_complete_progress(RG) + test_is_goal_complete(RG) + test_complete_goal_noop_reward(RG) + test_rotate_after_completion(RG) + test_reward_preview_text(RG) # ── Tests ───────────────────────────────────────────────────────────────────── @@ -57,21 +26,21 @@ func test_catalog_exists(gs: Variant) -> void: print("--- catalog ---") var catalog = gs.GOAL_CATALOG - _assert(catalog.size() > 0, "catalog_not_empty") - _assert_eq(catalog.size(), 7, "catalog_size_7_entries") + assert_true(catalog.size() > 0, "catalog_not_empty") + assert_eq(catalog.size(), 7, "catalog_size_7_entries") # Check expected types are present var types := {} for entry in catalog: types[entry["type"]] = true - _assert(types.has(gs.GOAL_TYPE_RESOURCE), "catalog_has_resource_type") - _assert(types.has(gs.GOAL_TYPE_BUILD), "catalog_has_build_type") - _assert(types.has(gs.GOAL_TYPE_BUILD_COMPLETE), "catalog_has_build_complete_type") + assert_true(types.has(gs.GOAL_TYPE_RESOURCE), "catalog_has_resource_type") + assert_true(types.has(gs.GOAL_TYPE_BUILD), "catalog_has_build_type") + assert_true(types.has(gs.GOAL_TYPE_BUILD_COMPLETE), "catalog_has_build_complete_type") # Check all 7 goal IDs var ids := ["gather_wood", "gather_stone", "gather_food", "build_hut", "build_workshop", "build_garden", "any_build"] for id in ids: - _assert(gs.GOAL_CATALOG.any(func(e): return e["id"] == id), "catalog_has_id_%s" % id) + assert_true(gs.GOAL_CATALOG.any(func(e): return e["id"] == id), "catalog_has_id_%s" % id) func test_apply_goal_template(gs: Variant) -> void: @@ -81,16 +50,16 @@ func test_apply_goal_template(gs: Variant) -> void: var template = gs.GOAL_CATALOG[0] # gather_wood var goal = gs.apply_goal_template(template) - _assert_str_eq(goal["id"], "gather_wood", "template_id") - _assert_str_eq(goal["type"], gs.GOAL_TYPE_RESOURCE, "template_type") - _assert_eq(goal["target"]["resource"], "wood", "template_target_resource") - _assert_eq(goal["target"]["amount"], 10, "template_target_amount") - _assert_eq(goal["current_progress"], 0, "template_progress_starts_zero") - _assert(not goal["completed"], "template_not_completed") + assert_eq(goal["id"], "gather_wood", "template_id") + assert_eq(goal["type"], gs.GOAL_TYPE_RESOURCE, "template_type") + assert_eq(goal["target"]["resource"], "wood", "template_target_resource") + assert_eq(goal["target"]["amount"], 10, "template_target_amount") + assert_eq(goal["current_progress"], 0, "template_progress_starts_zero") + assert_true(not goal["completed"], "template_not_completed") # Verify target is a deep copy (modifying one doesn't affect catalog) goal["target"]["amount"] = 999 - _assert_eq(gs.GOAL_CATALOG[0]["target"]["amount"], 10, "template_deep_copies_target") + assert_eq(gs.GOAL_CATALOG[0]["target"]["amount"], 10, "template_deep_copies_target") func test_select_next_active_goal(gs: Variant) -> void: @@ -99,21 +68,21 @@ func test_select_next_active_goal(gs: Variant) -> void: # First call: should return first catalog entry (gather_wood) var goal = gs.select_next_active_goal([]) - _assert_str_eq(goal["id"], "gather_wood", "select_first_returns_gather_wood") - _assert(not goal.is_empty(), "select_returns_non_empty") + assert_eq(goal["id"], "gather_wood", "select_first_returns_gather_wood") + assert_true(not goal.is_empty(), "select_returns_non_empty") # Skip gather_wood: should return gather_stone goal = gs.select_next_active_goal(["gather_wood"]) - _assert_str_eq(goal["id"], "gather_stone", "select_skips_completed") + assert_eq(goal["id"], "gather_stone", "select_skips_completed") # Skip all but last: should return any_build var completed := ["gather_wood", "gather_stone", "gather_food", "build_hut", "build_workshop", "build_garden"] goal = gs.select_next_active_goal(completed) - _assert_str_eq(goal["id"], "any_build", "select_returns_last_remaining") + assert_eq(goal["id"], "any_build", "select_returns_last_remaining") # All completed: should return empty dict goal = gs.select_next_active_goal(gs.GOAL_CATALOG.map(func(e): return e["id"])) - _assert(goal.is_empty(), "select_all_completed_returns_empty") + assert_true(goal.is_empty(), "select_all_completed_returns_empty") func test_update_resource_progress(gs: Variant) -> void: @@ -123,13 +92,13 @@ func test_update_resource_progress(gs: Variant) -> void: var goal := {"id": "gather_wood", "type": gs.GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 10}, "current_progress": 0, "completed": false} # gather_wood, target=10 gs.update_resource_progress(goal, 3) - _assert_eq(goal["current_progress"], 3, "progress_add_3") + assert_eq(goal["current_progress"], 3, "progress_add_3") gs.update_resource_progress(goal, 5) - _assert_eq(goal["current_progress"], 8, "progress_add_5_more") + assert_eq(goal["current_progress"], 8, "progress_add_5_more") gs.update_resource_progress(goal, 10) # should clamp at 10 - _assert_eq(goal["current_progress"], 10, "progress_clamped_at_target") + assert_eq(goal["current_progress"], 10, "progress_clamped_at_target") func test_compute_resource_progress(gs: Variant) -> void: @@ -142,14 +111,14 @@ func test_compute_resource_progress(gs: Variant) -> void: "harvested": {"wood": 3, "stone": 4, "food": 2}, } gs.compute_resource_progress(goal, game_state) - _assert_eq(goal["current_progress"], 4, "compute_stone_from_harvested") + assert_eq(goal["current_progress"], 4, "compute_stone_from_harvested") game_state = { "harvested": {"iron": 1}, } var goal2 = gs.apply_goal_template(gs.GOAL_CATALOG[0]) # gather_wood gs.compute_resource_progress(goal2, game_state) - _assert_eq(goal2["current_progress"], 0, "compute_missing_resource_is_zero") + assert_eq(goal2["current_progress"], 0, "compute_missing_resource_is_zero") func test_compute_build_progress(gs: Variant) -> void: @@ -166,7 +135,7 @@ func test_compute_build_progress(gs: Variant) -> void: ], } gs.compute_build_progress(goal, game_state) - _assert_eq(goal["current_progress"], 2, "compute_build_counts_target_kind") + assert_eq(goal["current_progress"], 2, "compute_build_counts_target_kind") # No builds of target kind var goal2 = gs.apply_goal_template(gs.GOAL_CATALOG[4]) # build_workshop @@ -174,7 +143,7 @@ func test_compute_build_progress(gs: Variant) -> void: "builds": [{"kind": "hut", "id": 1}], } gs.compute_build_progress(goal2, game_state2) - _assert_eq(goal2["current_progress"], 0, "compute_build_no_match_is_zero") + assert_eq(goal2["current_progress"], 0, "compute_build_no_match_is_zero") func test_compute_build_complete_progress(gs: Variant) -> void: @@ -190,11 +159,11 @@ func test_compute_build_complete_progress(gs: Variant) -> void: ], } gs.compute_build_complete_progress(goal, game_state) - _assert_eq(goal["current_progress"], 2, "build_complete_counts_all_builds") + assert_eq(goal["current_progress"], 2, "build_complete_counts_all_builds") var game_state_empty := {"builds": []} gs.compute_build_complete_progress(goal, game_state_empty) - _assert_eq(goal["current_progress"], 0, "build_complete_no_builds_is_zero") + assert_eq(goal["current_progress"], 0, "build_complete_no_builds_is_zero") func test_is_goal_complete(gs: Variant) -> void: @@ -203,24 +172,24 @@ func test_is_goal_complete(gs: Variant) -> void: # Resource goal: not complete at 0 var goal := {"id": "gather_wood", "type": gs.GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 10}, "current_progress": 0, "completed": false} # gather_wood, target=10 - _assert(not gs.is_goal_complete(goal), "resource_not_complete_at_zero") + assert_true(not gs.is_goal_complete(goal), "resource_not_complete_at_zero") gs.update_resource_progress(goal, 10) - _assert(gs.is_goal_complete(goal), "resource_complete_at_target") + assert_true(gs.is_goal_complete(goal), "resource_complete_at_target") # Build goal: not complete at 0 progress goal = gs.apply_goal_template(gs.GOAL_CATALOG[3]) # build_hut - _assert(not gs.is_goal_complete(goal), "build_not_complete_at_zero") + assert_true(not gs.is_goal_complete(goal), "build_not_complete_at_zero") goal["current_progress"] = 1 - _assert(gs.is_goal_complete(goal), "build_complete_with_one_build") + assert_true(gs.is_goal_complete(goal), "build_complete_with_one_build") # Build-complete goal goal = gs.apply_goal_template(gs.GOAL_CATALOG[6]) # any_build - _assert(not gs.is_goal_complete(goal), "build_complete_not_at_zero") + assert_true(not gs.is_goal_complete(goal), "build_complete_not_at_zero") goal["current_progress"] = 1 - _assert(gs.is_goal_complete(goal), "build_complete_complete_with_one_build") + assert_true(gs.is_goal_complete(goal), "build_complete_complete_with_one_build") func test_complete_goal_noop_reward(gs: Variant) -> void: @@ -228,13 +197,13 @@ func test_complete_goal_noop_reward(gs: Variant) -> void: print("--- complete_goal_noop_reward ---") var goal := {"id": "gather_wood", "type": gs.GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 10}, "current_progress": 0, "completed": false} - _assert(not goal["completed"], "goal_not_completed_initially") + assert_true(not goal["completed"], "goal_not_completed_initially") gs.complete_goal(goal) - _assert(goal["completed"], "goal_marked_completed") + assert_true(goal["completed"], "goal_marked_completed") # Verify no reward field was added (no-op completion) - _assert(not goal.has("reward"), "complete_no_reward_field_added") + assert_true(not goal.has("reward"), "complete_no_reward_field_added") func test_rotate_after_completion(gs: Variant) -> void: @@ -244,31 +213,31 @@ func test_rotate_after_completion(gs: Variant) -> void: # Test 1: Rotate from gather_wood → gather_stone, no prior completed IDs var active = gs.apply_goal_template(gs.GOAL_CATALOG[0]) # gather_wood var new_goal = gs.rotate_after_completion(active, []) - _assert_str_eq(new_goal["id"], "gather_stone", "rotate_from_wood_to_stone_no_prior") - _assert(active["completed"], "active_goal_marked_completed") - _assert(not new_goal["completed"], "new_goal_not_completed") - _assert_eq(new_goal["current_progress"], 0, "new_goal_progress_starts_zero") + assert_eq(new_goal["id"], "gather_stone", "rotate_from_wood_to_stone_no_prior") + assert_true(active["completed"], "active_goal_marked_completed") + assert_true(not new_goal["completed"], "new_goal_not_completed") + assert_eq(new_goal["current_progress"], 0, "new_goal_progress_starts_zero") # Test 2: Rotate with prior completed IDs — skip them active = gs.apply_goal_template(gs.GOAL_CATALOG[1]) # gather_stone new_goal = gs.rotate_after_completion(active, ["gather_wood"]) - _assert_str_eq(new_goal["id"], "gather_food", "rotate_skips_prior_completed") + assert_eq(new_goal["id"], "gather_food", "rotate_skips_prior_completed") # Test 3: Rotate from last goal (any_build) → empty when all done active = gs.apply_goal_template(gs.GOAL_CATALOG[6]) # any_build var all_ids := ["gather_wood", "gather_stone", "gather_food", "build_hut", "build_workshop", "build_garden"] new_goal = gs.rotate_after_completion(active, all_ids) - _assert(new_goal.is_empty(), "rotate_all_done_returns_empty") + assert_true(new_goal.is_empty(), "rotate_all_done_returns_empty") # Test 4: Completed goal does not immediately repeat — verify active_goal's ID is in the skip set active = gs.apply_goal_template(gs.GOAL_CATALOG[0]) # gather_wood new_goal = gs.rotate_after_completion(active, []) - _assert_str_eq(new_goal["id"], "gather_stone", "no_immediate_repeat_of_active") + assert_eq(new_goal["id"], "gather_stone", "no_immediate_repeat_of_active") # Test 5: Completed IDs passed in are deduplicated (duplicates shouldn't cause issues) active = gs.apply_goal_template(gs.GOAL_CATALOG[2]) # gather_food new_goal = gs.rotate_after_completion(active, ["gather_wood", "gather_wood"]) - _assert_str_eq(new_goal["id"], "gather_stone", "deduplicate_prior_completed_ids") + assert_eq(new_goal["id"], "gather_stone", "deduplicate_prior_completed_ids") # Test 6: Rotation chains correctly — complete one, get next, complete that, get next active = gs.apply_goal_template(gs.GOAL_CATALOG[0]) # gather_wood @@ -281,10 +250,10 @@ func test_rotate_after_completion(gs: Variant) -> void: chain.append(new_goal["id"]) completed_ids.append(active["id"]) active = new_goal - _assert_eq(chain.size(), 3, "rotation_chain_length_3") - _assert_str_eq(chain[0], "gather_stone", "chain_step_1_gather_stone") - _assert_str_eq(chain[1], "gather_food", "chain_step_2_gather_food") - _assert_str_eq(chain[2], "build_hut", "chain_step_3_build_hut") + assert_eq(chain.size(), 3, "rotation_chain_length_3") + assert_eq(chain[0], "gather_stone", "chain_step_1_gather_stone") + assert_eq(chain[1], "gather_food", "chain_step_2_gather_food") + assert_eq(chain[2], "build_hut", "chain_step_3_build_hut") func test_reward_preview_text(gs: Variant) -> void: @@ -294,42 +263,41 @@ func test_reward_preview_text(gs: Variant) -> void: # Test 1: Resource goal with reward text var goal := {"id": "gather_wood", "type": gs.GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 10}, "current_progress": 0, "completed": false, "reward": "+1 food"} # gather_wood, reward="+1 food" var preview = gs.get_reward_preview_text(goal) - _assert_str_eq(preview, "Reward: +1 food", "resource_goal_has_reward_preview") + assert_eq(preview, "Reward: +1 food", "resource_goal_has_reward_preview") # Test 2: Build goal with reward text goal = gs.apply_goal_template(gs.GOAL_CATALOG[3]) # build_hut, reward="haul speed +10%" preview = gs.get_reward_preview_text(goal) - _assert_str_eq(preview, "Reward: haul speed +10%", "build_goal_has_reward_preview") + assert_eq(preview, "Reward: haul speed +10%", "build_goal_has_reward_preview") # Test 3: Goal without reward field returns empty string var no_reward := {"id": "custom", "type": gs.GOAL_TYPE_RESOURCE, "target": {"resource": "wood", "amount": 5}, "current_progress": 0, "completed": false} preview = gs.get_reward_preview_text(no_reward) - _assert_str_eq(preview, "", "goal_without_reward_field_returns_empty") + assert_eq(preview, "", "goal_without_reward_field_returns_empty") # Test 4: Goal with empty reward string returns empty var empty_reward := no_reward.duplicate() empty_reward["reward"] = "" preview = gs.get_reward_preview_text(empty_reward) - _assert_str_eq(preview, "", "goal_with_empty_reward_returns_empty") + assert_eq(preview, "", "goal_with_empty_reward_returns_empty") # Test 5: Empty goal dict returns empty var empty_goal := {} preview = gs.get_reward_preview_text(empty_goal) - _assert_str_eq(preview, "", "empty_goal_returns_empty") + assert_eq(preview, "", "empty_goal_returns_empty") # Test 6: Compact dock UI fit — reward text should be short enough for compact label goal = gs.apply_goal_template(gs.GOAL_CATALOG[5]) # build_garden, reward="ambient event improves" preview = gs.get_reward_preview_text(goal) - _assert(preview.length() < 40, "reward_preview_fits_compact_dock") - _assert_str_eq(preview, "Reward: ambient event improves", "garden_goal_reward_preview") + assert_true(preview.length() < 40, "reward_preview_fits_compact_dock") + assert_eq(preview, "Reward: ambient event improves", "garden_goal_reward_preview") # Test 7: all catalog goals have non-empty reward strings for entry in gs.GOAL_CATALOG: var g = gs.apply_goal_template(entry) var p = gs.get_reward_preview_text(g) - _assert(not p.is_empty(), "catalog_entry_%s_has_reward" % entry["id"]) + assert_true(not p.is_empty(), "catalog_entry_%s_has_reward" % entry["id"]) # Note: Production code fix in game_state.gd (GDScript 2.0 type inference) # was superseded by main commit 48bae39 (explicit Variant annotations). # The test fixes below correct bugs identified in issue #183. - diff --git a/tests/test_runner.gd b/tests/test_runner.gd index 255f9ae..9364a12 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -1,14 +1,10 @@ -extends SceneTree +extends "res://tests/test_case.gd" -# ── Test harness ────────────────────────────────────────────────────────────── -# Each test prints: "TEST : []" -# At the end: summary with pass/fail counts. -# Failures are fatal — the CI job fails on the first assertion failure. +# ── Core script test suite ──────────────────────────────────────────────────── +# Persistence, resources, priorities, workers, migration, and reservation +# behavior. Assertion helpers and the summary come from tests/test_case.gd. -var test_pass := 0 -var test_fail := 0 - -func _initialize() -> void: +func run_tests() -> void: var game_state_script := load("res://scripts/game_state.gd") var game_state = game_state_script.new() root.add_child(game_state) @@ -31,42 +27,6 @@ func _initialize() -> void: test_two_worker_race_condition(game_state) test_delivery_clamping(game_state) - # Summary - print("") - print("=== test_runner summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED — CI should fail") - quit(1) - else: - print("test_runner: ok") - quit(0) - - -# ── Helpers ─────────────────────────────────────────────────────────────────── -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - # Don't abort on first failure — let all tests run for full report - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) - - -func _assert_not_empty(d: Dictionary, name: String) -> void: - _assert(not d.is_empty(), name, "dictionary should not be empty") - - -func _assert_empty(d: Variant, name: String) -> void: - _assert(d.is_empty(), name, "dictionary should be empty") - # ── Tests ───────────────────────────────────────────────────────────────────── @@ -82,14 +42,14 @@ func test_persistence_roundtrip(gs: Node) -> void: gs.save_game(payload) var loaded = gs.load_game() - _assert_not_empty(loaded, "persistence_roundtrip: load returned data") - _assert_eq(int(loaded.get("tick", -1)), 42, "persistence_roundtrip: tick") - _assert_eq(int(loaded.get("resources", {}).get("wood", -1)), 7, "persistence_roundtrip: wood") - _assert_eq(int(loaded.get("resources", {}).get("stone", -1)), 3, "persistence_roundtrip: stone") - _assert_eq(loaded.get("events", []).size(), 1, "persistence_roundtrip: events count") + assert_not_empty(loaded, "persistence_roundtrip: load returned data") + assert_eq(int(loaded.get("tick", -1)), 42, "persistence_roundtrip: tick") + assert_eq(int(loaded.get("resources", {}).get("wood", -1)), 7, "persistence_roundtrip: wood") + assert_eq(int(loaded.get("resources", {}).get("stone", -1)), 3, "persistence_roundtrip: stone") + assert_eq(loaded.get("events", []).size(), 1, "persistence_roundtrip: events count") gs.clear_game() - _assert_empty(gs.load_game(), "persistence_roundtrip: cleared game is empty") + assert_empty(gs.load_game(), "persistence_roundtrip: cleared game is empty") func test_resource_operations(gs: Node) -> void: @@ -113,17 +73,17 @@ func test_resource_operations(gs: Node) -> void: var loaded = gs.load_game() # Verify initial resources - _assert_eq(int(loaded.get("resources", {}).get("wood", -1)), 8, "resource_ops: initial wood") - _assert_eq(int(loaded.get("resources", {}).get("stone", -1)), 4, "resource_ops: initial stone") - _assert_eq(int(loaded.get("resources", {}).get("food", -1)), 2, "resource_ops: initial food") + assert_eq(int(loaded.get("resources", {}).get("wood", -1)), 8, "resource_ops: initial wood") + assert_eq(int(loaded.get("resources", {}).get("stone", -1)), 4, "resource_ops: initial stone") + assert_eq(int(loaded.get("resources", {}).get("food", -1)), 2, "resource_ops: initial food") # Simulate resource modification (gather wood) payload["resources"]["wood"] = 10 payload["harvested"]["wood"] = 2 gs.save_game(payload) loaded = gs.load_game() - _assert_eq(int(loaded.get("resources", {}).get("wood", -1)), 10, "resource_ops: updated wood") - _assert_eq(int(loaded.get("harvested", {}).get("wood", -1)), 2, "resource_ops: harvested wood") + assert_eq(int(loaded.get("resources", {}).get("wood", -1)), 10, "resource_ops: updated wood") + assert_eq(int(loaded.get("harvested", {}).get("wood", -1)), 2, "resource_ops: harvested wood") func test_build_costs_and_unlocks(gs: Node) -> void: @@ -136,12 +96,12 @@ func test_build_costs_and_unlocks(gs: Node) -> void: "workshop": {"wood": 4, "stone": 6}, "garden": {"wood": 3, "stone": 1}, } - _assert_eq(costs.get("hut", {}).get("wood", 0), 6, "build_costs: hut wood cost") - _assert_eq(costs.get("hut", {}).get("stone", 0), 2, "build_costs: hut stone cost") - _assert_eq(costs.get("workshop", {}).get("wood", 0), 4, "build_costs: workshop wood cost") - _assert_eq(costs.get("workshop", {}).get("stone", 0), 6, "build_costs: workshop stone cost") - _assert_eq(costs.get("garden", {}).get("wood", 0), 3, "build_costs: garden wood cost") - _assert_eq(costs.get("garden", {}).get("stone", 0), 1, "build_costs: garden stone cost") + assert_eq(costs.get("hut", {}).get("wood", 0), 6, "build_costs: hut wood cost") + assert_eq(costs.get("hut", {}).get("stone", 0), 2, "build_costs: hut stone cost") + assert_eq(costs.get("workshop", {}).get("wood", 0), 4, "build_costs: workshop wood cost") + assert_eq(costs.get("workshop", {}).get("stone", 0), 6, "build_costs: workshop stone cost") + assert_eq(costs.get("garden", {}).get("wood", 0), 3, "build_costs: garden wood cost") + assert_eq(costs.get("garden", {}).get("stone", 0), 1, "build_costs: garden stone cost") # Verify unlock chain: hut → workshop → garden var unlocks := { @@ -149,9 +109,9 @@ func test_build_costs_and_unlocks(gs: Node) -> void: "workshop": "hut", "garden": "workshop", } - _assert(unlocks.get("hut") == true, "build_unlocks: hut is unlocked by default") - _assert_eq(unlocks.get("workshop"), "hut", "build_unlocks: workshop requires hut") - _assert_eq(unlocks.get("garden"), "workshop", "build_unlocks: garden requires workshop") + assert_true(unlocks.get("hut") == true, "build_unlocks: hut is unlocked by default") + assert_eq(unlocks.get("workshop"), "hut", "build_unlocks: workshop requires hut") + assert_eq(unlocks.get("garden"), "workshop", "build_unlocks: garden requires workshop") func test_priority_ordering(gs: Node) -> void: @@ -174,10 +134,10 @@ func test_priority_ordering(gs: Node) -> void: var loaded = gs.load_game() var order = loaded.get("priority_order", []) - _assert_eq(order.size(), 3, "priority_order: has 3 entries") - _assert_eq(order[0], "gather", "priority_order: first is gather") - _assert_eq(order[1], "haul", "priority_order: second is haul") - _assert_eq(order[2], "build", "priority_order: third is build") + assert_eq(order.size(), 3, "priority_order: has 3 entries") + assert_eq(order[0], "gather", "priority_order: first is gather") + assert_eq(order[1], "haul", "priority_order: second is haul") + assert_eq(order[2], "build", "priority_order: third is build") # Test default order var default_payload := { @@ -194,8 +154,8 @@ func test_priority_ordering(gs: Node) -> void: gs.save_game(default_payload) loaded = gs.load_game() order = loaded.get("priority_order", []) - _assert_eq(order[0], "build", "priority_order: default first is build") - _assert_eq(order[2], "gather", "priority_order: default last is gather") + assert_eq(order[0], "build", "priority_order: default first is build") + assert_eq(order[2], "gather", "priority_order: default last is gather") func test_tile_get_set(gs: Node) -> void: @@ -223,19 +183,19 @@ func test_tile_get_set(gs: Node) -> void: var loaded = gs.load_game() # Verify tile grid - _assert_eq(loaded.get("tiles", []).size(), 25, "tile_ops: 5x5 grid = 25 tiles") - _assert_eq(loaded.get("tiles", [])[0].get("kind", ""), "ground", "tile_ops: tile[0] is ground") - _assert_eq(loaded.get("tiles", [])[12].get("kind", ""), "ground", "tile_ops: tile[12] is ground") + assert_eq(loaded.get("tiles", []).size(), 25, "tile_ops: 5x5 grid = 25 tiles") + assert_eq(loaded.get("tiles", [])[0].get("kind", ""), "ground", "tile_ops: tile[0] is ground") + assert_eq(loaded.get("tiles", [])[12].get("kind", ""), "ground", "tile_ops: tile[12] is ground") # Simulate placing a tree on tile 0 loaded["tiles"][0] = {"kind": "tree", "amount": 6, "resource": "wood", "build_kind": ""} gs.save_game(loaded) loaded = gs.load_game() - _assert_eq(loaded.get("tiles", [])[0].get("kind", ""), "tree", "tile_ops: tree placed on tile 0") - _assert_eq(int(loaded.get("tiles", [])[0].get("amount", -1)), 6, "tile_ops: tree has 6 wood") + assert_eq(loaded.get("tiles", [])[0].get("kind", ""), "tree", "tile_ops: tree placed on tile 0") + assert_eq(int(loaded.get("tiles", [])[0].get("amount", -1)), 6, "tile_ops: tree has 6 wood") # Verify other tiles unchanged - _assert_eq(loaded.get("tiles", [])[1].get("kind", ""), "ground", "tile_ops: tile 1 still ground") + assert_eq(loaded.get("tiles", [])[1].get("kind", ""), "ground", "tile_ops: tile 1 still ground") func test_worker_task_state(gs: Node) -> void: @@ -276,21 +236,21 @@ func test_worker_task_state(gs: Node) -> void: var loaded = gs.load_game() var loaded_workers = loaded.get("workers", []) - _assert_eq(loaded_workers.size(), 2, "worker_ops: 2 workers persisted") + assert_eq(loaded_workers.size(), 2, "worker_ops: 2 workers persisted") # Jun: idle, no carrying var jun = loaded_workers[0] - _assert_eq(jun.get("name", ""), "Jun", "worker_ops: Jun's name") - _assert(jun.get("task", {}).is_empty(), "worker_ops: Jun has no task") - _assert(jun.get("carrying", {}).is_empty(), "worker_ops: Jun carrying nothing") - _assert_eq(int(jun.get("break_ticks", 0)), 0, "worker_ops: Jun not on break") + assert_eq(jun.get("name", ""), "Jun", "worker_ops: Jun's name") + assert_true(jun.get("task", {}).is_empty(), "worker_ops: Jun has no task") + assert_true(jun.get("carrying", {}).is_empty(), "worker_ops: Jun carrying nothing") + assert_eq(int(jun.get("break_ticks", 0)), 0, "worker_ops: Jun not on break") # Mara: hauling wood var mara = loaded_workers[1] - _assert_eq(mara.get("name", ""), "Mara", "worker_ops: Mara's name") - _assert_eq(mara.get("task", {}).get("kind", ""), "haul", "worker_ops: Mara hauling") - _assert_eq(int(mara.get("carrying", {}).get("wood", 0)), 2, "worker_ops: Mara carrying 2 wood") - _assert_eq(int(mara.get("break_ticks", 0)), 0, "worker_ops: Mara not on break") + assert_eq(mara.get("name", ""), "Mara", "worker_ops: Mara's name") + assert_eq(mara.get("task", {}).get("kind", ""), "haul", "worker_ops: Mara hauling") + assert_eq(int(mara.get("carrying", {}).get("wood", 0)), 2, "worker_ops: Mara carrying 2 wood") + assert_eq(int(mara.get("break_ticks", 0)), 0, "worker_ops: Mara not on break") # Test break state workers[0]["break_ticks"] = 6 @@ -298,7 +258,7 @@ func test_worker_task_state(gs: Node) -> void: gs.save_game(payload) loaded = gs.load_game() jun = loaded.get("workers", [])[0] - _assert_eq(int(jun.get("break_ticks", -1)), 6, "worker_ops: Jun on break for 6 ticks") + assert_eq(int(jun.get("break_ticks", -1)), 6, "worker_ops: Jun on break for 6 ticks") func test_save_version_tracking(gs: Node) -> void: @@ -320,8 +280,8 @@ func test_save_version_tracking(gs: Node) -> void: gs.save_game(payload) var loaded = gs.load_game() - _assert_not_empty(loaded, "save_version: load returned data") - _assert_eq(int(loaded.get("save_version", -1)), 2, "save_version: version is 2") + assert_not_empty(loaded, "save_version: load returned data") + assert_eq(int(loaded.get("save_version", -1)), 2, "save_version: version is 2") func test_settings_roundtrip(gs: Node) -> void: @@ -335,14 +295,14 @@ func test_settings_roundtrip(gs: Node) -> void: gs.save_settings(settings) var loaded = gs.load_settings() - _assert_not_empty(loaded, "settings_roundtrip: settings loaded") - _assert_eq(loaded.get("dock_anchor", ""), "left", "settings_roundtrip: dock_anchor") - _assert_eq(int(loaded.get("tick_speed", -1)), 2, "settings_roundtrip: tick_speed") + assert_not_empty(loaded, "settings_roundtrip: settings loaded") + assert_eq(loaded.get("dock_anchor", ""), "left", "settings_roundtrip: dock_anchor") + assert_eq(int(loaded.get("tick_speed", -1)), 2, "settings_roundtrip: tick_speed") # Test default settings when nothing saved gs.clear_game() loaded = gs.load_settings() - _assert_empty(loaded, "settings_roundtrip: cleared settings are empty") + assert_empty(loaded, "settings_roundtrip: cleared settings are empty") func test_event_log(gs: Node) -> void: @@ -370,10 +330,10 @@ func test_event_log(gs: Node) -> void: var loaded = gs.load_game() var loaded_events = loaded.get("events", []) - _assert_eq(loaded_events.size(), 3, "event_log: 3 events persisted") - _assert_eq(loaded_events[0].get("tick", -1), 0, "event_log: first event tick 0") - _assert_eq(loaded_events[0].get("text", ""), "Colony started", "event_log: first event text") - _assert_eq(loaded_events[2].get("text", ""), "Hut built", "event_log: last event text") + assert_eq(loaded_events.size(), 3, "event_log: 3 events persisted") + assert_eq(loaded_events[0].get("tick", -1), 0, "event_log: first event tick 0") + assert_eq(loaded_events[0].get("text", ""), "Colony started", "event_log: first event text") + assert_eq(loaded_events[2].get("text", ""), "Hut built", "event_log: last event text") @@ -390,22 +350,22 @@ func test_bounded_event_log(gs: Node) -> void: while events.size() > MAX_EVENTS: events.pop_back() - _assert_eq(events.size(), MAX_EVENTS, "bounded_event_log: capped at 20") + assert_eq(events.size(), MAX_EVENTS, "bounded_event_log: capped at 20") # First event should be the most recent (24), last should be oldest kept (5) - _assert_eq(int(events[0].get("tick", -1)), 24, "bounded_event_log: first is newest (24)") - _assert_eq(int(events[MAX_EVENTS - 1].get("tick", -1)), 5, "bounded_event_log: last is oldest kept (5)") + assert_eq(int(events[0].get("tick", -1)), 24, "bounded_event_log: first is newest (24)") + assert_eq(int(events[MAX_EVENTS - 1].get("tick", -1)), 5, "bounded_event_log: last is oldest kept (5)") # Verify eviction count: 25 pushed - 20 kept = 5 evicted var evicted_count := 25 - MAX_EVENTS - _assert_eq(evicted_count, 5, "bounded_event_log: 5 events evicted") + assert_eq(evicted_count, 5, "bounded_event_log: 5 events evicted") # Empty log stays empty var empty_events := [] - _assert_empty(empty_events, "bounded_event_log: empty log is empty") + assert_empty(empty_events, "bounded_event_log: empty log is empty") # Single event fits without eviction empty_events.push_front({"tick": 0, "text": "Single"}) - _assert_eq(empty_events.size(), 1, "bounded_event_log: single event size 1") + assert_eq(empty_events.size(), 1, "bounded_event_log: single event size 1") func test_clear_game(gs: Node) -> void: print("") @@ -427,11 +387,11 @@ func test_clear_game(gs: Node) -> void: gs.clear_game() var loaded = gs.load_game() - _assert_empty(loaded, "clear_game: game data is empty after clear") + assert_empty(loaded, "clear_game: game data is empty after clear") # Settings should also be cleared var settings = gs.load_settings() - _assert_empty(settings, "clear_game: settings are empty after clear") + assert_empty(settings, "clear_game: settings are empty after clear") # ── Save migration hardening tests ──────────────────────────────────────────── @@ -457,12 +417,12 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(v1_payload) var migrated = gs.load_game() - _assert_eq(int(migrated.get("save_version", -1)), 2, "migration_v1_to_v2: version upgraded to 2") - _assert(migrated.has("migration_log"), "migration_v1_to_v2: migration_log present") - _assert_eq(int(migrated["migration_log"][0].get("from_version", -1)), 1, "migration_v1_to_v2: log from_version=1") - _assert_eq(int(migrated["migration_log"][0].get("to_version", -1)), 2, "migration_v1_to_v2: log to_version=2") + assert_eq(int(migrated.get("save_version", -1)), 2, "migration_v1_to_v2: version upgraded to 2") + assert_true(migrated.has("migration_log"), "migration_v1_to_v2: migration_log present") + assert_eq(int(migrated["migration_log"][0].get("from_version", -1)), 1, "migration_v1_to_v2: log from_version=1") + assert_eq(int(migrated["migration_log"][0].get("to_version", -1)), 2, "migration_v1_to_v2: log to_version=2") # Worker should get spawn_tick added - _assert(migrated.get("workers", [])[0].has("spawn_tick"), "migration_v1_to_v2: worker gets spawn_tick") + assert_true(migrated.get("workers", [])[0].has("spawn_tick"), "migration_v1_to_v2: worker gets spawn_tick") # ── Future version (> 2) is rejected ── gs.clear_game() @@ -480,7 +440,7 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(future_payload) var future_result = gs.load_game() - _assert_empty(future_result, "migration_future: future version rejected") + assert_empty(future_result, "migration_future: future version rejected") # ── Version 0 (missing) is rejected ── gs.clear_game() @@ -498,7 +458,7 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(v0_payload) var v0_result = gs.load_game() - _assert_empty(v0_result, "migration_v0: version 0 rejected") + assert_empty(v0_result, "migration_v0: version 0 rejected") # ── Missing save_version key treated as current version (backward compatible) ── gs.clear_game() @@ -515,8 +475,8 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(no_version_payload) var no_version_result = gs.load_game() - _assert_not_empty(no_version_result, "migration_no_version: missing version treated as current") - _assert_eq(int(no_version_result.get("save_version", -1)), 2, "migration_no_version: defaults to v2") + assert_not_empty(no_version_result, "migration_no_version: missing version treated as current") + assert_eq(int(no_version_result.get("save_version", -1)), 2, "migration_no_version: defaults to v2") # ── Malformed save: non-dictionary parsed value ── gs.clear_game() @@ -526,7 +486,7 @@ func test_save_migration_hardening(gs: Node) -> void: file.store_string("[1, 2, 3]") file.close() var non_dict_result = gs.load_game() - _assert_empty(non_dict_result, "malformed_non_dict: non-dictionary rejected") + assert_empty(non_dict_result, "malformed_non_dict: non-dictionary rejected") # ── Malformed save: missing required key 'resources' ── gs.clear_game() @@ -544,7 +504,7 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(malformed_resources_str) var malformed_resources_result = gs.load_game() - _assert_empty(malformed_resources_result, "malformed_resources_type: non-dict resources rejected") + assert_empty(malformed_resources_result, "malformed_resources_type: non-dict resources rejected") # ── Malformed save: bad tile count (not a valid grid size) ── gs.clear_game() @@ -565,7 +525,7 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(payload_bad_tiles) var bad_tiles_result = gs.load_game() - _assert_empty(bad_tiles_result, "malformed_bad_tile_count: invalid tile count rejected") + assert_empty(bad_tiles_result, "malformed_bad_tile_count: invalid tile count rejected") # ── Malformed save: tile missing required key ── gs.clear_game() @@ -590,7 +550,7 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(payload_tile_key) var tile_key_result = gs.load_game() - _assert_empty(tile_key_result, "malformed_tile_missing_key: tile missing key rejected") + assert_empty(tile_key_result, "malformed_tile_missing_key: tile missing key rejected") # ── Valid v2 save still works after all hardening ── gs.clear_game() @@ -608,9 +568,9 @@ func test_save_migration_hardening(gs: Node) -> void: } gs.save_game(valid_v2) var valid_result = gs.load_game() - _assert_eq(int(valid_result.get("tick", -1)), 100, "valid_v2: tick preserved") - _assert_eq(int(valid_result.get("save_version", -1)), 2, "valid_v2: version stays 2") - _assert_eq(valid_result.get("workers", [])[0].get("name", ""), "Ava", "valid_v2: worker name preserved") + assert_eq(int(valid_result.get("tick", -1)), 100, "valid_v2: tick preserved") + assert_eq(int(valid_result.get("save_version", -1)), 2, "valid_v2: version stays 2") + assert_eq(valid_result.get("workers", [])[0].get("name", ""), "Ava", "valid_v2: worker name preserved") # ── Resource reservation tracking tests (issue #122) ──────────────────────── @@ -637,9 +597,9 @@ func test_resource_reservations(gs: Node) -> void: } gs.save_game(payload) var loaded = gs.load_game() - _assert_not_empty(loaded, "reservations: save with reserved_resources loads") - _assert_eq(int(loaded.get("reserved_resources", {}).get("wood", -1)), 2, "reservations: wood reserved = 2") - _assert_eq(int(loaded.get("reserved_resources", {}).get("stone", -1)), 0, "reservations: stone reserved = 0") + assert_not_empty(loaded, "reservations: save with reserved_resources loads") + assert_eq(int(loaded.get("reserved_resources", {}).get("wood", -1)), 2, "reservations: wood reserved = 2") + assert_eq(int(loaded.get("reserved_resources", {}).get("stone", -1)), 0, "reservations: stone reserved = 0") # ── Test 2: backward compat — old save without reserved_resources loads ── gs.clear_game() @@ -657,8 +617,8 @@ func test_resource_reservations(gs: Node) -> void: } gs.save_game(legacy_payload) var legacy_loaded = gs.load_game() - _assert_not_empty(legacy_loaded, "reservations: legacy save without reserved_resources loads") - _assert_eq(int(legacy_loaded.get("tick", -1)), 5, "reservations: legacy tick preserved") + assert_not_empty(legacy_loaded, "reservations: legacy save without reserved_resources loads") + assert_eq(int(legacy_loaded.get("tick", -1)), 5, "reservations: legacy tick preserved") # ── Test 3: reserved_resources survives migration v1→v2 ── gs.clear_game() @@ -677,8 +637,8 @@ func test_resource_reservations(gs: Node) -> void: } gs.save_game(v1_with_reservations) var migrated = gs.load_game() - _assert_eq(int(migrated.get("save_version", -1)), 2, "reservations: v1->v2 migration succeeds") - _assert_eq(int(migrated.get("reserved_resources", {}).get("food", -1)), 3, "reservations: food reservation survives migration") + assert_eq(int(migrated.get("save_version", -1)), 2, "reservations: v1->v2 migration succeeds") + assert_eq(int(migrated.get("reserved_resources", {}).get("food", -1)), 3, "reservations: food reservation survives migration") # ── Test 4: reserved_resources empty by default in bootstrap ── gs.clear_game() @@ -696,7 +656,7 @@ func test_resource_reservations(gs: Node) -> void: gs.save_game(fresh_payload) var fresh_loaded = gs.load_game() # No reserved_resources field — should load fine (backward compat) - _assert_not_empty(fresh_loaded, "reservations: save without reserved_resources loads") + assert_not_empty(fresh_loaded, "reservations: save without reserved_resources loads") # ── Test 5: reserved_resources persists through update ── gs.clear_game() @@ -717,8 +677,8 @@ func test_resource_reservations(gs: Node) -> void: state["resources"]["wood"] = 6 # wood consumed gs.save_game(state) var updated = gs.load_game() - _assert_eq(int(updated.get("reserved_resources", {}).get("wood", -1)), 3, "reservations: wood reserved persists after resource change") - _assert_eq(int(updated.get("reserved_resources", {}).get("food", -1)), 1, "reservations: food reserved persists") + assert_eq(int(updated.get("reserved_resources", {}).get("wood", -1)), 3, "reservations: wood reserved persists after resource change") + assert_eq(int(updated.get("reserved_resources", {}).get("food", -1)), 1, "reservations: food reserved persists") # ── Two-worker race condition test (issue #122) ──────────────────────────── @@ -779,24 +739,24 @@ func test_two_worker_race_condition(gs: Node) -> void: # ── WorkerA chooses first — should get gather task and reserve wood ── var task_a: Dictionary = main.choose_task(main.state.workers[0]) - _assert_eq(str(task_a.kind), "gather", "race: workerA gets gather task") - _assert_eq(str(task_a.resource), "wood", "race: workerA targets wood") + assert_eq(str(task_a.kind), "gather", "race: workerA gets gather task") + assert_eq(str(task_a.resource), "wood", "race: workerA targets wood") # Verify reservation was created - _assert_eq(main.get_reserved("wood"), 1, "race: 1 wood reserved after workerA chooses") + assert_eq(main.get_reserved("wood"), 1, "race: 1 wood reserved after workerA chooses") # ── WorkerB chooses — should NOT get gather (tile fully reserved) ── var task_b: Dictionary = main.choose_task(main.state.workers[1]) - _assert(task_b.is_empty(), "race: workerB gets no task (fully reserved)") + assert_true(task_b.is_empty(), "race: workerB gets no task (fully reserved)") # ── After gathering, reservation is released and tree is depleted ── main.do_gather(main.state.workers[0], task_a) - _assert_eq(main.get_reserved("wood"), 0, "race: reservation released after gather") - _assert_eq(int(main.state.workers[0].get("carrying", {}).get("wood", 0)), 1, "race: workerA carrying 1 wood") + assert_eq(main.get_reserved("wood"), 0, "race: reservation released after gather") + assert_eq(int(main.state.workers[0].get("carrying", {}).get("wood", 0)), 1, "race: workerA carrying 1 wood") # ── WorkerB can't gather — tree was depleted by workerA ── var task_b2: Dictionary = main.choose_task(main.state.workers[1]) - _assert(task_b2.is_empty(), "race: workerB gets no task (tree depleted after gather)") + assert_true(task_b2.is_empty(), "race: workerB gets no task (tree depleted after gather)") # ── Delivery clamping test (issue #122) ─────────────────────────────────── @@ -852,8 +812,8 @@ func test_delivery_clamping(gs: Node) -> void: main.do_haul(main.state.workers[0], main.state.workers[0].task) var build: Dictionary = main.get_build(1) - _assert_eq(int(build.delivered.get("wood", 0)), 6, "clamping: delivered clamped to cost (6)") - _assert_eq(int(main.state.resources.get("wood", -1)), 12, "clamping: excess refunded to stockpile (10+2=12)") + assert_eq(int(build.delivered.get("wood", 0)), 6, "clamping: delivered clamped to cost (6)") + assert_eq(int(main.state.resources.get("wood", -1)), 12, "clamping: excess refunded to stockpile (10+2=12)") # ── Second haul — already at cost, should deliver 0 ── main.state.workers[0]["carrying"] = {"wood": 4} @@ -861,6 +821,6 @@ func test_delivery_clamping(gs: Node) -> void: main.do_haul(main.state.workers[0], main.state.workers[0].task) build = main.get_build(1) - _assert_eq(int(build.delivered.get("wood", 0)), 6, "clamping: no over-delivery (stays at 6)") - _assert_eq(int(main.state.resources.get("wood", -1)), 16, "clamping: all excess refunded (12+4=16)") + assert_eq(int(build.delivered.get("wood", 0)), 6, "clamping: no over-delivery (stays at 6)") + assert_eq(int(main.state.resources.get("wood", -1)), 16, "clamping: all excess refunded (12+4=16)") diff --git a/tests/test_save_backup.gd b/tests/test_save_backup.gd index 8e3e999..2332eeb 100644 --- a/tests/test_save_backup.gd +++ b/tests/test_save_backup.gd @@ -1,4 +1,4 @@ -extends SceneTree +extends "res://tests/test_case.gd" # ============================================================================= # Tests for timestamped save backup/restore and extended validation. @@ -12,38 +12,32 @@ extends SceneTree # 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 +# +# Assertion helpers and the summary come from tests/test_case.gd. # ============================================================================= -var tests_run := 0 -var tests_passed := 0 -var tests_failed := 0 -var failures := [] - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -func _assert(condition: bool, msg: String) -> void: - tests_run += 1 - if condition: - tests_passed += 1 - else: - failures.append("FAIL: %s" % msg) - -func _assert_eq(actual, expected, msg: String) -> void: - tests_run += 1 - if actual == expected: - tests_passed += 1 - else: - failures.append("FAIL: %s (expected %s, got %s)" % [msg, str(expected), str(actual)]) +func run_tests() -> void: + # Autoloads are not running in --script mode — instantiate game_state.gd + # manually (same pattern as test_runner.gd). + var game_state_script := load("res://scripts/game_state.gd") + var gs = game_state_script.new() + root.add_child(gs) + await process_frame + + flow_backup_creates_file(gs) + flow_restore_from_backup(gs) + flow_list_backups_sorted(gs) + flow_validation_rejects_invalid(gs) + flow_validation_accepts_valid(gs) + flow_grid_sizing_consistency(gs) + flow_goal_and_stance_validation(gs) # --------------------------------------------------------------------------- # Flow 1: backup_save creates a timestamped file # --------------------------------------------------------------------------- -func flow_backup_creates_file() -> void: +func flow_backup_creates_file(gs: Node) -> void: print("\n=== Flow 1: backup_save creates a timestamped file ===") - var gs := load_game_state() gs.clear_game() # Save some state first @@ -59,18 +53,18 @@ func flow_backup_creates_file() -> void: gs.save_game(state) # Backup - var backup_path := gs.backup_save() - _assert(not backup_path.is_empty(), "backup_save returns a path") - _assert(backup_path.begins_with("user://"), "backup path starts with user://") - _assert(backup_path.contains("windowstead-backup-"), "backup filename has prefix") + var backup_path: String = gs.backup_save() + assert_true(not backup_path.is_empty(), "backup_save returns a path") + assert_true(backup_path.begins_with("user://"), "backup path starts with user://") + assert_true(backup_path.contains("windowstead-backup-"), "backup filename has prefix", + "got %s" % backup_path) # --------------------------------------------------------------------------- # Flow 2: restore_backup restores from the latest backup # --------------------------------------------------------------------------- -func flow_restore_from_backup() -> void: +func flow_restore_from_backup(gs: Node) -> void: print("\n=== Flow 2: restore_backup restores from the latest backup ===") - var gs := load_game_state() gs.clear_game() # Save original state @@ -99,29 +93,37 @@ func flow_restore_from_backup() -> void: gs.save_game(state2) # Verify modified state - var loaded := gs.load_game() - _assert_eq(loaded.get("tick", -1), 50, "modified tick is 50") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 100, "modified wood is 100") + var loaded: Dictionary = gs.load_game() + assert_eq(loaded.get("tick", -1), 50, "modified tick is 50") + assert_eq(loaded.get("resources", {}).get("wood", -1), 100, "modified wood is 100") # Restore backup - var restored_path := gs.restore_backup() - _assert(not restored_path.is_empty(), "restore_backup returns a path") + var restored_path: String = gs.restore_backup() + if not assert_true(not restored_path.is_empty(), "restore_backup returns a path"): + # Restore failed (list_backups found nothing) — skip the dependent + # state checks so one root cause doesn't cascade into extra FAILs. + return # Verify restored state loaded = gs.load_game() - _assert_eq(loaded.get("tick", -1), 10, "restored tick is 10") - _assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "restored wood is 8") - _assert_eq(loaded.get("workers", []).size(), 0, "restored workers empty") - _assert_eq(loaded.get("builds", []).size(), 0, "restored builds empty") + assert_eq(loaded.get("tick", -1), 10, "restored tick is 10") + assert_eq(loaded.get("resources", {}).get("wood", -1), 8, "restored wood is 8") + assert_eq(loaded.get("workers", []).size(), 0, "restored workers empty") + assert_eq(loaded.get("builds", []).size(), 0, "restored builds empty") # --------------------------------------------------------------------------- # Flow 3: list_backups returns newest-first sorted list # --------------------------------------------------------------------------- -func flow_list_backups_sorted() -> void: +## Remove backup files left over from earlier flows/runs so counts are exact. +func _clear_backups(gs: Node) -> void: + for backup_path in gs.list_backups(): + DirAccess.remove_absolute(ProjectSettings.globalize_path(String(backup_path))) + +func flow_list_backups_sorted(gs: Node) -> void: print("\n=== Flow 3: list_backups returns newest-first sorted list ===") - var gs := load_game_state() gs.clear_game() + _clear_backups(gs) # Create multiple backups var state := {"tick": 1, "resources": {"wood": 1}, "harvested": {}, "workers": [], "tiles": [], "builds": [], "events": [], "save_version": 2} @@ -136,18 +138,19 @@ func flow_list_backups_sorted() -> void: gs.save_game(state) gs.backup_save() - var backups := gs.list_backups() - _assert_eq(backups.size(), 3, "three backups exist") - _assert(backups[0] > backups[1], "first backup is newest (sorted reverse)") - _assert(backups[1] > backups[2], "second backup is older than first") + var backups: Array = gs.list_backups() + if not assert_eq(backups.size(), 3, "three backups exist"): + # Backups not discoverable — skip ordering checks (would index OOB). + return + assert_true(backups[0] > backups[1], "first backup is newest (sorted reverse)") + assert_true(backups[1] > backups[2], "second backup is older than first") # --------------------------------------------------------------------------- # Flow 4: validate_save_schema rejects invalid worker/build/task shapes # --------------------------------------------------------------------------- -func flow_validation_rejects_invalid() -> void: +func flow_validation_rejects_invalid(gs: Node) -> void: print("\n=== Flow 4: validate_save_schema rejects invalid shapes ===") - var gs := load_game_state() # Invalid worker: missing name var bad_worker := { @@ -155,13 +158,13 @@ func flow_validation_rejects_invalid() -> void: "workers": [{"pos": {"x": 0, "y": 0}, "carrying": {}, "task": {}}], "tiles": [], "builds": [], "events": [], } - var result := gs.validate_save_schema(bad_worker) - _assert(not result.valid, "rejects worker missing name") + var result: Dictionary = gs.validate_save_schema(bad_worker) + assert_false(result.valid, "rejects worker missing name") # Invalid worker: negative break_ticks bad_worker["workers"] = [{"name": "Jun", "pos": {"x": 0, "y": 0}, "carrying": {}, "task": {}, "break_ticks": -5}] result = gs.validate_save_schema(bad_worker) - _assert(not result.valid, "rejects negative break_ticks") + assert_false(result.valid, "rejects negative break_ticks") # Invalid build: missing id var bad_build := { @@ -171,20 +174,19 @@ func flow_validation_rejects_invalid() -> void: "events": [], } result = gs.validate_save_schema(bad_build) - _assert(not result.valid, "rejects build missing id") + assert_false(result.valid, "rejects build missing id") # Invalid worker: non-numeric carrying value bad_worker["workers"] = [{"name": "Jun", "pos": {"x": 0, "y": 0}, "carrying": {"wood": "bad"}, "task": {}}] result = gs.validate_save_schema(bad_worker) - _assert(not result.valid, "rejects non-numeric carrying value") + assert_false(result.valid, "rejects non-numeric carrying value") # --------------------------------------------------------------------------- # Flow 5: validate_save_schema accepts valid worker/build/task shapes # --------------------------------------------------------------------------- -func flow_validation_accepts_valid() -> void: +func flow_validation_accepts_valid(gs: Node) -> void: print("\n=== Flow 5: validate_save_schema accepts valid shapes ===") - var gs := load_game_state() var good_state := { "tick": 10, "resources": {"wood": 8, "stone": 4}, "harvested": {"wood": 0}, @@ -206,8 +208,9 @@ func flow_validation_accepts_valid() -> void: ], "events": [{"tick": 5, "text": "test event"}], } - var result := gs.validate_save_schema(good_state) - _assert(result.valid, "accepts valid worker/build/task shapes") + var result: Dictionary = gs.validate_save_schema(good_state) + assert_true(result.valid, "accepts valid worker/build/task shapes", + str(result.get("error", ""))) # A side anchor (10x24=240) should also be accepted. var side_state := good_state.duplicate() @@ -221,15 +224,15 @@ func flow_validation_accepts_valid() -> void: 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") + assert_true(result.valid, "accepts side anchor with matching tile count", + str(result.get("error", ""))) # --------------------------------------------------------------------------- # Flow 6: grid sizing consistency (issue #236) # --------------------------------------------------------------------------- -func flow_grid_sizing_consistency() -> void: +func flow_grid_sizing_consistency(gs: Node) -> void: print("\n=== Flow 6: grid sizing consistency ===") - var gs := load_game_state() var base := _minimal_valid_state() @@ -241,14 +244,14 @@ func flow_grid_sizing_consistency() -> void: 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, + assert_false(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, + assert_false(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. @@ -257,23 +260,22 @@ func flow_grid_sizing_consistency() -> void: 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, + assert_true(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, + assert_false(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: +func flow_goal_and_stance_validation(gs: Node) -> 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() @@ -282,7 +284,7 @@ func flow_goal_and_stance_validation() -> void: "target": {"resource": "wood", "amount": 5}, "current_progress": 0, "completed": false, } - _assert(not gs.validate_save_schema(bad_goal).valid, + assert_false(gs.validate_save_schema(bad_goal).valid, "rejects active_goal with unknown id") # active_goal empty dict is valid; a known goal is valid @@ -292,42 +294,42 @@ func flow_goal_and_stance_validation() -> void: "target": {"resource": "wood", "amount": 5}, "current_progress": 0, "completed": false, } - _assert(gs.validate_save_schema(known_goal).valid, + assert_true(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, + assert_false(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, + assert_false(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, + assert_true(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, + assert_false(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, + assert_false(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, + assert_true(gs.validate_save_schema(good_completed).valid, "accepts completed_goal_ids containing only known goals") # --------------------------------------------------------------------------- @@ -344,39 +346,3 @@ func _minimal_valid_state() -> Dictionary: "events": [], "save_version": 2, } - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -func _initialize() -> void: - print("===========================================") - print(" Windowstead Save Backup & Validation Tests") - print("===========================================") - - flow_backup_creates_file() - flow_restore_from_backup() - 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]) - print("===========================================") - - for f in failures: - print(" " + f) - - if tests_failed > 0: - print("\nBackup & validation tests FAILED") - quit(1) - else: - print("\nAll backup & validation tests passed") - quit(0) - - -func load_game_state() -> Node: - var gs_script := load("res://scripts/game_state.gd") - return gs_script.new() diff --git a/tests/test_tile_render.gd b/tests/test_tile_render.gd index a5653fd..7eb8009 100644 --- a/tests/test_tile_render.gd +++ b/tests/test_tile_render.gd @@ -4,55 +4,41 @@ ## Run: godot --headless --quit ## Or: godot --headless --main-pack windowstead.pck --script tests/test_tile_render.gd -extends SceneTree +extends "res://tests/test_case.gd" const TileRender := preload("res://scripts/tile_render.gd") const C := preload("res://scripts/constants.gd") -func _initialize() -> void: - var pass_count := 0 - var fail_count := 0 - var test_count := 0 - +func run_tests() -> void: # --- tile_style tests --- - test_count += 1; pass_count += test("tile_style returns StyleBoxFlat", _test_tile_style_returns_stylebox) - test_count += 1; pass_count += test("tile_style sets border color to accent", _test_tile_style_border_color) - test_count += 1; pass_count += test("tile_style uses TILE_BACKDROPS for bg", _test_tile_style_bg_from_backdrops) - test_count += 1; pass_count += test("tile_style stockpile override", _test_tile_style_stockpile_override) - test_count += 1; pass_count += test("tile_style unknown kind default backdrop", _test_tile_style_unknown_kind) - test_count += 1; pass_count += test("tile_style corner radius is 8", _test_tile_style_corner_radius) - test_count += 1; pass_count += test("tile_style shadow color and size", _test_tile_style_shadow) + check("tile_style returns StyleBoxFlat", _test_tile_style_returns_stylebox) + check("tile_style sets border color to accent", _test_tile_style_border_color) + check("tile_style uses TILE_BACKDROPS for bg", _test_tile_style_bg_from_backdrops) + check("tile_style stockpile override", _test_tile_style_stockpile_override) + check("tile_style unknown kind default backdrop", _test_tile_style_unknown_kind) + check("tile_style corner radius is 8", _test_tile_style_corner_radius) + check("tile_style shadow color and size", _test_tile_style_shadow) # --- tile_accent tests --- - test_count += 1; pass_count += test("tile_accent build placement valid (green)", _test_accent_build_valid) - test_count += 1; pass_count += test("tile_accent build placement invalid (red)", _test_accent_build_invalid) - test_count += 1; pass_count += test("tile_accent no build placement default", _test_accent_no_build) - test_count += 1; pass_count += test("tile_accent stockpile gold", _test_accent_stockpile) - test_count += 1; pass_count += test("tile_accent resource color", _test_accent_resource_color) - test_count += 1; pass_count += test("tile_accent structure color", _test_accent_structure_color) - test_count += 1; pass_count += test("tile_accent foundation accent", _test_accent_foundation) - test_count += 1; pass_count += test("tile_accent hover mismatch no highlight", _test_accent_hover_mismatch) - test_count += 1; pass_count += test("tile_accent empty context default", _test_accent_empty_context) + check("tile_accent build placement valid (green)", _test_accent_build_valid) + check("tile_accent build placement invalid (red)", _test_accent_build_invalid) + check("tile_accent no build placement default", _test_accent_no_build) + check("tile_accent stockpile gold", _test_accent_stockpile) + check("tile_accent resource color", _test_accent_resource_color) + check("tile_accent structure color", _test_accent_structure_color) + check("tile_accent foundation accent", _test_accent_foundation) + check("tile_accent hover mismatch no highlight", _test_accent_hover_mismatch) + check("tile_accent empty context default", _test_accent_empty_context) # --- Integration with constants --- - test_count += 1; pass_count += test("tile_accent uses real RESOURCE_COLORS", _test_accent_real_resource_colors) - test_count += 1; pass_count += test("tile_accent uses real STRUCTURE_COLORS", _test_accent_real_structure_colors) - - fail_count = test_count - pass_count - print("\n=== TileRender Regression Tests ===") - print("Passed: %d" % pass_count) - print("Failed: %d" % fail_count) - - if fail_count > 0: - print("REGRESSION FAILURES DETECTED") - quit(1) - else: - print("All tile_render tests passed.") - quit(0) + check("tile_accent uses real RESOURCE_COLORS", _test_accent_real_resource_colors) + check("tile_accent uses real STRUCTURE_COLORS", _test_accent_real_structure_colors) -func test(name: String, fn: Callable) -> int: +## Runs a check callable and reports it through the shared assertion API. +## The callable may return a bool or a {ok, msg} Dictionary. +func check(name: String, fn: Callable) -> void: var ok := true var error_msg := "" var result: Variant = fn.call() @@ -63,12 +49,7 @@ func test(name: String, fn: Callable) -> int: ok = false error_msg = "returned false" - if ok: - print(" ✓ %s" % name) - return 1 - else: - print(" ✗ %s: %s" % [name, error_msg]) - return 0 + assert_true(ok, name, error_msg) # --- tile_style tests --- diff --git a/tests/test_worker_cap.gd b/tests/test_worker_cap.gd index 113f23f..48919d0 100644 --- a/tests/test_worker_cap.gd +++ b/tests/test_worker_cap.gd @@ -1,15 +1,14 @@ +extends "res://tests/test_case.gd" + ## Tests for worker cap calculation (issue #146). ## Verifies: base cap, hut bonus, multiple huts, no structures, unknown structure types. -extends SceneTree - -var test_pass := 0 -var test_fail := 0 -func _initialize() -> void: - # Load main.gd and create an instance (no UI nodes needed for logic tests) - var main_script: GDScript = preload("res://scripts/main.gd") - var main: Control = main_script.new() +func run_tests() -> void: + # main.gd references the GameState autoload, so it must be load()ed at + # runtime — preload() compiles before autoloads are registered in --script mode. + var main_script: GDScript = load("res://scripts/main.gd") + var main = main_script.new() test_base_cap_no_structures(main) test_one_hut_bonus(main) @@ -18,54 +17,30 @@ func _initialize() -> void: test_mixed_structures(main) test_incomplete_builds_do_not_count(main) - # Summary - print("") - print("=== test_worker_cap summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED") - quit(1) - else: - print("test_worker_cap: ok") - quit(0) - - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) + main.free() # ── Test 1: Base cap with no structures ── -func test_base_cap_no_structures(main: Control) -> void: +func test_base_cap_no_structures(main) -> void: print("") print("--- base cap ---") _setup_state(main, []) - _assert_eq(main.get_worker_cap(), 2, "base_cap: returns BASE_WORKER_CAP (2) with no builds") + assert_eq(main.get_worker_cap(), 2, "base_cap: returns BASE_WORKER_CAP (2) with no builds") # ── Test 2: One completed hut adds bonus ── -func test_one_hut_bonus(main: Control) -> void: +func test_one_hut_bonus(main) -> void: print("") print("--- one hut bonus ---") var builds = [ {"id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, "complete": true, "delivered": {"wood": 6, "stone": 2}, "progress": 1.0}, ] _setup_state(main, builds) - _assert_eq(main.get_worker_cap(), 4, "one_hut: base(2) + hut_bonus(2) = 4") + assert_eq(main.get_worker_cap(), 4, "one_hut: base(2) + hut_bonus(2) = 4") # ── Test 3: Multiple huts stack ── -func test_multiple_huts_add_up(main: Control) -> void: +func test_multiple_huts_add_up(main) -> void: print("") print("--- multiple huts ---") var builds = [ @@ -73,22 +48,22 @@ func test_multiple_huts_add_up(main: Control) -> void: {"id": 2, "kind": "hut", "pos": {"x": 3, "y": 2}, "complete": true, "delivered": {"wood": 6, "stone": 2}, "progress": 1.0}, ] _setup_state(main, builds) - _assert_eq(main.get_worker_cap(), 6, "multi_hut: base(2) + 2*hut_bonus(2) = 6") + assert_eq(main.get_worker_cap(), 6, "multi_hut: base(2) + 2*hut_bonus(2) = 6") # ── Test 4: Workshop does not increase cap ── -func test_workshop_does_not_increase_cap(main: Control) -> void: +func test_workshop_does_not_increase_cap(main) -> void: print("") print("--- workshop no bonus ---") var builds = [ {"id": 1, "kind": "workshop", "pos": {"x": 2, "y": 2}, "complete": true, "delivered": {"wood": 4, "stone": 6}, "progress": 1.0}, ] _setup_state(main, builds) - _assert_eq(main.get_worker_cap(), 2, "workshop: no bonus for workshop, stays at base(2)") + assert_eq(main.get_worker_cap(), 2, "workshop: no bonus for workshop, stays at base(2)") # ── Test 5: Mixed structures ── -func test_mixed_structures(main: Control) -> void: +func test_mixed_structures(main) -> void: print("") print("--- mixed structures ---") var builds = [ @@ -96,22 +71,22 @@ func test_mixed_structures(main: Control) -> void: {"id": 2, "kind": "workshop", "pos": {"x": 3, "y": 2}, "complete": true, "delivered": {"wood": 4, "stone": 6}, "progress": 1.0}, ] _setup_state(main, builds) - _assert_eq(main.get_worker_cap(), 4, "mixed: base(2) + hut_bonus(2) = 4 (workshop adds nothing)") + assert_eq(main.get_worker_cap(), 4, "mixed: base(2) + hut_bonus(2) = 4 (workshop adds nothing)") # ── Test 6: Incomplete builds don't count ── -func test_incomplete_builds_do_not_count(main: Control) -> void: +func test_incomplete_builds_do_not_count(main) -> void: print("") print("--- incomplete builds ---") var builds = [ {"id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, "complete": false, "delivered": {"wood": 3, "stone": 1}, "progress": 0.5}, ] _setup_state(main, builds) - _assert_eq(main.get_worker_cap(), 2, "incomplete: incomplete hut doesn't count, stays at base(2)") + assert_eq(main.get_worker_cap(), 2, "incomplete: incomplete hut doesn't count, stays at base(2)") # ── Helper ── -func _setup_state(main: Control, builds: Array) -> void: +func _setup_state(main, builds: Array) -> void: main.state = { "tick": 0, "resources": {"wood": 8, "stone": 4, "food": 2}, diff --git a/tests/test_worker_intent.gd b/tests/test_worker_intent.gd index eee450d..1ceb4e1 100644 --- a/tests/test_worker_intent.gd +++ b/tests/test_worker_intent.gd @@ -1,14 +1,14 @@ +extends "res://tests/test_case.gd" + ## Tests for worker intent icons and text (issue #136). ## Verifies: icon/text mapping for all task kinds, idle reasons, break state. -extends SceneTree - -var test_pass := 0 -var test_fail := 0 -func _initialize() -> void: - var main_script: GDScript = preload("res://scripts/main.gd") - var main: Control = main_script.new() +func run_tests() -> void: + # main.gd references the GameState autoload, so it must be load()ed at + # runtime — preload() compiles before autoloads are registered in --script mode. + var main_script: GDScript = load("res://scripts/main.gd") + var main = main_script.new() test_worker_intent_icon_gather_wood(main) test_worker_intent_icon_gather_stone(main) @@ -30,107 +30,84 @@ func _initialize() -> void: test_worker_idle_reason_stockpile_full(main) test_worker_intent_icon_build_id_fallback(main) - print("") - print("=== test_worker_intent summary: %d passed, %d failed ===" % [test_pass, test_fail]) - if test_fail > 0: - print("FAILURES DETECTED") - quit(1) - else: - print("test_worker_intent: ok") - quit(0) - - -func _assert(condition: Variant, name: String, detail: String = "") -> void: - if not condition: - test_fail += 1 - if not detail.is_empty(): - print("TEST %s: FAIL — %s" % [name, detail]) - else: - print("TEST %s: FAIL" % name) - else: - test_pass += 1 - print("TEST %s: PASS" % name) - - -func _assert_eq(actual: Variant, expected: Variant, name: String) -> void: - _assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)]) + main.free() # ── Icon Tests ─────────────────────────────────────────────────────────────── -func test_worker_intent_icon_gather_wood(main: Control) -> void: +func test_worker_intent_icon_gather_wood(main) -> void: print("") print("--- icon: gather wood ---") var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "wood"}, "break_ticks": 0} - _assert_eq(main.worker_intent_icon(worker), "🪓", "gather wood icon is 🪓") + assert_eq(main.worker_intent_icon(worker), "🪓", "gather wood icon is 🪓") -func test_worker_intent_icon_gather_stone(main: Control) -> void: +func test_worker_intent_icon_gather_stone(main) -> void: print("") print("--- icon: gather stone ---") var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "stone"}, "break_ticks": 0} - _assert_eq(main.worker_intent_icon(worker), "⛏", "gather stone icon is ⛏") + assert_eq(main.worker_intent_icon(worker), "⛏", "gather stone icon is ⛏") -func test_worker_intent_icon_gather_food(main: Control) -> void: +func test_worker_intent_icon_gather_food(main) -> void: print("") print("--- icon: gather food ---") var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "food"}, "break_ticks": 0} - _assert_eq(main.worker_intent_icon(worker), "🫐", "gather food icon is 🫐") + assert_eq(main.worker_intent_icon(worker), "🫐", "gather food icon is 🫐") -func test_worker_intent_icon_haul(main: Control) -> void: +func test_worker_intent_icon_haul(main) -> void: print("") print("--- icon: haul ---") var worker := {"name": "Jun", "task": {"kind": "haul", "resource": "wood"}, "break_ticks": 0} - _assert_eq(main.worker_intent_icon(worker), "📦", "haul icon is 📦") + assert_eq(main.worker_intent_icon(worker), "📦", "haul icon is 📦") -func test_worker_intent_icon_build_hut(main: Control) -> void: +func test_worker_intent_icon_build_hut(main) -> void: print("") print("--- icon: build hut ---") var worker := {"name": "Jun", "task": {"kind": "build", "build_kind": "hut"}, "break_ticks": 0} - _assert_eq(main.worker_intent_icon(worker), "🏗", "build hut icon is 🏗") + assert_eq(main.worker_intent_icon(worker), "🏗", "build hut icon is 🏗") -func test_worker_intent_icon_idle(main: Control) -> void: +func test_worker_intent_icon_idle(main) -> void: print("") print("--- icon: idle ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 0} - _assert_eq(main.worker_intent_icon(worker), "💤", "idle icon is 💤") + assert_eq(main.worker_intent_icon(worker), "💤", "idle icon is 💤") -func test_worker_intent_icon_break(main: Control) -> void: +func test_worker_intent_icon_break(main) -> void: print("") print("--- icon: break ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 5} - _assert_eq(main.worker_intent_icon(worker), "☕", "break icon is ☕") + assert_eq(main.worker_intent_icon(worker), "☕", "break icon is ☕") # ── Text Tests ─────────────────────────────────────────────────────────────── -func test_worker_intent_text_gather_wood(main: Control) -> void: +func test_worker_intent_text_gather_wood(main) -> void: print("") print("--- text: gather wood ---") var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "wood"}, "break_ticks": 0} - _assert_eq(main.worker_intent_text(worker), "gathering wood", "gather wood text is correct") + assert_eq(main.worker_intent_text(worker), "gathering wood", "gather wood text is correct") -func test_worker_intent_text_gather_stone(main: Control) -> void: +func test_worker_intent_text_gather_stone(main) -> void: print("") print("--- text: gather stone ---") var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "stone"}, "break_ticks": 0} - _assert_eq(main.worker_intent_text(worker), "gathering stone", "gather stone text is correct") + assert_eq(main.worker_intent_text(worker), "gathering stone", "gather stone text is correct") -func test_worker_intent_text_gather_food(main: Control) -> void: +func test_worker_intent_text_gather_food(main) -> void: print("") print("--- text: gather food ---") var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "food"}, "break_ticks": 0} - _assert_eq(main.worker_intent_text(worker), "gathering food", "gather food text is correct") + assert_eq(main.worker_intent_text(worker), "gathering food", "gather food text is correct") -func test_worker_intent_text_haul_to_build(main: Control) -> void: +func test_worker_intent_text_haul_to_build(main) -> void: print("") print("--- text: haul to build ---") var worker := {"name": "Jun", "task": {"kind": "haul", "resource": "wood", "build_id": 1}, "break_ticks": 0} @@ -138,57 +115,59 @@ func test_worker_intent_text_haul_to_build(main: Control) -> void: "builds": [{"id": 1, "kind": "hut", "complete": false}], "workers": [worker], } - var text := main.worker_intent_text(worker) - _assert("hauling wood to hut" in text, "haul to build includes build kind") + var text: String = main.worker_intent_text(worker) + assert_true("hauling wood to hut" in text, "haul to build includes build kind") -func test_worker_intent_text_haul_to_stockpile(main: Control) -> void: +func test_worker_intent_text_haul_to_stockpile(main) -> void: print("") print("--- text: haul to stockpile ---") var worker := {"name": "Jun", "task": {"kind": "haul", "resource": "stone"}, "break_ticks": 0} - _assert_eq(main.worker_intent_text(worker), "hauling stone", "haul to stockpile text is correct") + assert_eq(main.worker_intent_text(worker), "hauling stone", "haul to stockpile text is correct") -func test_worker_intent_text_build_hut(main: Control) -> void: +func test_worker_intent_text_build_hut(main) -> void: print("") print("--- text: build hut ---") var worker := {"name": "Jun", "task": {"kind": "build", "build_kind": "hut"}, "break_ticks": 0} - _assert_eq(main.worker_intent_text(worker), "building hut", "build hut text is correct") + assert_eq(main.worker_intent_text(worker), "building hut", "build hut text is correct") -func test_worker_intent_text_idle_no_task(main: Control) -> void: +func test_worker_intent_text_idle_no_task(main) -> void: print("") print("--- text: idle no task ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 0} + # food must be above LOW_FOOD_THRESHOLD (3) or the food-priority idle reason wins. main.state = { "builds": [], - "resources": {"wood": 0, "stone": 0, "food": 2}, + "resources": {"wood": 0, "stone": 0, "food": 5}, } - var text := main.worker_intent_text(worker) - _assert("No valid task" in text, "idle with no builds shows 'No valid task'") + var text: String = main.worker_intent_text(worker) + assert_true("No valid task" in text, "idle with no builds shows 'No valid task'") -func test_worker_intent_text_break(main: Control) -> void: +func test_worker_intent_text_break(main) -> void: print("") print("--- text: break ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 3} - _assert_eq(main.worker_intent_text(worker), "on break", "break text is 'on break'") + assert_eq(main.worker_intent_text(worker), "on break", "break text is 'on break'") # ── Idle Reason Tests ──────────────────────────────────────────────────────── -func test_worker_idle_reason_no_task(main: Control) -> void: +func test_worker_idle_reason_no_task(main) -> void: print("") print("--- idle reason: no task ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 0} + # food must be above LOW_FOOD_THRESHOLD (3) or the food-priority idle reason wins. main.state = { "builds": [], - "resources": {"wood": 0, "stone": 0, "food": 2}, + "resources": {"wood": 0, "stone": 0, "food": 5}, } - _assert_eq(main.worker_idle_reason(worker), "idle_no_task", "no builds → idle_no_task") + assert_eq(main.worker_idle_reason(worker), "idle_no_task", "no builds → idle_no_task") -func test_worker_idle_reason_food_priority(main: Control) -> void: +func test_worker_idle_reason_food_priority(main) -> void: print("") print("--- idle reason: food priority ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 0} @@ -197,23 +176,23 @@ func test_worker_idle_reason_food_priority(main: Control) -> void: "resources": {"wood": 1, "stone": 1, "food": 2}, } # food=2 <= LOW_FOOD_THRESHOLD (3), so should_bias_to_food_gathering() → true - _assert_eq(main.worker_idle_reason(worker), "idle_food_priority", "low food → idle_food_priority") + assert_eq(main.worker_idle_reason(worker), "idle_food_priority", "low food → idle_food_priority") -func test_worker_idle_reason_stockpile_full(main: Control) -> void: +func test_worker_idle_reason_stockpile_full(main) -> void: print("") print("--- idle reason: stockpile full ---") var worker := {"name": "Jun", "task": {}, "break_ticks": 0} main.state = { - "builds": [{"id": 1, "kind": "hut", "complete": false}], + "builds": [{"id": 1, "kind": "hut", "complete": false, "delivered": {}}], "resources": {"wood": 100, "stone": 100}, } # Build needs wood+stone, both are available and delivered=0, so has_pending_haul=true # All costs resources > 0 → stockpile_full=true - _assert_eq(main.worker_idle_reason(worker), "idle_stockpile_full", "build waiting for resources with full stockpile → idle_stockpile_full") + assert_eq(main.worker_idle_reason(worker), "idle_stockpile_full", "build waiting for resources with full stockpile → idle_stockpile_full") -func test_worker_intent_icon_build_id_fallback(main: Control) -> void: +func test_worker_intent_icon_build_id_fallback(main) -> void: print("") print("--- icon: build_id fallback ---") var worker := {"name": "Jun", "task": {"kind": "build", "build_id": 1}, "break_ticks": 0} @@ -221,4 +200,4 @@ func test_worker_intent_icon_build_id_fallback(main: Control) -> void: "builds": [{"id": 1, "kind": "hut", "complete": false}], "workers": [worker], } - _assert_eq(main.worker_intent_icon(worker), "🏗", "build_id fallback resolves to hut icon") + assert_eq(main.worker_intent_icon(worker), "🏗", "build_id fallback resolves to hut icon")