fix: enforce village resource invariants

This commit is contained in:
Rijad Zuzo
2026-07-09 23:10:18 +02:00
parent d6520c426a
commit 5cbab527bf
9 changed files with 136 additions and 42 deletions
+9 -5
View File
@@ -18,17 +18,21 @@ world only when consumed.
## Authority ## Authority
- `StorageStateRecord` owns pantry contents and capacity. - `StorageStateRecord` owns pantry and woodpile contents and capacity.
- `SimNPC.inventory` owns carried item amounts. - `SimNPC.inventory` owns carried item amounts.
- `SimulationManager` performs deposit, withdrawal, and consumption - `SimulationManager` performs deposit, withdrawal, and consumption
transactions. transactions.
- `village.food` is a synchronized aggregate view used by the existing UI, - `village.food` and `village.wood` are synchronized aggregate views used by
priorities, and utility scoring. It is not a second mutation path. the existing UI, priorities, and utility scoring. They are not second
mutation paths.
- `StorageNode` supplies the active-world interaction position and presentation - `StorageNode` supplies the active-world interaction position and presentation
for deposit, withdrawal, eating, and current player pantry interaction. for deposit, withdrawal, eating, and current player pantry interaction.
The village pantry has stable ID `village_pantry`. Storage and NPC inventory The village pantry has stable ID `village_pantry`; the woodpile has stable ID
are included in versioned state and deterministic checksums. `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 Successful extraction, deposit, withdrawal, and consumption also append
structured economic facts. See [the economic event stream](ECONOMIC_EVENTS.md). structured economic facts. See [the economic event stream](ECONOMIC_EVENTS.md).
+8 -9
View File
@@ -652,14 +652,12 @@ Completed after the architecture gate:
The practical next sequence is: The practical next sequence is:
1. Expand the structured event feed with actor/target/cause metadata so the 1. Move proven action preconditions and material costs into a reusable contract,
inspector can show a readable timeline per NPC. Narrative events for task beginning with the patrol/study wood dependency, then expose unavailable
starts, sleep, and death are recorded; next should add depletion notices, choices and interruption reasons in the NPC inspector.
relationship seeds, and village-level event summaries filtered by relevance. 2. Grow the existing familiarity seed into one consequence-bearing relationship
2. Seed relationship dimensions (familiarity, trust, obligation) from shared dimension, such as trust or obligation, driven by structured events rather
work, meal, and sleep proximity, then expose them through utility than proximity alone.
considerations so NPCs begin to prefer known colleagues and familiar routes.
Start with one lightweight dimension before building the full graph.
Recently completed: Recently completed:
@@ -706,7 +704,8 @@ Recently completed:
- Wood inventory and woodpile storage: wood now follows the same - Wood inventory and woodpile storage: wood now follows the same
gather→carry→deposit pattern as food. VillageWoodpile StorageNode receives gather→carry→deposit pattern as food. VillageWoodpile StorageNode receives
wood deposits. Village wood syncs from storage. Economic events track all 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 - Structured event feed: EconomicEventRecord now supports narrative events
(task started, NPC slept, NPC died) alongside economic transfers. (task started, NPC slept, NPC died) alongside economic transfers.
Per-NPC event history with human-readable descriptions exposed in the Per-NPC event history with human-readable descriptions exposed in the
+5
View File
@@ -107,3 +107,8 @@ pip install gdtoolkit
``` ```
The script exits non-zero on any failure, so it will fail the CI step. 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.
+29 -18
View File
@@ -271,11 +271,11 @@ func simulate_tick() -> void:
): ):
village.apply_npc_task(npc) village.apply_npc_task(npc)
elif completed_task == SimulationIds.ACTION_PATROL: elif completed_task == SimulationIds.ACTION_PATROL:
_consume_wood_for_work(npc, completed_task) if _consume_wood_for_work(npc, completed_task):
village.apply_npc_task(npc) village.apply_npc_task(npc)
elif completed_task == SimulationIds.ACTION_STUDY: elif completed_task == SimulationIds.ACTION_STUDY:
_consume_wood_for_work(npc, completed_task) if _consume_wood_for_work(npc, completed_task):
village.apply_npc_task(npc) village.apply_npc_task(npc)
elif debug_logs: elif debug_logs:
print( print(
"[SimulationManager] ", "[SimulationManager] ",
@@ -711,23 +711,27 @@ func deposit_npc_wood(npc: SimNPC) -> float:
return deposited 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() var woodpile := get_woodpile()
if woodpile == null: 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) var consumed := woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1.0)
_sync_village_wood() _sync_village_wood()
if consumed >= 1.0: if consumed < 1.0:
_record_economic_event( return false
SimulationIds.EVENT_ITEM_CONSUMED, _record_economic_event(
npc.id, SimulationIds.EVENT_ITEM_CONSUMED,
SimulationIds.STORAGE_VILLAGE_WOODPILE, npc.id,
&"consumed", SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_WOOD, &"consumed",
consumed SimulationIds.RESOURCE_WOOD,
) consumed
if debug_logs: )
print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id]) 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: 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: 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) village_changed.emit(village)
@@ -16,6 +16,7 @@ const DINNER_BEGIN := 0.7
const DINNER_END := 0.8 const DINNER_END := 0.8
const WORK_BEGIN := 0.28 const WORK_BEGIN := 0.28
const WORK_END := 0.85 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: 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 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: for familiar_id in npc.familiarity:
if not familiar_id is int: if not familiar_id is int:
continue continue
+14
View File
@@ -68,6 +68,20 @@ func _test_selection_and_execution_are_separate() -> void:
work_selection.scores.size() == 4, work_selection.scores.size() == 4,
"Ordinary work selection should expose all compared utility scores" "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: func _test_target_resolution_and_travel_are_separate() -> void:
+46
View File
@@ -112,6 +112,7 @@ func _run() -> void:
"Exactly one food unit should leave the world when eaten" "Exactly one food unit should leave the world when eaten"
) )
_check_economic_event_chain(manager) _check_economic_event_chain(manager)
_test_wood_work_requires_material()
var restored: Node = load("res://simulation/SimulationManager.gd").new() var restored: Node = load("res://simulation/SimulationManager.gd").new()
restored.debug_logs = false restored.debug_logs = false
@@ -179,3 +180,48 @@ func _check_economic_event_chain(manager: Node) -> void:
), ),
"Economic events should record exact transferred quantities" "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()
+2 -1
View File
@@ -189,7 +189,7 @@ func _test_household_familiarity() -> void:
manager.simulation_seed = 700 manager.simulation_seed = 700
manager.debug_logs = false manager.debug_logs = false
manager.set_process(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, 7.0),
Vector3(-15.0, 2.0, 8.0), Vector3(-15.0, 2.0, 8.0),
Vector3(-8.5, 2.0, 3.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(20.0, 0.0, 20.0),
Vector3(22.0, 0.0, 22.0) Vector3(22.0, 0.0, 22.0)
] ]
manager.home_positions = household_positions
root.add_child(manager) root.add_child(manager)
var a: SimNPC = manager.npcs[0] var a: SimNPC = manager.npcs[0]
Regular → Executable
+19 -9
View File
@@ -91,6 +91,7 @@ if [[ -z "$GODOT" ]]; then
echo "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH." echo "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH."
exit 1 exit 1
fi fi
GODOT_ENV=(env "HOME=$GODOT_PROFILE" "APPDATA=$GODOT_PROFILE" "LOCALAPPDATA=$GODOT_PROFILE")
# -- 1. gdformat -------------------------------------------------------------- # -- 1. gdformat --------------------------------------------------------------
fmt_tool=$(find_gdformat) || true fmt_tool=$(find_gdformat) || true
@@ -177,7 +178,7 @@ fi
godot_result="PASS" godot_result="PASS"
: > "$LOG/godot-check.log" : > "$LOG/godot-check.log"
if need_timeout; then 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" godot_result="PASS"
else else
ec=$? ec=$?
@@ -189,12 +190,15 @@ if need_timeout; then
fi fi
fi fi
else 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" godot_result="PASS"
else else
godot_result="FAIL" godot_result="FAIL"
fi fi
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 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 grep -iE "error|warning|parse|syntax" "$LOG/godot-check.log" 2>/dev/null | head -20 | while IFS= read -r errline; do
ERRORS+=("godot: $errline") ERRORS+=("godot: $errline")
@@ -208,15 +212,19 @@ scenario_result="PASS"
for test in tests/*_test.gd; do for test in tests/*_test.gd; do
echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log" echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log"
if need_timeout; then 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" scenario_result="FAIL"
ERRORS+=("scenario: $(basename "$test") failed or timed out") ERRORS+=("scenario: $(basename "$test") failed or timed out")
fi 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" scenario_result="FAIL"
ERRORS+=("scenario: $(basename "$test") failed") ERRORS+=("scenario: $(basename "$test") failed")
fi fi
done 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 if [[ "$scenario_result" == "FAIL" ]]; then
FIXES+=("Fix failing project scenario tests") FIXES+=("Fix failing project scenario tests")
fi fi
@@ -225,7 +233,7 @@ fi
gut_result="SKIPPED" gut_result="SKIPPED"
: > "$LOG/gut.log" : > "$LOG/gut.log"
if [[ -f "addons/gut/gut_cmdln.gd" ]]; then 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" gut_result="PASS"
else else
gut_result="FAIL" gut_result="FAIL"
@@ -254,8 +262,9 @@ echo " godot-check $godot_result"
echo " scenarios $scenario_result" echo " scenarios $scenario_result"
echo " gut $gut_result" echo " gut $gut_result"
seen_err=() seen_err=("__quality_sentinel__")
for e in "${ERRORS[@]}"; do for e in "${ERRORS[@]:-}"; do
[[ -z "$e" ]] && continue
contains_element "$e" "${seen_err[@]}" && continue contains_element "$e" "${seen_err[@]}" && continue
seen_err+=("$e") seen_err+=("$e")
echo " $e" echo " $e"
@@ -264,9 +273,10 @@ done
if $OVERALL && [[ ${#FIXES[@]} -gt 0 ]]; then if $OVERALL && [[ ${#FIXES[@]} -gt 0 ]]; then
echo "" echo ""
echo " NEXT FIX:" echo " NEXT FIX:"
seen_fix=() seen_fix=("__quality_sentinel__")
idx=1 idx=1
for fix in "${FIXES[@]}"; do for fix in "${FIXES[@]:-}"; do
[[ -z "$fix" ]] && continue
contains_element "$fix" "${seen_fix[@]}" && continue contains_element "$fix" "${seen_fix[@]}" && continue
seen_fix+=("$fix") seen_fix+=("$fix")
echo " $idx. $fix" echo " $idx. $fix"