From 9a658bd49769cb6bd9737360851d19cd6a9af6cf Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 15 Jul 2026 20:33:10 -0600 Subject: [PATCH 1/4] Run every test suite in CI and locally via just test-all Replaces the four hand-listed suite steps (linux + macOS jobs) with a loop over tests/test_*.gd so new suites are gated automatically. The TestCase base now fails a run in which zero assertions executed, so a suite whose preloads fail to compile can't exit 0 silently. --- .github/workflows/test.yml | 120 ++++++++++++------------------------- .justfile | 13 +++- smoke.log | 3 - smoke_ci.log | 3 - tests/test_case.gd | 7 ++- 5 files changed, 55 insertions(+), 91 deletions(-) delete mode 100644 smoke.log delete mode 100644 smoke_ci.log diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3fb8171..cef3771 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,57 +69,28 @@ jobs: unzip -oq /tmp/godot_linux.zip -d .tools/ chmod +x .tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 - - name: Run script tests + - name: Run all test suites shell: bash run: | set -euo pipefail - ./.tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 --headless --path . --script res://tests/test_runner.gd > tests.log 2>&1 - TEST_EXIT=$? - cat tests.log - # Print structured summary for CI - if [ $TEST_EXIT -ne 0 ]; then - echo "::error::Script tests failed (exit code $TEST_EXIT)" - exit 1 - fi - PASS_COUNT=$(grep -c ": PASS" tests.log || true) - FAIL_COUNT=$(grep -c ": FAIL" tests.log || true) - echo "test_runner: $PASS_COUNT passed, $FAIL_COUNT failed" - - - name: Run E2E gameplay flow tests - shell: bash - run: | - set -euo pipefail - ./.tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 --headless --path . --script res://tests/test_e2e.gd > e2e-tests.log 2>&1 - E2E_EXIT=$? - cat e2e-tests.log - if [ $E2E_EXIT -ne 0 ]; then - echo "::error::E2E tests failed (exit code $E2E_EXIT)" - exit 1 - fi - - - name: Run layout regression tests - shell: bash - run: | - set -euo pipefail - ./.tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 --headless --path . --script res://tests/test_layout_math.gd > layout-tests.log 2>&1 - LAYOUT_EXIT=$? - cat layout-tests.log - if [ $LAYOUT_EXIT -ne 0 ]; then - echo "::error::Layout regression tests failed (exit code $LAYOUT_EXIT)" - exit 1 - fi - - - name: Run reservation tests (issue #143) - shell: bash - run: | - set -euo pipefail - ./.tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 --headless --path . --script res://tests/test_reservations.gd > reservation-tests.log 2>&1 - RES_EXIT=$? - cat reservation-tests.log - if [ $RES_EXIT -ne 0 ]; then - echo "::error::Reservation tests failed (exit code $RES_EXIT)" - exit 1 - fi + GODOT=./.tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 + FAILED=0 + for suite in tests/test_*.gd; do + name=$(basename "$suite" .gd) + if [ "$name" = "test_case" ]; then + continue + fi + log="${name}.log" + if "$GODOT" --headless --path . --script "res://tests/${name}.gd" > "$log" 2>&1; then + PASS_COUNT=$(grep -c ": PASS" "$log" || true) + echo "${name}: ${PASS_COUNT} passed" + else + echo "::error::${name} failed" + cat "$log" + FAILED=1 + fi + done + exit $FAILED macos-validation: name: macOS validation @@ -186,44 +157,27 @@ jobs: echo "Smoke test exited with code $SMOKE_EXIT on macOS runner; keeping script tests as the hard validation gate for this job." >&2 fi - - name: Run script tests - shell: bash - run: | - set -euo pipefail - "$GODOT_MAC_APP/Contents/MacOS/Godot" --headless --path . --script res://tests/test_runner.gd > tests.log 2>&1 - TEST_EXIT=$? - cat tests.log - if [ $TEST_EXIT -ne 0 ]; then - echo "::error::Script tests failed (exit code $TEST_EXIT)" - exit 1 - fi - PASS_COUNT=$(grep -c ": PASS" tests.log || true) - FAIL_COUNT=$(grep -c ": FAIL" tests.log || true) - echo "test_runner: $PASS_COUNT passed, $FAIL_COUNT failed" - - - name: Run E2E gameplay flow tests + - name: Run all test suites shell: bash run: | set -euo pipefail - "$GODOT_MAC_APP/Contents/MacOS/Godot" --headless --path . --script res://tests/test_e2e.gd > e2e-tests.log 2>&1 - E2E_EXIT=$? - cat e2e-tests.log - if [ $E2E_EXIT -ne 0 ]; then - echo "::error::E2E tests failed (exit code $E2E_EXIT)" - exit 1 - fi - - - name: Run layout regression tests - shell: bash - run: | - set -euo pipefail - "$GODOT_MAC_APP/Contents/MacOS/Godot" --headless --path . --script res://tests/test_layout_math.gd > layout-tests.log 2>&1 - LAYOUT_EXIT=$? - cat layout-tests.log - if [ $LAYOUT_EXIT -ne 0 ]; then - echo "::error::Layout regression tests failed (exit code $LAYOUT_EXIT)" - exit 1 - fi + FAILED=0 + for suite in tests/test_*.gd; do + name=$(basename "$suite" .gd) + if [ "$name" = "test_case" ]; then + continue + fi + log="${name}.log" + if "$GODOT_MAC_APP/Contents/MacOS/Godot" --headless --path . --script "res://tests/${name}.gd" > "$log" 2>&1; then + PASS_COUNT=$(grep -c ": PASS" "$log" || true) + echo "${name}: ${PASS_COUNT} passed" + else + echo "::error::${name} failed" + cat "$log" + FAILED=1 + fi + done + exit $FAILED export-validation: name: Export validation (Linux) diff --git a/.justfile b/.justfile index 2f25994..02e3cb8 100644 --- a/.justfile +++ b/.justfile @@ -25,8 +25,19 @@ test-layout: test-e2e: '{{ godot }}' --headless --path '{{ justfile_dir() }}' --script res://tests/test_e2e.gd +[doc('Run every test suite in tests/ (same matrix as CI)')] +test-all: + FAILED=0; \ + for suite in '{{ justfile_dir() }}'/tests/test_*.gd; do \ + name=$(basename "$suite" .gd); \ + if [ "$name" = "test_case" ]; then continue; fi; \ + echo "== $name"; \ + '{{ godot }}' --headless --path '{{ justfile_dir() }}' --script "res://tests/$name.gd" || FAILED=1; \ + done; \ + exit $FAILED + [doc('Run all local validation checks (canonical matrix)')] -validate: test test-layout test-e2e +validate: test-all [doc('Export a local macOS app bundle')] build-macos: diff --git a/smoke.log b/smoke.log deleted file mode 100644 index 9c1ff46..0000000 --- a/smoke.log +++ /dev/null @@ -1,3 +0,0 @@ -Fontconfig error: Cannot load default config file: No such file: (null) -Godot Engine v4.2.2.stable.official.15073afe3 - https://godotengine.org - diff --git a/smoke_ci.log b/smoke_ci.log deleted file mode 100644 index 656d657..0000000 --- a/smoke_ci.log +++ /dev/null @@ -1,3 +0,0 @@ -libfontconfig.so.1: cannot open shared object file: No such file or directory -Godot Engine v4.2.2.stable.official.15073afe3 - https://godotengine.org - diff --git a/tests/test_case.gd b/tests/test_case.gd index 8ca0798..34cbc8d 100644 --- a/tests/test_case.gd +++ b/tests/test_case.gd @@ -39,7 +39,12 @@ func run_tests() -> void: func _finish() -> void: print("") print("=== %s summary: %d passed, %d failed ===" % [suite_name, test_pass, test_fail]) - if test_fail > 0: + if test_pass == 0 and test_fail == 0: + # Zero assertions means something upstream broke (a preload failed to + # compile, run_tests aborted) — never report that as success. + print("TEST %s: FAIL — no assertions ran" % suite_name) + quit(1) + elif test_fail > 0: print("FAILURES DETECTED — CI should fail") quit(1) else: From 148e92c42688a8511d8b2692d15ed18dcf7bfca3 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 15 Jul 2026 20:33:11 -0600 Subject: [PATCH 2/4] Add full-cycle tick integration test (closes #234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives ColonySim.process_tick() directly — no scene, UI, or autoloads — covering worker movement, gather/haul cycles, food upkeep cadence, goal completion/rotation, the dirty-flag contract, starvation build stalls, idle behavior, and build completion with milestone advance. Requires the sim to be autoload-free: the reservation rebuild is now a static ColonySim function that GameState delegates to, instead of the sim reaching into the GameState autoload. Also flattens do_haul into _deliver_carried/_pick_up_for_build now that the path is covered. --- scripts/colony_sim.gd | 118 +++++++++++-------- scripts/game_state.gd | 25 +--- tests/test_tick_integration.gd | 202 +++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 67 deletions(-) create mode 100644 tests/test_tick_integration.gd diff --git a/scripts/colony_sim.gd b/scripts/colony_sim.gd index cd3c940..0884ef0 100644 --- a/scripts/colony_sim.gd +++ b/scripts/colony_sim.gd @@ -410,55 +410,60 @@ 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 = {} + _deliver_carried(worker, task, resource, carried) 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 = {} + _pick_up_for_build(worker, task, resource) return worker.task = {} +## Deposit whatever the worker carries: into the target build (clamped to its +## remaining need, excess refunded) or into the stockpile when there is no +## live build target. +func _deliver_carried(worker: Dictionary, task: Dictionary, resource: String, carried: int) -> void: + var build: Dictionary = get_build(int(task.build_id)) if int(task.build_id) >= 0 else {} + if build.is_empty() or bool(build.complete): + state.resources[resource] = int(state.resources.get(resource, 0)) + carried + else: + # 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, maxi(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 + # Release reservation for the delivered amount + if deliver > 0: + build["reserved"] = build.get("reserved", {}) + build.reserved[resource] = maxi(reserved - deliver, 0) + set_build(int(task.build_id), build) + mark_dirty() + worker.carrying[resource] = 0 + worker.task = {} + + +## Take one unit from the stockpile, reserve it for the build, and head there. +func _pick_up_for_build(worker: Dictionary, task: Dictionary, resource: String) -> void: + var build := get_build(int(task.build_id)) + if build.is_empty() or bool(build.complete): + # Build gone or complete — clear task, resource stays in stockpile + worker.task = {} + return + state.resources[resource] = int(state.resources.get(resource, 0)) - 1 + worker.carrying[resource] = 1 + if not build.has("reserved"): + build["reserved"] = {} + build.reserved[resource] = int(build.reserved.get(resource, 0)) + 1 + mark_dirty() + set_build(int(task.build_id), build) + worker.task.target = build.pos + + func do_build(worker: Dictionary, task: Dictionary) -> void: var build := get_build(int(task.build_id)) if build.is_empty() or bool(build.complete): @@ -539,11 +544,30 @@ func get_reserved(resource: String) -> int: 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. +## Rebuild reserved_resources from active worker tasks. The single +## implementation both GameState (load path, trusts existing reservations) +## and the runtime (force rebuild) delegate to — static and autoload-free so +## the sim stays testable in --script mode. +static func rebuild_reservations_from_workers(target_state: Dictionary, trust_existing := true) -> void: + if trust_existing: + var existing: Dictionary = target_state.get("reserved_resources", {}) + if not existing.is_empty(): + return # Already has reservations — trust them + target_state["reserved_resources"] = {} + for worker in target_state.get("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(): + target_state["reserved_resources"][resource] = target_state["reserved_resources"].get(resource, 0) + 1 + + +## Force a rebuild after a load, discarding whatever was saved. func rebuild_reservations() -> void: - state["reserved_resources"] = {} - GameState.rebuild_reservations_from_workers(state) + rebuild_reservations_from_workers(state, false) func _clean_stale_reservations() -> void: diff --git a/scripts/game_state.gd b/scripts/game_state.gd index 11713ea..c46583f 100644 --- a/scripts/game_state.gd +++ b/scripts/game_state.gd @@ -4,6 +4,7 @@ const LayoutMath := preload("res://scripts/layout_math.gd") const RotatingGoal := preload("res://scripts/rotating_goal.gd") const ColonyStance := preload("res://scripts/colony_stance.gd") const GoalReward := preload("res://scripts/goal_reward.gd") +const ColonySim := preload("res://scripts/colony_sim.gd") const SAVE_KEY := "windowstead-save-v2" const BACKUP_PREFIX := "windowstead-backup-" @@ -75,24 +76,11 @@ func load_game(path: String = "") -> Dictionary: # ── Rebuild reserved_resources from active worker tasks ────────────────────── # Called after load/migration to prevent double-booking when reservations are -# missing or stale. Only rebuilds when the field is empty (missing from old saves). +# missing or stale. Only rebuilds when the field is empty (missing from old +# saves). The implementation lives in ColonySim so it exists exactly once. func rebuild_reservations_from_workers(state: Dictionary) -> void: - var existing: Dictionary = state.get("reserved_resources", {}) - if not existing.is_empty(): - return # Already has reservations — trust them - - state["reserved_resources"] = {} - var workers: Array = state.get("workers", []) - for worker in workers: - var task: Dictionary = worker.get("task", {}) - if task.is_empty(): - continue - var kind: String = task.get("kind", "") - if kind == "gather" or kind == "haul": - var resource: String = task.get("resource", "") - if not resource.is_empty(): - state["reserved_resources"][resource] = state["reserved_resources"].get(resource, 0) + 1 + ColonySim.rebuild_reservations_from_workers(state) # ── Schema validation ──────────────────────────────────────────────────────── # Returns {valid: bool, reason: String} @@ -300,10 +288,7 @@ func validate_save_schema(data: Dictionary) -> Dictionary: # remain loadable until they are migrated. func _expected_grid_sizes() -> Array: var sizes: Array = [] - # Mirror the canonical anchor families in layout_math.gd; new anchors added - # there will need to be appended here as well. - var anchors: Array = ["bottom", "side"] - for anchor in anchors: + for anchor in LayoutMath.ALL_ANCHOR_FAMILIES: var dims = LayoutMath.grid_dims_for_anchor(anchor) if typeof(dims) != TYPE_DICTIONARY or dims.is_empty(): continue diff --git a/tests/test_tick_integration.gd b/tests/test_tick_integration.gd new file mode 100644 index 0000000..522a81c --- /dev/null +++ b/tests/test_tick_integration.gd @@ -0,0 +1,202 @@ +extends "res://tests/test_case.gd" + +# ── Full-cycle tick integration tests (issue #234) ──────────────────────────── +# Drives ColonySim.process_tick() — the entire per-tick orchestration that +# main.gd's _on_tick delegates to — against a minimal real game state, with no +# scene tree, UI, or autoloads. Covers worker movement, gathering + hauling, +# food upkeep cadence, goal completion/rotation, the dirty flag contract, +# starvation stalls, idle behavior, and build completion. + +const ColonySim := preload("res://scripts/colony_sim.gd") +const Constants := preload("res://scripts/constants.gd") + +const GRID := 5 + + +func run_tests() -> void: + test_tick_advances_and_worker_moves() + test_gather_and_haul_cycle() + test_food_upkeep_interval() + test_goal_completion_and_rotation() + test_dirty_flag_contract() + test_starvation_stalls_builds() + test_idle_when_no_tasks() + test_build_completion() + + +# ── Setup helpers ───────────────────────────────────────────────────────────── + +func _new_sim(resources: Dictionary, worker_positions: Array) -> ColonySim: + var sim := ColonySim.new() + sim.grid_w = GRID + sim.grid_h = GRID + sim.stockpile_pos = Vector2i(0, 0) + sim.priority_order = ["build", "haul", "gather"] as Array[String] + sim.rng.seed = 1234 + var tiles: Array = [] + for i in GRID * GRID: + tiles.append({"kind": "ground", "amount": 0, "resource": "", "build_kind": ""}) + var workers: Array = [] + for i in worker_positions.size(): + var pos: Vector2i = worker_positions[i] + workers.append({ + "name": Constants.WORKER_NAMES[i], + "pos": {"x": pos.x, "y": pos.y}, + "prev_pos": {"x": pos.x, "y": pos.y}, + "carrying": {}, + "task": {}, + "break_ticks": 0, + }) + sim.state = { + "tick": 0, + "resources": resources, + "harvested": {"wood": 0, "stone": 0, "food": 0}, + "priority_order": ["build", "haul", "gather"], + "workers": workers, + "tiles": tiles, + "builds": [], + "next_build_id": 1, + "reserved_resources": {}, + "events": [], + } + sim.ensure_defaults() + return sim + + +func _run_ticks(sim: ColonySim, count: int) -> void: + for i in count: + sim.process_tick() + + +func _has_event_containing(sim: ColonySim, needle: String) -> bool: + for entry in sim.state.events: + if String(entry.text).contains(needle): + return true + return false + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +func test_tick_advances_and_worker_moves() -> void: + print("\n--- tick advances and worker moves toward resource ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(0, 0)]) + sim.set_tile(Vector2i(3, 0), {"kind": "tree", "amount": 2, "resource": "wood", "build_kind": ""}) + + sim.process_tick() + assert_eq(sim.tick(), 1, "tick: incremented to 1") + var worker: Dictionary = sim.state.workers[0] + assert_eq(String(worker.task.get("kind", "")), "gather", "tick: worker picked a gather task") + assert_eq(sim.get_reserved("wood"), 1, "tick: gather task reserved the resource") + assert_eq(ColonySim.data_to_vec(worker.pos), Vector2i(1, 0), "tick: worker stepped toward the tree") + assert_eq(ColonySim.data_to_vec(worker.prev_pos), Vector2i(0, 0), "tick: prev_pos captured for interpolation") + + sim.process_tick() + assert_eq(ColonySim.data_to_vec(sim.state.workers[0].pos), Vector2i(2, 0), "tick: worker keeps approaching") + + +func test_gather_and_haul_cycle() -> void: + print("\n--- gather and haul cycle deposits resources ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(0, 0)]) + sim.set_tile(Vector2i(3, 0), {"kind": "tree", "amount": 2, "resource": "wood", "build_kind": ""}) + + # 3 ticks out + gather + 3 ticks back + deposit, twice over ≈ 16 ticks. + _run_ticks(sim, 20) + assert_eq(int(sim.state.resources.get("wood", 0)), 2, "cycle: both wood units delivered to stockpile") + assert_eq(int(sim.state.harvested.get("wood", 0)), 2, "cycle: harvested tally recorded") + assert_eq(String(sim.get_tile(Vector2i(3, 0)).kind), "ground", "cycle: depleted tree reverted to ground") + assert_eq(sim.get_reserved("wood"), 0, "cycle: reservation released after depletion") + + +func test_food_upkeep_interval() -> void: + print("\n--- food upkeep fires on its interval ---") + # 3 workers = 1 above BASE_WORKERS_NO_UPKEEP; empty grid so nobody works. + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(0, 0), Vector2i(1, 1), Vector2i(2, 2)]) + + _run_ticks(sim, Constants.FOOD_UPKEEP_INTERVAL_TICKS - 1) + assert_eq(int(sim.state.resources.food), 10, "upkeep: no deduction before the interval") + + sim.process_tick() + assert_eq(int(sim.state.resources.food), 10 - Constants.FOOD_PER_EXTRA_WORKER, "upkeep: deducted exactly on the interval tick") + assert_true(_has_event_containing(sim, "The crew ate"), "upkeep: event logged") + + _run_ticks(sim, Constants.FOOD_UPKEEP_INTERVAL_TICKS) + assert_eq(int(sim.state.resources.food), 10 - 2 * Constants.FOOD_PER_EXTRA_WORKER, "upkeep: fires again next interval") + + +func test_goal_completion_and_rotation() -> void: + print("\n--- goal completes and rotates ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(0, 0)]) + assert_eq(String(sim.state.active_goal.get("id", "")), "gather_wood", "goal: first catalog goal seeded") + + # Satisfy the gather_wood target (10 harvested wood) out-of-band. + sim.state.harvested["wood"] = 10 + sim.process_tick() + + assert_true(sim.state.completed_goal_ids.has("gather_wood"), "goal: completed id recorded") + assert_eq(String(sim.state.active_goal.get("id", "")), "gather_stone", "goal: rotated to next catalog goal") + assert_true(_has_event_containing(sim, "Goal completed: gather_wood"), "goal: completion event logged") + assert_not_empty(sim.state.active_rewards, "goal: completion reward granted") + assert_eq(String(sim.state.active_rewards[0].get("label", "")), "+1 food trickle", "goal: reward label from GoalReward catalog") + + +func test_dirty_flag_contract() -> void: + print("\n--- dirty flag set by mutations, clearable by persist owner ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(0, 0)]) + sim.set_tile(Vector2i(3, 0), {"kind": "tree", "amount": 1, "resource": "wood", "build_kind": ""}) + sim.dirty = false + + # Movement alone is intentionally not a mutation point; the gather on the + # 4th tick (3 tiles of travel, then harvest) is what dirties the state. + _run_ticks(sim, 4) + assert_true(sim.dirty, "dirty: tick with a gather mutation marks state dirty") + + sim.dirty = false + var quiet := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(0, 0)]) + quiet.dirty = false + quiet.process_tick() + assert_false(quiet.dirty, "dirty: idle tick on an empty world stays clean") + + +func test_starvation_stalls_builds() -> void: + print("\n--- starvation stalls build progress ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 0}, [Vector2i(2, 2)]) + # Fully delivered hut ready to build, worker already standing on it. + sim.state.builds.append({ + "id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, + "delivered": {"wood": 6, "stone": 2}, "progress": 0.0, "complete": false, + }) + + _run_ticks(sim, 5) + var build: Dictionary = sim.get_build(1) + assert_eq(float(build.progress), 0.0, "starvation: build progress frozen at zero food") + assert_false(bool(build.complete), "starvation: build never completes") + + +func test_idle_when_no_tasks() -> void: + print("\n--- workers idle when no tasks exist ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(2, 2)]) + _run_ticks(sim, 3) + var worker: Dictionary = sim.state.workers[0] + assert_true(worker.task.is_empty(), "idle: worker has no task on an empty world") + assert_eq(ColonySim.data_to_vec(worker.pos), Vector2i(2, 2), "idle: worker does not wander") + + +func test_build_completion() -> void: + print("\n--- build completes, grants bonus, satisfies milestone ---") + var sim := _new_sim({"wood": 0, "stone": 0, "food": 10}, [Vector2i(2, 2)]) + sim.state.builds.append({ + "id": 1, "kind": "hut", "pos": {"x": 2, "y": 2}, + "delivered": {"wood": 6, "stone": 2}, "progress": 0.0, "complete": false, + }) + sim.set_tile(Vector2i(2, 2), {"kind": "foundation", "amount": 0, "resource": "", "build_kind": "hut"}) + + # Build speed is 0.34/tick at full food → complete on the 3rd work tick. + _run_ticks(sim, 6) + var build: Dictionary = sim.get_build(1) + assert_true(bool(build.complete), "build: hut completed") + assert_eq(String(sim.get_tile(Vector2i(2, 2)).kind), "hut", "build: tile converted to the structure") + assert_eq(int(sim.state.resources.food), 10 + int(ColonySim.BUILD_COMPLETION_FOOD["hut"]), "build: completion food bonus granted") + assert_true(_has_event_containing(sim, "finished"), "build: completion event logged") + # The build_hut milestone should have completed and advanced. + assert_true(sim.state.completed_milestone_ids.has("build_hut"), "build: hut milestone recorded") + assert_eq(String(sim.state.current_milestone_id), "stockpile_food", "build: milestone chain advanced") From d904585e8617880062d95c754b212ff437f5dbb9 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 15 Jul 2026 20:33:11 -0600 Subject: [PATCH 3/4] Finish deferred structure/altitude refactors - Popup visibility is a single MenuMode state machine (set_menu_mode); the four panel flags can no longer desync across open/close paths. - apply_anchor_layout's mirrored reparenting branches collapse onto an ordered (node, parent, index) table with a shared tail. - Structure/tile icons and short labels move to constants.gd tables; next_unlock_text walks BUILD_UNLOCKS instead of hardcoding the chain. - LayoutMath exports ALL_ANCHOR_FAMILIES and BOTTOM_DOCK_MIN_HEIGHT; save validation and main.gd consume them (removes the mirrored anchor list and the duplicated 460px minimum); drop dead tile_is_square. - main.gd uses Constants.X uniformly instead of local const re-aliases. --- scripts/constants.gd | 21 +++ scripts/layout_math.gd | 14 +- scripts/main.gd | 320 ++++++++++++++++------------------------- 3 files changed, 149 insertions(+), 206 deletions(-) diff --git a/scripts/constants.gd b/scripts/constants.gd index e09305e..8b9a96e 100644 --- a/scripts/constants.gd +++ b/scripts/constants.gd @@ -69,6 +69,27 @@ const BUILD_UNLOCKS := { "garden": "workshop", } +# Per-structure presentation: build/world icons and the short label shown on +# the tile. Adding a structure means adding a row here, not editing matches +# scattered through main.gd. +const STRUCTURE_ICONS := { + "hut": "🏠", + "workshop": "🛠", + "garden": "🪴", +} + +const TILE_ICONS := { + "tree": "🌲", + "rock": "🪨", + "berries": "🫐", +} + +const TILE_SHORT_LABELS := { + "hut": "hut", + "workshop": "shop", + "garden": "grow", +} + const BASE_WORKER_CAP := 2 const WORKER_CAP_BONUSES := { diff --git a/scripts/layout_math.gd b/scripts/layout_math.gd index 0628908..aa2a436 100644 --- a/scripts/layout_math.gd +++ b/scripts/layout_math.gd @@ -9,10 +9,17 @@ ## ## Usage: call functions directly; no Godot node dependency. +# Canonical anchor families. Everything that is not the bottom strip belongs +# to the "vertical" family; consumers (e.g. save validation) should iterate +# this list instead of hardcoding family names. +const ALL_ANCHOR_FAMILIES := ["bottom", "vertical"] + const SIDE_GRID_W := 10 const SIDE_GRID_H := 24 const BOTTOM_GRID_W := 32 const BOTTOM_GRID_H := 5 +# Minimum bottom-dock window height so the popup sidebar stays reachable. +const BOTTOM_DOCK_MIN_HEIGHT := 460 const TILE_GAP := 6 const TILE_SIZE_BUMP := 1.15 const BOTTOM_TILE_BASE_PX := 48.5 @@ -103,7 +110,7 @@ static func dock_size_for_anchor_tile_size(anchor_family: String, grid_w: int, g var world := world_pixel_size_for_tile_size(grid_w, grid_h, tile_size) var base := Vector2i(world.x + padding.x, world.y + padding.y) if anchor_family == "bottom" and include_sidebar: - base.y = maxi(base.y, 460) + base.y = maxi(base.y, BOTTOM_DOCK_MIN_HEIGHT) return base @@ -155,11 +162,6 @@ static func anchor_family_from_dock_anchor(dock_anchor: String) -> String: return "vertical" -## Verify that tile sizes are square (x == y). -static func tile_is_square(tile_px: int) -> bool: - return true # by construction, tile_px_for_anchor returns a single int - - ## Compute the stockpile position for a given anchor family. static func stockpile_pos_for_anchor(anchor_family: String) -> Vector2i: if anchor_family == "bottom": diff --git a/scripts/main.gd b/scripts/main.gd index 044ab7c..f1f3ded 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -1,22 +1,10 @@ extends Control const Constants := preload("res://scripts/constants.gd") -const WORKER_NAMES := Constants.WORKER_NAMES -const BASE_TICK_SECONDS := Constants.BASE_TICK_SECONDS -const EVENT_INTERVAL_TICKS := Constants.EVENT_INTERVAL_TICKS -const MAX_EVENT_LOG := Constants.MAX_EVENT_LOG -const RESOURCE_COLORS := Constants.RESOURCE_COLORS -const STRUCTURE_COLORS := Constants.STRUCTURE_COLORS -const TILE_BACKDROPS := Constants.TILE_BACKDROPS -const WORKER_BADGE_COLORS := Constants.WORKER_BADGE_COLORS -const BUILD_COSTS := Constants.BUILD_COSTS -const BUILD_EFFECTS := Constants.BUILD_EFFECTS const LayoutMath := preload("res://scripts/layout_math.gd") -const BUILD_UNLOCKS := Constants.BUILD_UNLOCKS 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 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") @@ -523,80 +511,62 @@ func apply_anchor_layout(dock_anchor: String) -> void: side_header_row.visible = false ensure_bottom_header() bottom_header_row.visible = true - if bottom_header_row.get_parent() != left_column: - bottom_header_row.get_parent().remove_child(bottom_header_row) - left_column.add_child(bottom_header_row) - if resource_label.get_parent() != bottom_header_row: - resource_label.get_parent().remove_child(resource_label) - bottom_header_row.add_child(resource_label) - if bottom_status_column.get_parent() != bottom_header_row: - bottom_status_column.get_parent().remove_child(bottom_status_column) - bottom_header_row.add_child(bottom_status_column) - if hud_row.get_parent() != bottom_button_row: - hud_row.get_parent().remove_child(hud_row) - bottom_button_row.add_child(hud_row) - if status_label.get_parent() != bottom_status_column: - status_label.get_parent().remove_child(status_label) - bottom_status_column.add_child(status_label) + # Ordered (node, parent, index) — the header tree for this anchor. + _apply_header_tree([ + [bottom_header_row, left_column, 0], + [resource_label, bottom_header_row, 0], + [bottom_status_column, bottom_header_row, 1], + [bottom_button_row, bottom_status_column, 0], + [status_label, bottom_status_column, 1], + [hud_row, bottom_button_row, 0], + ]) status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT status_label.autowrap_mode = TextServer.AUTOWRAP_OFF - status_label.clip_text = false - status_label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING - status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - resource_label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN menu_hint.size_flags_horizontal = Control.SIZE_SHRINK_END - left_column.move_child(bottom_header_row, 0) - bottom_header_row.move_child(resource_label, 0) - bottom_header_row.move_child(bottom_status_column, 1) - bottom_status_column.move_child(bottom_button_row, 0) - bottom_status_column.move_child(status_label, 1) - hud_row.move_child(menu_button, 0) - hud_row.move_child(build_mode_button, 1) - hud_row.move_child(menu_hint, 2) else: if bottom_header_row: bottom_header_row.visible = false ensure_side_header() side_header_row.visible = true - if side_header_row.get_parent() != left_column: - side_header_row.get_parent().remove_child(side_header_row) - left_column.add_child(side_header_row) - if resource_label.get_parent() != side_header_row: - resource_label.get_parent().remove_child(resource_label) - side_header_row.add_child(resource_label) - if side_status_column.get_parent() != side_header_row: - side_status_column.get_parent().remove_child(side_status_column) - side_header_row.add_child(side_status_column) - if side_button_row.get_parent() != side_header_row: - side_button_row.get_parent().remove_child(side_button_row) - side_header_row.add_child(side_button_row) - if hud_row.get_parent() != side_button_row: - hud_row.get_parent().remove_child(hud_row) - side_button_row.add_child(hud_row) - if status_label.get_parent() != side_status_column: - status_label.get_parent().remove_child(status_label) - side_status_column.add_child(status_label) - left_column.move_child(side_header_row, 0) - side_header_row.move_child(resource_label, 0) - side_header_row.move_child(side_button_row, 1) - side_header_row.move_child(side_status_column, 2) - side_status_column.move_child(status_label, 0) + _apply_header_tree([ + [side_header_row, left_column, 0], + [resource_label, side_header_row, 0], + [side_button_row, side_header_row, 1], + [side_status_column, side_header_row, 2], + [status_label, side_status_column, 0], + [hud_row, side_button_row, 0], + ]) status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - status_label.clip_text = false - status_label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING - status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - resource_label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN menu_hint.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - hud_row.move_child(menu_button, 0) - hud_row.move_child(build_mode_button, 1) - hud_row.move_child(menu_hint, 2) + # Shared per-anchor tail — identical in both modes. + status_label.clip_text = false + status_label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING + status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + resource_label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + hud_row.move_child(menu_button, 0) + hud_row.move_child(build_mode_button, 1) + 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 menu_hint: menu_hint.add_theme_font_size_override("font_size", 13) position_popup_panel(dock_anchor) + +## Reparent-and-order a header layout described as [node, parent, index] rows. +func _apply_header_tree(rows: Array) -> void: + for row in rows: + var node: Node = row[0] + var parent: Node = row[1] + if node.get_parent() != parent: + if node.get_parent(): + node.get_parent().remove_child(node) + parent.add_child(node) + for row in rows: + var node: Node = row[0] + var parent: Node = row[1] + parent.move_child(node, int(row[2])) func position_popup_panel(dock_anchor: String) -> void: var backdrop_size: Vector2 = get_node("Backdrop").size var popup_size: Vector2 = sidebar_scroll.custom_minimum_size @@ -608,7 +578,7 @@ func dock_size_for_anchor(dock_anchor: String) -> Vector2i: var size := LayoutMath.dock_size_for_anchor_tile_size(anchor_family, grid_w, grid_h, tile_size, sidebar_scroll.visible) if startup_panel and startup_panel.visible: size.x = maxi(size.x, int(startup_panel.custom_minimum_size.x) + 64) - size.y = maxi(size.y, 460) + size.y = maxi(size.y, LayoutMath.BOTTOM_DOCK_MIN_HEIGHT) return size func dock_position_for_anchor(usable_rect: Rect2i, dock_size: Vector2i, dock_anchor: String) -> Vector2i: @@ -764,9 +734,9 @@ func bootstrap_state() -> void: {"tick": 0, "text": "Start with a hut, unlock a workshop, then a garden for steady snacks."}, ], } - for i in WORKER_NAMES.size(): + for i in Constants.WORKER_NAMES.size(): state.workers.append({ - "name": WORKER_NAMES[i], + "name": Constants.WORKER_NAMES[i], "pos": vec_to_data(stockpile_pos + Vector2i(i, 1)), "prev_pos": vec_to_data(stockpile_pos + Vector2i(i, 1)), "carrying": {}, @@ -814,28 +784,34 @@ func dock_anchor_from_option(index: int) -> String: return DOCK_OPTIONS[index] return "right" -func toggle_menu() -> void: - var is_open := not sidebar_scroll.visible - sidebar_scroll.visible = is_open - menu_actions.visible = is_open - management_panels.visible = false - settings_panel.visible = false +# ── Popup/menu state machine ────────────────────────────────────────────────── +# The sidebar popup is always in exactly one mode; every open/close path goes +# through set_menu_mode so the four panel-visibility flags can never desync. + +enum MenuMode { CLOSED, MENU, MANAGEMENT, SETTINGS } +var menu_mode: MenuMode = MenuMode.CLOSED + +func set_menu_mode(mode: MenuMode) -> void: + menu_mode = mode + sidebar_scroll.visible = mode != MenuMode.CLOSED + menu_actions.visible = mode == MenuMode.MENU + management_panels.visible = mode == MenuMode.MANAGEMENT or mode == MenuMode.SETTINGS + settings_panel.visible = mode == MenuMode.SETTINGS + for child in management_panels.get_children(): + child.visible = (child == settings_panel) == (mode == MenuMode.SETTINGS) apply_dock_position() position_popup_panel(String(settings.get("dock_anchor", "bottom"))) - if is_open: - render_all() - else: - close_menu() update_menu_button_text() -func hide_all_popups() -> void: - sidebar_scroll.visible = false - menu_actions.visible = false - management_panels.visible = false - settings_panel.visible = false +func toggle_menu() -> void: + if sidebar_scroll.visible: + close_menu() + return + set_menu_mode(MenuMode.MENU) + render_all() func close_menu() -> void: - hide_all_popups() + set_menu_mode(MenuMode.CLOSED) if not pending_build_kind.is_empty(): world_label.text = 'Colony • click ground for %s' % cap(pending_build_kind) else: @@ -846,49 +822,27 @@ func open_build_popup() -> void: if not pending_build_kind.is_empty(): cancel_build_placement() return - sidebar_scroll.visible = true - menu_actions.visible = false - management_panels.visible = true - apply_dock_position() - position_popup_panel(String(settings.get("dock_anchor", "bottom"))) - for child in management_panels.get_children(): - child.visible = child != settings_panel - settings_panel.visible = false + set_menu_mode(MenuMode.MANAGEMENT) update_build_preview(first_visible_build_kind()) - update_menu_button_text() func open_settings() -> void: - sidebar_scroll.visible = true - menu_actions.visible = false - management_panels.visible = true - apply_dock_position() - position_popup_panel(String(settings.get("dock_anchor", "bottom"))) - for child in management_panels.get_children(): - child.visible = child == settings_panel - settings_panel.visible = true - update_menu_button_text() + set_menu_mode(MenuMode.SETTINGS) func close_settings() -> void: - settings_panel.visible = false - management_panels.visible = false - menu_actions.visible = sidebar_scroll.visible - update_menu_button_text() + set_menu_mode(MenuMode.MENU if sidebar_scroll.visible else MenuMode.CLOSED) func start_new_game() -> void: open_startup_menu() func save_game() -> void: - persist() + persist(true) push_event("Game saved. Tiny bureaucracy, handled.") - hide_all_popups() - close_settings() - update_menu_button_text() + set_menu_mode(MenuMode.CLOSED) render_sidebar() func _load_failed(message: String) -> void: push_event(message) - menu_actions.visible = false - close_settings() + set_menu_mode(MenuMode.CLOSED) if game_active: render_sidebar() @@ -910,9 +864,7 @@ func load_saved_game() -> void: apply_priority_order() apply_orientation_lock_ui() push_event("Save loaded. Tiny lives resume their routines.") - hide_all_popups() - close_settings() - update_menu_button_text() + set_menu_mode(MenuMode.CLOSED) render_all() func exit_game() -> void: @@ -948,10 +900,7 @@ func _on_dock_side_selected(index: int) -> void: render_sidebar() return var previous_family := anchor_family - var menu_was_open := sidebar_scroll.visible - var menu_actions_was_visible := menu_actions.visible - var management_was_visible := management_panels.visible - var settings_was_visible := settings_panel.visible + var previous_mode := menu_mode settings["dock_anchor"] = dock_anchor_from_option(index) save_settings() apply_dock_position() @@ -960,18 +909,8 @@ func _on_dock_side_selected(index: int) -> void: bootstrap_state() push_event("Dock orientation changed. The colony replanned itself for the new strip.") render_all() - sidebar_scroll.visible = menu_was_open - if menu_was_open: - menu_actions.visible = menu_actions_was_visible - management_panels.visible = management_was_visible - for child in management_panels.get_children(): - if settings_was_visible: - child.visible = child == settings_panel - else: - child.visible = child != settings_panel - settings_panel.visible = settings_was_visible - position_popup_panel(settings["dock_anchor"]) - update_menu_button_text() + # Restore whatever popup mode was active before the dock change. + set_menu_mode(previous_mode) func update_tick_speed_label() -> void: var setting := clampi(int(tick_speed_slider.value), 0, TICK_SPEEDS.size() - 1) @@ -1215,13 +1154,13 @@ func _get_trend(resource_name: String) -> String: var current := int(state.resources.get(resource_name, 0)) var previous := int(prev_resources.get(resource_name, -1)) if previous < 0: - return RESOURCE_TRENDS["stable"] + return Constants.RESOURCE_TRENDS["stable"] elif current > previous: - return RESOURCE_TRENDS["rising"] + return Constants.RESOURCE_TRENDS["rising"] elif current < previous: - return RESOURCE_TRENDS["falling"] + return Constants.RESOURCE_TRENDS["falling"] else: - return RESOURCE_TRENDS["stable"] + return Constants.RESOURCE_TRENDS["stable"] func stockpile_summary_text(compact: bool = false) -> String: var harvested: Dictionary = state.get("harvested", {}) @@ -1239,8 +1178,8 @@ func tick_seconds_for_setting() -> float: 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 + return Constants.BASE_TICK_SECONDS * multiplier + return Constants.BASE_TICK_SECONDS * float(TICK_SPEEDS[setting]["mult"]) * multiplier func _on_tick() -> void: if not game_active or state.is_empty(): @@ -1310,9 +1249,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)) - hide_all_popups() - apply_dock_position() - update_menu_button_text() + set_menu_mode(MenuMode.CLOSED) render_all() func handle_tile_click(index: int) -> void: @@ -1341,7 +1278,7 @@ func queue_structure_at(pos: Vector2i, kind: String) -> void: push_event("No room for %s. Dense urban planning strikes again." % kind) return var zero_costs := {} - for resource in BUILD_COSTS.get(kind, {}).keys(): + for resource in Constants.BUILD_COSTS.get(kind, {}).keys(): zero_costs[resource] = 0 var build := { "id": int(state.next_build_id), @@ -1358,12 +1295,10 @@ 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 = "" - hide_all_popups() hover_tile_index = -1 world_label.text = "Colony" - apply_dock_position() - update_menu_button_text() - persist() + set_menu_mode(MenuMode.CLOSED) + persist(true) render_all() func cancel_build_placement() -> void: @@ -1371,13 +1306,11 @@ func cancel_build_placement() -> void: return var kind := pending_build_kind pending_build_kind = "" - hide_all_popups() hover_tile_index = -1 world_label.text = "Colony" if not kind.is_empty(): world_label.text = "Colony • place another " + cap(kind) - apply_dock_position() - update_menu_button_text() + set_menu_mode(MenuMode.CLOSED) render_all() func render_all() -> void: @@ -1581,14 +1514,7 @@ func can_place_at(pos: Vector2i, kind: String) -> bool: return is_structure_unlocked(kind) func structure_icon(kind: String) -> String: - match kind: - "hut": - return "🏠" - "workshop": - return "🛠" - "garden": - return "🪴" - return "🏗" + return Constants.STRUCTURE_ICONS.get(kind, "🏗") func render_goal() -> void: if not is_instance_valid(goal_label): @@ -1635,7 +1561,7 @@ func _render_crew_list() -> void: 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.name.add_theme_color_override("font_color", Constants.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. @@ -1652,7 +1578,7 @@ func render_build_buttons() -> void: if child is Button: var kind := String(child.get_meta("kind")) var unlocked := is_structure_unlocked(kind) - var costs: Dictionary = BUILD_COSTS[kind] + var costs: Dictionary = Constants.BUILD_COSTS[kind] child.disabled = not unlocked child.text = "+ Place %s • %d wood / %d stone" % [cap(kind), int(costs.get("wood", 0)), int(costs.get("stone", 0))] child.tooltip_text = build_preview_text(kind) @@ -1670,7 +1596,7 @@ func update_build_preview(kind: String) -> void: build_preview_label.text = build_preview_text(kind) func build_cost_text(kind: String) -> String: - var costs: Dictionary = BUILD_COSTS.get(kind, {}) + var costs: Dictionary = Constants.BUILD_COSTS.get(kind, {}) var parts: Array[String] = [] for resource in costs.keys(): var amount := int(costs[resource]) @@ -1680,7 +1606,7 @@ 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, {}) + var costs: Dictionary = Constants.BUILD_COSTS.get(kind, {}) for resource in costs.keys(): var shortage := int(costs[resource]) - int(state.get("resources", {}).get(resource, 0)) if shortage > 0: @@ -1692,12 +1618,12 @@ func build_preview_text(kind: String) -> String: var missing_text := "missing " + ", ".join(missing) if not missing.is_empty() else "available" var locked_text := "" if not is_structure_unlocked(kind): - locked_text = " • locked until %s" % cap(String(BUILD_UNLOCKS[kind])) + locked_text = " • locked until %s" % cap(String(Constants.BUILD_UNLOCKS[kind])) return "%s • cost %s • %s • %s%s" % [ cap(kind), build_cost_text(kind), missing_text, - String(BUILD_EFFECTS.get(kind, "Adds a colony upgrade.")), + String(Constants.BUILD_EFFECTS.get(kind, "Adds a colony upgrade.")), locked_text, ] @@ -1706,38 +1632,29 @@ func tile_icon(tile: Dictionary, pos: Vector2i) -> String: return structure_icon(pending_build_kind) if can_place_at(pos, pending_build_kind) else "✕" if pos == stockpile_pos: return "📦" - match String(tile.kind): - "tree": return "🌲" - "rock": return "🪨" - "berries": return "🫐" - "foundation": - var build := get_build_at_pos(pos) - if not build.is_empty() and has_costs_delivered(build) and tick % 2 == 0: - return "🔨" - return "🏗" - "hut", "workshop", "garden": - return structure_icon(String(tile.kind)) - _: - return "" + var kind := String(tile.kind) + if Constants.TILE_ICONS.has(kind): + return Constants.TILE_ICONS[kind] + if Constants.STRUCTURE_ICONS.has(kind): + return structure_icon(kind) + if kind == "foundation": + var build := get_build_at_pos(pos) + if not build.is_empty() and has_costs_delivered(build) and tick % 2 == 0: + return "🔨" + return "🏗" + return "" func tile_amount_text(tile: Dictionary, pos: Vector2i) -> String: if not pending_build_kind.is_empty() and pos == hovered_tile_pos(): return cap(pending_build_kind).left(4) if can_place_at(pos, pending_build_kind) else "busy" if pos == stockpile_pos: return "hub" - match String(tile.kind): - "tree", "rock", "berries": - return str(int(tile.amount)) - "foundation": - return cap(String(tile.build_kind)).left(4) - "hut": - return "hut" - "workshop": - return "shop" - "garden": - return "grow" - _: - return "" + var kind := String(tile.kind) + if Constants.TILE_ICONS.has(kind): + return str(int(tile.amount)) + if kind == "foundation": + return cap(String(tile.build_kind)).left(4) + return Constants.TILE_SHORT_LABELS.get(kind, "") func carried_resource(worker: Dictionary) -> String: var carrying: Dictionary = worker.get("carrying", {}) @@ -1758,7 +1675,7 @@ func worker_texture(name: String, frame: int, carrying: String = "") -> Texture2 var cache_key := "%s:%d:%s" % [name, frame, carrying] if worker_texture_cache.has(cache_key): return worker_texture_cache[cache_key] - var accent: Color = WORKER_BADGE_COLORS.get(name, Color.WHITE) + var accent: Color = Constants.WORKER_BADGE_COLORS.get(name, Color.WHITE) var shadow := accent.darkened(0.45) var skin := Color("#f2d0b1") var image := Image.create(12, 14, false, Image.FORMAT_RGBA8) @@ -1814,9 +1731,9 @@ func worker_texture(name: String, frame: int, carrying: String = "") -> Texture2 # 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_style_theme := {"Constants.TILE_BACKDROPS": Constants.TILE_BACKDROPS, "stockpile_pos": Vector2i.ZERO} var _tile_accent_ctx: Dictionary = {} -var _tile_accent_theme := {"RESOURCE_COLORS": RESOURCE_COLORS, "STRUCTURE_COLORS": STRUCTURE_COLORS} +var _tile_accent_theme := {"Constants.RESOURCE_COLORS": Constants.RESOURCE_COLORS, "Constants.STRUCTURE_COLORS": Constants.STRUCTURE_COLORS} ## Delegates to TileRender.tile_style, passing scene state via context. func tile_style(tile: Dictionary, pos: Vector2i) -> StyleBoxFlat: @@ -1875,11 +1792,14 @@ func settlement_status_text() -> String: return status + "\nNext: " + next_unlock func next_unlock_text() -> String: - if not is_structure_complete("hut"): - return "Finish a hut to unlock the workshop" - if not is_structure_complete("workshop"): - return "Finish a workshop to unlock the garden" - return "Garden tier unlocked. Keep the tiny settlement fed" + # Walk Constants.BUILD_UNLOCKS: report the first structure still gated on an + # incomplete prerequisite, so a new tier only needs a table entry. + for kind in Constants.BUILD_UNLOCKS: + var unlock: Variant = Constants.BUILD_UNLOCKS[kind] + if typeof(unlock) == TYPE_STRING and not is_structure_complete(String(unlock)): + return "Finish a %s to unlock the %s" % [String(unlock), kind] + var last_kind := String(Constants.BUILD_UNLOCKS.keys()[-1]) + return "%s tier unlocked. Keep the tiny settlement fed" % cap(last_kind) func is_save_compatible(loaded: Dictionary) -> bool: var tiles: Array = loaded.get("tiles", []) From 0a95095339c7205a06b27bfa4ed4341d353c4052 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 15 Jul 2026 20:33:11 -0600 Subject: [PATCH 4/4] Stop tracking smoke test logs --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0423a95..4ce55a0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ build/ .tools/ .DS_Store + +# CI/local smoke test output +*.log