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
6 changes: 4 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ jobs:
continue
fi
log="${name}.log"
if "$GODOT" --headless --path . --script "res://tests/${name}.gd" > "$log" 2>&1; then
if "$GODOT" --headless --path . --script "res://tests/${name}.gd" > "$log" 2>&1 \
&& ! grep -q "SCRIPT ERROR" "$log"; then
PASS_COUNT=$(grep -c ": PASS" "$log" || true)
echo "${name}: ${PASS_COUNT} passed"
else
Expand Down Expand Up @@ -168,7 +169,8 @@ jobs:
continue
fi
log="${name}.log"
if "$GODOT_MAC_APP/Contents/MacOS/Godot" --headless --path . --script "res://tests/${name}.gd" > "$log" 2>&1; then
if "$GODOT_MAC_APP/Contents/MacOS/Godot" --headless --path . --script "res://tests/${name}.gd" > "$log" 2>&1 \
&& ! grep -q "SCRIPT ERROR" "$log"; then
PASS_COUNT=$(grep -c ": PASS" "$log" || true)
echo "${name}: ${PASS_COUNT} passed"
else
Expand Down
5 changes: 4 additions & 1 deletion .justfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ test-all:
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; \
log=$(mktemp); \
'{{ godot }}' --headless --path '{{ justfile_dir() }}' --script "res://tests/$name.gd" 2>&1 | tee "$log"; \
if [ "${PIPESTATUS[0]}" -ne 0 ] || grep -q "SCRIPT ERROR" "$log"; then FAILED=1; fi; \
rm -f "$log"; \
done; \
exit $FAILED

Expand Down
91 changes: 63 additions & 28 deletions scripts/colony_sim.gd
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ 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}

Expand Down Expand Up @@ -134,10 +132,21 @@ func _process_goals() -> void:
push_event("Reward ended: %s" % expired_label)


# Current-milestone lookup cache — the catalog entry only changes when the
# chain advances, so the per-tick scan + deep copy is skipped otherwise.
var _milestone_cache: Dictionary = {}
var _milestone_cache_id := "__unset__"

func _current_milestone(milestone_id: String) -> Dictionary:
if _milestone_cache_id != milestone_id:
_milestone_cache_id = milestone_id
_milestone_cache = MilestoneManager.get_current_milestone(MilestoneManager.MILESTONE_CATALOG, milestone_id)
return _milestone_cache

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)
var active_milestone: Dictionary = _current_milestone(current_id)
if active_milestone.is_empty() or not MilestoneManager.is_milestone_complete(active_milestone, state):
return
state["completed_milestone_ids"].append(current_id)
Expand Down Expand Up @@ -174,15 +183,11 @@ func spawn_resource_drop() -> void:
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)
var kinds: Array = Constants.RESOURCE_TILES.keys()
var resource_kind: String = kinds[rng.randi_range(0, kinds.size() - 1)]
var drop: Dictionary = Constants.RESOURCE_TILES[resource_kind]
set_tile(pos, {"kind": resource_kind, "amount": drop.drop_amount, "resource": drop.resource, "build_kind": ""})
push_event(drop.drop_message)


# ── Food upkeep (issue #147, links to #133) ───────────────────────────────────
Expand Down Expand Up @@ -348,15 +353,15 @@ func gather_gather_tasks() -> Array[Dictionary]:
for y in grid_h:
for x in grid_w:
var tile := get_tile(Vector2i(x, y))
if GATHERABLE_KINDS.has(String(tile.kind)):
if Constants.RESOURCE_TILES.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:
if Constants.RESOURCE_TILES.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)):
Expand Down Expand Up @@ -599,11 +604,36 @@ func set_tile(pos: Vector2i, data: Dictionary) -> void:
mark_dirty()


# id → array-index cache for build lookups (hot path: every haul/build step).
# Builds are append-only, so the index only needs rebuilding when the array
# size changes or the state dictionary was swapped wholesale — the entry
# verification below catches both.
var _build_index: Dictionary = {}

func _rebuild_build_index() -> void:
_build_index.clear()
var builds: Array = state.get("builds", [])
for i in builds.size():
_build_index[int(builds[i].id)] = i

