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
- `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).
+8 -9
View File
@@ -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
+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 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.
+17 -6
View File
@@ -271,10 +271,10 @@ func simulate_tick() -> void:
):
village.apply_npc_task(npc)
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)
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)
elif debug_logs:
print(
@@ -711,13 +711,16 @@ 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:
if consumed < 1.0:
return false
_record_economic_event(
SimulationIds.EVENT_ITEM_CONSUMED,
npc.id,
@@ -728,6 +731,7 @@ func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> void:
)
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)
@@ -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
+14
View File
@@ -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:
+46
View File
@@ -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()
+2 -1
View File
@@ -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]
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."
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"