fix: enforce village resource invariants
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user