func _build_array_index(id: int) -> int:
var builds: Array = state.get("builds", [])
if _build_index.size() != builds.size():
_rebuild_build_index()
var idx := int(_build_index.get(id, -1))
if idx >= 0 and idx < builds.size() and int(builds[idx].id) == id:
return idx
# Stale (same-sized state swapped in) — rebuild once and retry.
_rebuild_build_index()
idx = int(_build_index.get(id, -1))
if idx >= 0 and int(builds[idx].id) == id:
return idx
return -1


func get_build(id: int) -> Dictionary:
for build in state.builds:
if int(build.id) == id:
return build
return {}
var idx := _build_array_index(id)
return state.builds[idx] if idx >= 0 else {}


func get_build_at_pos(pos: Vector2i) -> Dictionary:
Expand All @@ -614,10 +644,9 @@ func get_build_at_pos(pos: Vector2i) -> Dictionary:


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
var idx := _build_array_index(id)
if idx >= 0:
state.builds[idx] = updated


func is_pos_in_bounds(pos: Vector2i) -> bool:
Expand All @@ -640,14 +669,20 @@ func find_open_ground() -> Vector2i:


func seed_tile(pos: Vector2i) -> Dictionary:
# Deterministic placement hash — the constants just spread resource tiles
# pleasingly across the strip; tile contents come from RESOURCE_TILES.
var key := int((pos.x * 13 + pos.y * 7 + pos.x * pos.y) % 14)
var kind := ""
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": ""}
kind = "tree"
elif key == 6 or key == 8:
kind = "rock"
elif key == 11:
kind = "berries"
if kind.is_empty():
return {"kind": "ground", "amount": 0, "resource": "", "build_kind": ""}
var def: Dictionary = Constants.RESOURCE_TILES[kind]
return {"kind": kind, "amount": def.seed_amount, "resource": def.resource, "build_kind": ""}


# ── Coordinate helpers ────────────────────────────────────────────────────────
Expand Down
42 changes: 42 additions & 0 deletions scripts/constants.gd
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ const BASE_TICK_SECONDS := 0.9
const EVENT_INTERVAL_TICKS := 66
const MAX_EVENT_LOG := 20

# Focus Mode slows the colony down while the player works (issue #19).
const FOCUS_MODE_TICK_MULTIPLIER := 2.5
# Zoom slider bounds/step for tile scaling.
const ZOOM_MIN := 0.5
const ZOOM_MAX := 2.0
const ZOOM_STEP := 0.1

