fix: enforce village resource invariants
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -271,10 +271,10 @@ 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(
|
||||||
@@ -711,13 +711,16 @@ 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:
|
||||||
|
return false
|
||||||
_record_economic_event(
|
_record_economic_event(
|
||||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||||
npc.id,
|
npc.id,
|
||||||
@@ -728,6 +731,7 @@ func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> void:
|
|||||||
)
|
)
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id])
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user