Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 37 additions & 83 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
build/
.tools/
.DS_Store

# CI/local smoke test output
*.log
13 changes: 12 additions & 1 deletion .justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
118 changes: 71 additions & 47 deletions scripts/colony_sim.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions scripts/constants.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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 := {
Expand Down
25 changes: 5 additions & 20 deletions scripts/game_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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-"
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
Loading