const RESOURCE_COLORS := {
"wood": Color("#5d8f58"),
"stone": Color("#8b96a4"),
Expand Down Expand Up @@ -38,6 +45,17 @@ const TILE_BACKDROPS := {
"stockpile": Color("#66522a"),
}

# Tile accent palette, consumed by TileRender via its theme context so the
# whole tile look is tuned from this file.
const TILE_ACCENTS := {
"placement_ok": Color("#73d38c"),
"placement_blocked": Color("#d36b6b"),
"stockpile": Color("#d4b36f"),
"foundation": Color("#c7a25e"),
"default": Color(1, 1, 1, 0.35),
}
const TILE_DEFAULT_BACKDROP := Color("#1b2128")

const WORKER_BADGE_COLORS := {
"Jun": Color("#f58f6c"),
"Mara": Color("#75c7ff"),
Expand Down Expand Up @@ -84,6 +102,30 @@ const TILE_ICONS := {
"berries": "🫐",
}

# Everything that defines a gatherable resource tile: what it yields, how
# much a world-seeded tile holds, how much an ambient supply drop holds, and
# the drop announcement. The sim derives gatherability from membership here.
const RESOURCE_TILES := {
"tree": {
"resource": "wood",
"seed_amount": 6,
"drop_amount": 4,
"drop_message": "A driftwood bundle lands nearby. Fresh wood appeared.",
},
"rock": {
"resource": "stone",
"seed_amount": 5,
"drop_amount": 4,
"drop_message": "A rubble drop lands nearby. Fresh stone appeared.",
},
"berries": {
"resource": "food",
"seed_amount": 4,
"drop_amount": 3,
"drop_message": "A snack crate lands nearby. Fresh food appeared.",
},
}

const TILE_SHORT_LABELS := {
"hut": "hut",
"workshop": "shop",
Expand Down
91 changes: 46 additions & 45 deletions scripts/game_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -27,39 +27,57 @@ func _ready() -> void:
if OS.has_feature("web"):
use_local_storage = JavaScriptBridge.eval("typeof localStorage !== 'undefined'", true)

# ── Shared persistence plumbing ───────────────────────────────────────────────
# The web build stores a JSON string inside localStorage (hence the double
# stringify/parse dance); the desktop build writes plain JSON files. These
# four helpers are the only place either quirk lives.

func _local_storage_write(key: String, payload: String) -> void:
JavaScriptBridge.eval("localStorage.setItem('%s', %s)" % [key, JSON.stringify(payload)], true)

func _local_storage_read(key: String) -> Dictionary:
var raw = JavaScriptBridge.eval("localStorage.getItem('%s')" % key, true)
if raw == null or String(raw).is_empty() or String(raw) == "null":
return {}
var parsed = JSON.parse_string(String(raw))
if typeof(parsed) == TYPE_STRING:
parsed = JSON.parse_string(parsed)
return parsed if parsed is Dictionary else {}

func _write_text_file(path: String, payload: String) -> void:
var file := FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_string(payload)
file.close()

## Returns the parsed JSON from a file, or null when missing/empty/unreadable.
func _read_json_file(path: String) -> Variant:
if not FileAccess.file_exists(path):
return null
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return null
var text := file.get_as_text()
if text.strip_edges().is_empty():
return null
return JSON.parse_string(text)

func save_game(data: Dictionary, path: String = "") -> void:
var target_path := path if not path.is_empty() else SAVE_PATH
var payload := JSON.stringify(data)
if use_local_storage:
JavaScriptBridge.eval("localStorage.setItem('%s', %s)" % [SAVE_KEY, JSON.stringify(payload)], true)
_local_storage_write(SAVE_KEY, payload)
return
var file := FileAccess.open(target_path, FileAccess.WRITE)
if file:
file.store_string(payload)
file.close()
_write_text_file(target_path, payload)

func load_game(path: String = "") -> Dictionary:
var target_path := path if not path.is_empty() else SAVE_PATH
if use_local_storage:
var raw = JavaScriptBridge.eval("localStorage.getItem('%s')" % SAVE_KEY, true)
if raw == null or String(raw).is_empty() or String(raw) == "null":
return {}
var parsed = JSON.parse_string(String(raw))
if typeof(parsed) == TYPE_STRING:
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
if not FileAccess.file_exists(target_path):
return {}
var file := FileAccess.open(target_path, FileAccess.READ)
if not file:
return {}
var text := file.get_as_text()
if text.strip_edges().is_empty():
return {}
var parsed = JSON.parse_string(text)
var stored := _local_storage_read(SAVE_KEY)
if not stored.is_empty():
rebuild_reservations_from_workers(stored)
return stored
var parsed: Variant = _read_json_file(target_path)
if not parsed is Dictionary:
return {}

Expand Down Expand Up @@ -501,29 +519,12 @@ func restore_backup() -> String:
func save_settings(data: Dictionary) -> void:
var payload := JSON.stringify(data)
if use_local_storage:
JavaScriptBridge.eval("localStorage.setItem('%s', %s)" % [SETTINGS_KEY, JSON.stringify(payload)], true)
_local_storage_write(SETTINGS_KEY, payload)
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(payload)
_write_text_file(SETTINGS_PATH, payload)

func load_settings() -> Dictionary:
if use_local_storage:
var raw = JavaScriptBridge.eval("localStorage.getItem('%s')" % SETTINGS_KEY, true)
if raw == null or String(raw).is_empty() or String(raw) == "null":
return {}
var parsed = JSON.parse_string(String(raw))
if typeof(parsed) == TYPE_STRING:
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 {}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if not file:
return {}
var text := file.get_as_text()
if text.strip_edges().is_empty():
return {}
var parsed = JSON.parse_string(text)
return _local_storage_read(SETTINGS_KEY)
var parsed: Variant = _read_json_file(SETTINGS_PATH)
return parsed if parsed is Dictionary else {}
Loading