From 5cbab527bf060c4f871dd3e9d1dc7132b793c632 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 9 Jul 2026 23:10:18 +0200 Subject: [PATCH] fix: enforce village resource invariants --- docs/FOOD_STORAGE_ARCHITECTURE.md | 14 +++--- docs/LEARNING_ROADMAP.md | 17 ++++---- docs/local_quality_gate.md | 5 +++ simulation/SimulationManager.gd | 47 +++++++++++++-------- simulation/actions/ActionSelectionSystem.gd | 4 ++ tests/action_system_boundaries_test.gd | 14 ++++++ tests/food_storage_loop_test.gd | 46 ++++++++++++++++++++ tests/npc_schedule_test.gd | 3 +- tools/quality.sh | 28 ++++++++---- 9 files changed, 136 insertions(+), 42 deletions(-) mode change 100644 => 100755 tools/quality.sh diff --git a/docs/FOOD_STORAGE_ARCHITECTURE.md b/docs/FOOD_STORAGE_ARCHITECTURE.md index 7009b24..e03e106 100644 --- a/docs/FOOD_STORAGE_ARCHITECTURE.md +++ b/docs/FOOD_STORAGE_ARCHITECTURE.md @@ -18,17 +18,21 @@ world only when consumed. ## Authority -- `StorageStateRecord` owns pantry contents and capacity. +- `StorageStateRecord` owns pantry and woodpile contents and capacity. - `SimNPC.inventory` owns carried item amounts. - `SimulationManager` performs deposit, withdrawal, and consumption transactions. -- `village.food` is a synchronized aggregate view used by the existing UI, - priorities, and utility scoring. It is not a second mutation path. +- `village.food` and `village.wood` are synchronized aggregate views used by + the existing UI, priorities, and utility scoring. They are not second + mutation paths. - `StorageNode` supplies the active-world interaction position and presentation for deposit, withdrawal, eating, and current player pantry interaction. -The village pantry has stable ID `village_pantry`. Storage and NPC inventory -are included in versioned state and deterministic checksums. +The village pantry has stable ID `village_pantry`; the woodpile has stable ID +`village_woodpile`. Storage and NPC inventory are included in versioned state +and deterministic checksums. Patrol and study require one wood on completion: +the effect is applied only when that unit can be withdrawn, and selection does +not choose those actions while the woodpile is empty. Successful extraction, deposit, withdrawal, and consumption also append structured economic facts. See [the economic event stream](ECONOMIC_EVENTS.md). diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index cb625e0..ad145aa 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -652,14 +652,12 @@ Completed after the architecture gate: The practical next sequence is: -1. Expand the structured event feed with actor/target/cause metadata so the - inspector can show a readable timeline per NPC. Narrative events for task - starts, sleep, and death are recorded; next should add depletion notices, - relationship seeds, and village-level event summaries filtered by relevance. -2. Seed relationship dimensions (familiarity, trust, obligation) from shared - work, meal, and sleep proximity, then expose them through utility - considerations so NPCs begin to prefer known colleagues and familiar routes. - Start with one lightweight dimension before building the full graph. +1. Move proven action preconditions and material costs into a reusable contract, + beginning with the patrol/study wood dependency, then expose unavailable + choices and interruption reasons in the NPC inspector. +2. Grow the existing familiarity seed into one consequence-bearing relationship + dimension, such as trust or obligation, driven by structured events rather + than proximity alone. Recently completed: @@ -706,7 +704,8 @@ Recently completed: - Wood inventory and woodpile storage: wood now follows the same gather→carry→deposit pattern as food. VillageWoodpile StorageNode receives wood deposits. Village wood syncs from storage. Economic events track all - transfers. + transfers. Patrol and study no longer produce benefits without paying their + one-wood cost, and utility selection avoids those actions when wood is empty. - Structured event feed: EconomicEventRecord now supports narrative events (task started, NPC slept, NPC died) alongside economic transfers. Per-NPC event history with human-readable descriptions exposed in the diff --git a/docs/local_quality_gate.md b/docs/local_quality_gate.md index e111073..5788476 100644 --- a/docs/local_quality_gate.md +++ b/docs/local_quality_gate.md @@ -107,3 +107,8 @@ pip install gdtoolkit ``` The script exits non-zero on any failure, so it will fail the CI step. + +The shell gate isolates Godot's cross-platform user-data paths under +`logs/quality/godot_profile`. It also treats Godot script parse/load markers as +failures because headless Godot can report those errors while returning a zero +process exit code. diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 3094f0a..1ff336f 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -271,11 +271,11 @@ func simulate_tick() -> void: ): village.apply_npc_task(npc) elif completed_task == SimulationIds.ACTION_PATROL: - _consume_wood_for_work(npc, completed_task) - village.apply_npc_task(npc) + if _consume_wood_for_work(npc, completed_task): + village.apply_npc_task(npc) elif completed_task == SimulationIds.ACTION_STUDY: - _consume_wood_for_work(npc, completed_task) - village.apply_npc_task(npc) + if _consume_wood_for_work(npc, completed_task): + village.apply_npc_task(npc) elif debug_logs: print( "[SimulationManager] ", @@ -711,23 +711,27 @@ func deposit_npc_wood(npc: SimNPC) -> float: return deposited -func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> void: +func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> bool: var woodpile := get_woodpile() if woodpile == null: - return + return false + if woodpile.get_amount(SimulationIds.RESOURCE_WOOD) < 1.0: + return false var consumed := woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1.0) _sync_village_wood() - if consumed >= 1.0: - _record_economic_event( - SimulationIds.EVENT_ITEM_CONSUMED, - npc.id, - SimulationIds.STORAGE_VILLAGE_WOODPILE, - &"consumed", - SimulationIds.RESOURCE_WOOD, - consumed - ) - if debug_logs: - print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id]) + if consumed < 1.0: + return false + _record_economic_event( + SimulationIds.EVENT_ITEM_CONSUMED, + npc.id, + SimulationIds.STORAGE_VILLAGE_WOODPILE, + &"consumed", + SimulationIds.RESOURCE_WOOD, + consumed + ) + if debug_logs: + print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id]) + return true func withdraw_to_npc(npc: SimNPC, item_id: StringName, amount: float) -> float: @@ -898,7 +902,14 @@ func add_food(amount: float) -> void: func add_wood(amount: float) -> void: - village.apply_resource_delta(&"wood", amount) + var woodpile := get_woodpile() + if woodpile == null: + return + if amount >= 0.0: + woodpile.deposit(SimulationIds.RESOURCE_WOOD, amount) + else: + woodpile.withdraw(SimulationIds.RESOURCE_WOOD, -amount) + _sync_village_wood() village_changed.emit(village) diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index aea75c0..feed575 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -16,6 +16,7 @@ const DINNER_BEGIN := 0.7 const DINNER_END := 0.8 const WORK_BEGIN := 0.28 const WORK_END := 0.85 +const UNAVAILABLE_ACTION_SCORE := -1000000.0 func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []) -> ActionSelectionResult: @@ -181,6 +182,9 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) 0.75 ) } + if village.wood < 1.0: + scores[SimulationIds.ACTION_PATROL] = UNAVAILABLE_ACTION_SCORE + scores[SimulationIds.ACTION_STUDY] = UNAVAILABLE_ACTION_SCORE for familiar_id in npc.familiarity: if not familiar_id is int: continue diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index 99958df..00ae898 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -68,6 +68,20 @@ func _test_selection_and_execution_are_separate() -> void: work_selection.scores.size() == 4, "Ordinary work selection should expose all compared utility scores" ) + var guard := SimNPC.new(701, "BoundaryGuard", SimulationIds.PROFESSION_GUARD, 5.0, 5.0) + guard.hunger = 20.0 + guard.energy = 80.0 + village.food = 100.0 + village.wood = 0.0 + village.safety = 0.0 + village.knowledge = 100.0 + village.update_modifiers() + village.update_priorities() + var unfunded_selection := selector.select_action(guard, village, 0.5) + _check( + unfunded_selection.action_id == SimulationIds.ACTION_GATHER_WOOD, + "A guard should gather wood instead of selecting patrol when its material cost is unavailable" + ) func _test_target_resolution_and_travel_are_separate() -> void: diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index 1d0ce29..ef84225 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -112,6 +112,7 @@ func _run() -> void: "Exactly one food unit should leave the world when eaten" ) _check_economic_event_chain(manager) + _test_wood_work_requires_material() var restored: Node = load("res://simulation/SimulationManager.gd").new() restored.debug_logs = false @@ -179,3 +180,48 @@ func _check_economic_event_chain(manager: Node) -> void: ), "Economic events should record exact transferred quantities" ) + + +func _test_wood_work_requires_material() -> void: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.debug_logs = false + root.add_child(manager) + manager.set_process(false) + var woodpile: StorageStateRecord = manager.get_woodpile() + woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1000.0) + woodpile.deposit(SimulationIds.RESOURCE_WOOD, 0.5) + manager._sync_village_wood() + var npc: SimNPC = manager.npcs[0] + var safety_before: float = manager.village.safety + + npc.set_task(SimulationIds.ACTION_PATROL, 1.0) + npc.start_working() + manager.simulate_tick() + _check( + ( + is_equal_approx(manager.village.safety, safety_before) + and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 0.5) + ), + "Patrol should not consume partial wood or create safety when its full cost cannot be paid" + ) + + manager.add_wood(1.5) + _check( + ( + is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 2.0) + and is_equal_approx(manager.village.wood, 2.0) + ), + "Wood changes should update authoritative storage and the village projection" + ) + var knowledge_before: float = manager.village.knowledge + npc.set_task(SimulationIds.ACTION_STUDY, 1.0) + npc.start_working() + manager.simulate_tick() + _check( + ( + is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 1.0) + and manager.village.knowledge > knowledge_before + ), + "Funded study should consume one wood and produce knowledge" + ) + manager.free() diff --git a/tests/npc_schedule_test.gd b/tests/npc_schedule_test.gd index 7de0184..d7250f6 100644 --- a/tests/npc_schedule_test.gd +++ b/tests/npc_schedule_test.gd @@ -189,7 +189,7 @@ func _test_household_familiarity() -> void: manager.simulation_seed = 700 manager.debug_logs = false manager.set_process(false) - manager.home_positions = [ + var household_positions: Array[Vector3] = [ Vector3(-15.0, 2.0, 7.0), Vector3(-15.0, 2.0, 8.0), Vector3(-8.5, 2.0, 3.0), @@ -197,6 +197,7 @@ func _test_household_familiarity() -> void: Vector3(20.0, 0.0, 20.0), Vector3(22.0, 0.0, 22.0) ] + manager.home_positions = household_positions root.add_child(manager) var a: SimNPC = manager.npcs[0] diff --git a/tools/quality.sh b/tools/quality.sh old mode 100644 new mode 100755 index ae8ab2d..72783fc --- a/tools/quality.sh +++ b/tools/quality.sh @@ -91,6 +91,7 @@ if [[ -z "$GODOT" ]]; then echo "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH." exit 1 fi +GODOT_ENV=(env "HOME=$GODOT_PROFILE" "APPDATA=$GODOT_PROFILE" "LOCALAPPDATA=$GODOT_PROFILE") # -- 1. gdformat -------------------------------------------------------------- fmt_tool=$(find_gdformat) || true @@ -177,7 +178,7 @@ fi godot_result="PASS" : > "$LOG/godot-check.log" if need_timeout; then - if APPDATA="$GODOT_PROFILE" LOCALAPPDATA="$GODOT_PROFILE" timeout 30 "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then + if timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then godot_result="PASS" else ec=$? @@ -189,12 +190,15 @@ if need_timeout; then fi fi else - if APPDATA="$GODOT_PROFILE" LOCALAPPDATA="$GODOT_PROFILE" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then + if "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then godot_result="PASS" else godot_result="FAIL" fi fi +if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then + godot_result="FAIL" +fi if [[ "$godot_result" == "FAIL" ]]; then grep -iE "error|warning|parse|syntax" "$LOG/godot-check.log" 2>/dev/null | head -20 | while IFS= read -r errline; do ERRORS+=("godot: $errline") @@ -208,15 +212,19 @@ scenario_result="PASS" for test in tests/*_test.gd; do echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log" if need_timeout; then - if ! APPDATA="$GODOT_PROFILE" LOCALAPPDATA="$GODOT_PROFILE" timeout 30 "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then + if ! timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then scenario_result="FAIL" ERRORS+=("scenario: $(basename "$test") failed or timed out") fi - elif ! APPDATA="$GODOT_PROFILE" LOCALAPPDATA="$GODOT_PROFILE" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then + elif ! "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then scenario_result="FAIL" ERRORS+=("scenario: $(basename "$test") failed") fi done +if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then + scenario_result="FAIL" + ERRORS+=("scenario: Godot reported a script load or parse error") +fi if [[ "$scenario_result" == "FAIL" ]]; then FIXES+=("Fix failing project scenario tests") fi @@ -225,7 +233,7 @@ fi gut_result="SKIPPED" : > "$LOG/gut.log" if [[ -f "addons/gut/gut_cmdln.gd" ]]; then - if APPDATA="$GODOT_PROFILE" LOCALAPPDATA="$GODOT_PROFILE" "$GODOT" --headless --path "$ROOT" -s addons/gut/gut_cmdln.gd >> "$LOG/gut.log" 2>&1; then + if "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" -s addons/gut/gut_cmdln.gd >> "$LOG/gut.log" 2>&1; then gut_result="PASS" else gut_result="FAIL" @@ -254,8 +262,9 @@ echo " godot-check $godot_result" echo " scenarios $scenario_result" echo " gut $gut_result" -seen_err=() -for e in "${ERRORS[@]}"; do +seen_err=("__quality_sentinel__") +for e in "${ERRORS[@]:-}"; do + [[ -z "$e" ]] && continue contains_element "$e" "${seen_err[@]}" && continue seen_err+=("$e") echo " $e" @@ -264,9 +273,10 @@ done if $OVERALL && [[ ${#FIXES[@]} -gt 0 ]]; then echo "" echo " NEXT FIX:" - seen_fix=() + seen_fix=("__quality_sentinel__") idx=1 - for fix in "${FIXES[@]}"; do + for fix in "${FIXES[@]:-}"; do + [[ -z "$fix" ]] && continue contains_element "$fix" "${seen_fix[@]}" && continue seen_fix+=("$fix") echo " $idx. $fix"