fix: enforce storage and tooling invariants
This commit is contained in:
+5
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
# Godot C# / Mono generated files
|
# Godot C# / Mono generated files
|
||||||
.mono/
|
.mono/
|
||||||
data_*/
|
/data_*/
|
||||||
mono_crash.*
|
mono_crash.*
|
||||||
*.csproj
|
*.csproj
|
||||||
*.sln
|
*.sln
|
||||||
@@ -57,7 +57,11 @@ crash_handler*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
.LSOverride
|
.LSOverride
|
||||||
|
# Finder's custom-folder icon file ends with a carriage return. Keep this
|
||||||
|
# broad match, but explicitly allow real source directories named "icons".
|
||||||
Icon?
|
Icon?
|
||||||
|
!**/icons/
|
||||||
|
!**/icons/**
|
||||||
._*
|
._*
|
||||||
.DocumentRevisions-V100/
|
.DocumentRevisions-V100/
|
||||||
.Spotlight-V100/
|
.Spotlight-V100/
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# The Steward
|
||||||
|
|
||||||
|
The Steward is a Godot 4.7 simulation prototype about readable autonomous
|
||||||
|
village life in a magical Jajce-inspired valley. The project is built as a
|
||||||
|
living systems garden: simulation state is authoritative, while scenes provide
|
||||||
|
presentation, navigation, and interaction geometry.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Godot 4.7.x;
|
||||||
|
- Python 3;
|
||||||
|
- the pinned development tools from `requirements-dev.txt`.
|
||||||
|
|
||||||
|
Set up the local tools with `uv`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv .venv
|
||||||
|
uv pip install --python .venv/bin/python -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `project.godot` in Godot 4.7 and run the main scene.
|
||||||
|
|
||||||
|
## Quality gate
|
||||||
|
|
||||||
|
Run the complete local gate before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/quality.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\tools\quality.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
For a faster changed-GDScript formatting and lint pass, while still running
|
||||||
|
the Godot checks and scenarios:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/quality.sh --changed
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate enforces Godot 4.7, the pinned formatter and linter, headless project
|
||||||
|
scenarios, and the vendored GUT suite. See
|
||||||
|
[`docs/local_quality_gate.md`](docs/local_quality_gate.md) for setup and
|
||||||
|
troubleshooting.
|
||||||
|
|
||||||
|
## Project guide
|
||||||
|
|
||||||
|
- [`AGENTS.md`](AGENTS.md) — development loop, architecture rules, validation,
|
||||||
|
documentation, and Git expectations;
|
||||||
|
- [`docs/PROJECT_CONTEXT.md`](docs/PROJECT_CONTEXT.md) — vision, current
|
||||||
|
implementation, and constraints;
|
||||||
|
- [`docs/ARCHITECTURE_OVERVIEW.md`](docs/ARCHITECTURE_OVERVIEW.md) — runtime
|
||||||
|
ownership and dependency map;
|
||||||
|
- [`docs/LEARNING_ROADMAP.md`](docs/LEARNING_ROADMAP.md) — milestone order and
|
||||||
|
the current next slice;
|
||||||
|
- [`docs/README.md`](docs/README.md) — complete documentation map.
|
||||||
|
|
||||||
|
Start with the current code and tests when they disagree with a plan, preserve
|
||||||
|
in-progress work, and prefer one complete gameplay proof over a broad
|
||||||
|
abstraction.
|
||||||
@@ -78,7 +78,16 @@ NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds
|
|||||||
StorageStateRecord entries; world-schema v1 migrates legacy village food and
|
StorageStateRecord entries; world-schema v1 migrates legacy village food and
|
||||||
wood into the stable `village_pantry` and `village_woodpile` records. Parsed
|
wood into the stable `village_pantry` and `village_woodpile` records. Parsed
|
||||||
storage values are canonicalized so save/restore continuation retains
|
storage values are canonicalized so save/restore continuation retains
|
||||||
byte-stable checksums.
|
byte-stable checksums. Current parsing rejects empty item IDs, negative or
|
||||||
|
non-finite amounts, negative or non-finite capacity, and contents above
|
||||||
|
capacity. Runtime deposit and withdrawal requests also reject empty item IDs
|
||||||
|
and non-finite amounts without mutating authoritative stock. When the authored
|
||||||
|
pantry or woodpile is present, current world parsing also requires its
|
||||||
|
authoritative food or wood amount to match the duplicated village summary;
|
||||||
|
contradictory saves are rejected instead of silently changing checksum during
|
||||||
|
restore. World-v1 migration expands a storage's inferred capacity when needed
|
||||||
|
so valid legacy stock above the current default is preserved rather than
|
||||||
|
discarded.
|
||||||
|
|
||||||
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
|
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
|
||||||
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
|
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ parser errors, and LLM-generated garbage before committing.
|
|||||||
```
|
```
|
||||||
|
|
||||||
On Windows, use `.venv/Scripts/python.exe` for the `--python` value.
|
On Windows, use `.venv/Scripts/python.exe` for the `--python` value.
|
||||||
- **Godot 4** binary on `PATH`, or set the `GODOT_BIN` environment variable
|
- **Godot 4.7.x** binary on `PATH`, or set the `GODOT_BIN` environment
|
||||||
|
variable. The gate rejects other engine series before importing or testing.
|
||||||
- **GUT 9.7.1** is vendored under `addons/gut` for Godot 4.7. No separate
|
- **GUT 9.7.1** is vendored under `addons/gut` for Godot 4.7. No separate
|
||||||
package-manager install is needed.
|
package-manager install is needed.
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ The downloaded release archive SHA-256 is
|
|||||||
`14969aa46adc84aa08cdd21b9f6d1a64addd92ae60b36f02d0521ed305aa4086`.
|
`14969aa46adc84aa08cdd21b9f6d1a64addd92ae60b36f02d0521ed305aa4086`.
|
||||||
|
|
||||||
The quality and format scripts discover `.venv` automatically; activating it
|
The quality and format scripts discover `.venv` automatically; activating it
|
||||||
is optional.
|
is optional. Missing `gdformat` or `gdlint` is a gate failure rather than a
|
||||||
|
silent skip.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ If everything passes:
|
|||||||
|
|
||||||
```
|
```
|
||||||
QUALITY RESULT: PASS
|
QUALITY RESULT: PASS
|
||||||
|
Godot: 4.7.stable.official.5b4e0cb0f
|
||||||
|
|
||||||
godot-import PASS
|
godot-import PASS
|
||||||
gdformat PASS
|
gdformat PASS
|
||||||
@@ -87,6 +90,7 @@ On failure, only relevant errors and fix suggestions are shown:
|
|||||||
|
|
||||||
```
|
```
|
||||||
QUALITY RESULT: FAIL
|
QUALITY RESULT: FAIL
|
||||||
|
Godot: 4.7.stable.official.5b4e0cb0f
|
||||||
|
|
||||||
godot-import PASS
|
godot-import PASS
|
||||||
gdformat FAIL
|
gdformat FAIL
|
||||||
@@ -113,18 +117,20 @@ to the terminal.
|
|||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GODOT_BIN` | Path to the Godot executable (auto-detected from PATH if unset) |
|
| `GODOT_BIN` | Path to the Godot 4.7.x executable (auto-detected if unset) |
|
||||||
|
|
||||||
## CI setup
|
## CI setup
|
||||||
|
|
||||||
To add this to CI, install the dependencies and run:
|
To add this to CI, provide a Godot 4.7.x executable, install the dependencies,
|
||||||
|
and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -r requirements-dev.txt
|
python -m pip install -r requirements-dev.txt
|
||||||
./tools/quality.sh
|
./tools/quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script exits non-zero on any failure, so it will fail the CI step.
|
The script exits non-zero on any failure, including a wrong Godot series or
|
||||||
|
missing pinned formatter/linter, so it will fail the CI step.
|
||||||
|
|
||||||
The shell gate isolates Godot's cross-platform user-data paths under
|
The shell gate isolates Godot's cross-platform user-data paths under
|
||||||
`logs/quality/godot_profile`. Before running scenarios it imports project and
|
`logs/quality/godot_profile`. Before running scenarios it imports project and
|
||||||
@@ -134,4 +140,5 @@ parse or load markers are failures because headless Godot can report those
|
|||||||
errors while returning a zero process exit code. Every Godot subprocess has a
|
errors while returning a zero process exit code. Every Godot subprocess has a
|
||||||
portable watchdog on stock macOS as well as Linux/Windows, and nonzero
|
portable watchdog on stock macOS as well as Linux/Windows, and nonzero
|
||||||
`gdformat`/`gdlint` exits fail the gate even when their output is not a familiar
|
`gdformat`/`gdlint` exits fail the gate even when their output is not a familiar
|
||||||
diagnostic string.
|
diagnostic string. Import receives a 60-second timeout on both shell and
|
||||||
|
PowerShell; the focused checks retain their 30-second timeout.
|
||||||
|
|||||||
@@ -288,6 +288,26 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
storage_ids[storage_id] = true
|
storage_ids[storage_id] = true
|
||||||
storage_records_by_id[storage_id] = storage_record
|
storage_records_by_id[storage_id] = storage_record
|
||||||
record.storages.append(storage_record)
|
record.storages.append(storage_record)
|
||||||
|
var pantry := (
|
||||||
|
storage_records_by_id.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
pantry != null
|
||||||
|
and not is_equal_approx(
|
||||||
|
float(record.village.data["food"]), pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
var woodpile := (
|
||||||
|
storage_records_by_id.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
woodpile != null
|
||||||
|
and not is_equal_approx(
|
||||||
|
float(record.village.data["wood"]), woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
|
||||||
var event_ids := {}
|
var event_ids := {}
|
||||||
var event_records_by_id := {}
|
var event_records_by_id := {}
|
||||||
@@ -689,7 +709,8 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
StorageStateRecord
|
StorageStateRecord
|
||||||
. create(
|
. create(
|
||||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
{String(SimulationIds.RESOURCE_FOOD): initial_food}
|
{String(SimulationIds.RESOURCE_FOOD): initial_food},
|
||||||
|
maxf(StorageStateRecord.DEFAULT_CAPACITY, initial_food)
|
||||||
)
|
)
|
||||||
. to_dictionary()
|
. to_dictionary()
|
||||||
),
|
),
|
||||||
@@ -697,7 +718,8 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
StorageStateRecord
|
StorageStateRecord
|
||||||
. create(
|
. create(
|
||||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||||
{String(SimulationIds.RESOURCE_WOOD): initial_wood}
|
{String(SimulationIds.RESOURCE_WOOD): initial_wood},
|
||||||
|
maxf(StorageStateRecord.DEFAULT_CAPACITY, initial_wood)
|
||||||
)
|
)
|
||||||
. to_dictionary()
|
. to_dictionary()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class_name StorageStateRecord
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_VERSION := 1
|
const SCHEMA_VERSION := 1
|
||||||
|
const DEFAULT_CAPACITY := 100.0
|
||||||
|
|
||||||
var data: Dictionary
|
var data: Dictionary
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ func _init(record_data: Dictionary = {}) -> void:
|
|||||||
|
|
||||||
|
|
||||||
static func create(
|
static func create(
|
||||||
storage_id: StringName, initial_amounts: Dictionary, capacity: float = 100.0
|
storage_id: StringName, initial_amounts: Dictionary, capacity: float = DEFAULT_CAPACITY
|
||||||
) -> StorageStateRecord:
|
) -> StorageStateRecord:
|
||||||
return StorageStateRecord.new(
|
return StorageStateRecord.new(
|
||||||
{
|
{
|
||||||
@@ -32,12 +33,28 @@ static func from_dictionary(record_data: Dictionary) -> StorageStateRecord:
|
|||||||
return null
|
return null
|
||||||
if not record_data["amounts"] is Dictionary:
|
if not record_data["amounts"] is Dictionary:
|
||||||
return null
|
return null
|
||||||
|
var capacity := float(record_data["capacity"])
|
||||||
|
if not is_finite(capacity) or capacity < 0.0:
|
||||||
|
return null
|
||||||
var normalized := record_data.duplicate(true)
|
var normalized := record_data.duplicate(true)
|
||||||
normalized["schema_version"] = SCHEMA_VERSION
|
normalized["schema_version"] = SCHEMA_VERSION
|
||||||
normalized["capacity"] = float(record_data["capacity"])
|
normalized["capacity"] = capacity
|
||||||
var normalized_amounts := {}
|
var normalized_amounts := {}
|
||||||
for item_id in record_data["amounts"]:
|
var total_amount := 0.0
|
||||||
normalized_amounts[String(item_id)] = float(record_data["amounts"][item_id])
|
for raw_item_id in record_data["amounts"]:
|
||||||
|
var item_id := String(raw_item_id)
|
||||||
|
var amount := float(record_data["amounts"][raw_item_id])
|
||||||
|
if (
|
||||||
|
item_id.is_empty()
|
||||||
|
or normalized_amounts.has(item_id)
|
||||||
|
or not is_finite(amount)
|
||||||
|
or amount < 0.0
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
total_amount += amount
|
||||||
|
if not is_finite(total_amount) or total_amount > capacity:
|
||||||
|
return null
|
||||||
|
normalized_amounts[item_id] = amount
|
||||||
normalized["amounts"] = normalized_amounts
|
normalized["amounts"] = normalized_amounts
|
||||||
return StorageStateRecord.new(normalized)
|
return StorageStateRecord.new(normalized)
|
||||||
|
|
||||||
@@ -62,6 +79,8 @@ func get_available_capacity() -> float:
|
|||||||
|
|
||||||
|
|
||||||
func deposit(item_id: StringName, requested_amount: float) -> float:
|
func deposit(item_id: StringName, requested_amount: float) -> float:
|
||||||
|
if item_id.is_empty() or not is_finite(requested_amount) or requested_amount <= 0.0:
|
||||||
|
return 0.0
|
||||||
var accepted := minf(maxf(requested_amount, 0.0), get_available_capacity())
|
var accepted := minf(maxf(requested_amount, 0.0), get_available_capacity())
|
||||||
if accepted <= 0.0:
|
if accepted <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -70,6 +89,8 @@ func deposit(item_id: StringName, requested_amount: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
func withdraw(item_id: StringName, requested_amount: float) -> float:
|
func withdraw(item_id: StringName, requested_amount: float) -> float:
|
||||||
|
if item_id.is_empty() or not is_finite(requested_amount) or requested_amount <= 0.0:
|
||||||
|
return 0.0
|
||||||
var removed := minf(maxf(requested_amount, 0.0), get_amount(item_id))
|
var removed := minf(maxf(requested_amount, 0.0), get_amount(item_id))
|
||||||
if removed <= 0.0:
|
if removed <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func _run() -> void:
|
|||||||
_test_relationship_schema_rejection()
|
_test_relationship_schema_rejection()
|
||||||
_test_opportunity_schema_rejection()
|
_test_opportunity_schema_rejection()
|
||||||
_test_animal_schema_rejection()
|
_test_animal_schema_rejection()
|
||||||
|
_test_storage_schema_rejection()
|
||||||
_test_schema_rejection()
|
_test_schema_rejection()
|
||||||
|
|
||||||
if failures.is_empty():
|
if failures.is_empty():
|
||||||
@@ -154,6 +155,80 @@ func _test_schema_rejection() -> void:
|
|||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_storage_schema_rejection() -> void:
|
||||||
|
var valid_storage := (
|
||||||
|
StorageStateRecord
|
||||||
|
. create(&"schema_test_storage", {String(SimulationIds.RESOURCE_FOOD): 1.0}, 2.0)
|
||||||
|
. to_dictionary()
|
||||||
|
)
|
||||||
|
|
||||||
|
var negative_amount := valid_storage.duplicate(true)
|
||||||
|
negative_amount["amounts"][String(SimulationIds.RESOURCE_FOOD)] = -1.0
|
||||||
|
_check(
|
||||||
|
StorageStateRecord.from_dictionary(negative_amount) == null,
|
||||||
|
"Storage records should reject negative item amounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
var non_finite_amount := valid_storage.duplicate(true)
|
||||||
|
non_finite_amount["amounts"][String(SimulationIds.RESOURCE_FOOD)] = NAN
|
||||||
|
_check(
|
||||||
|
StorageStateRecord.from_dictionary(non_finite_amount) == null,
|
||||||
|
"Storage records should reject non-finite item amounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
var invalid_capacity := valid_storage.duplicate(true)
|
||||||
|
invalid_capacity["capacity"] = -1.0
|
||||||
|
_check(
|
||||||
|
StorageStateRecord.from_dictionary(invalid_capacity) == null,
|
||||||
|
"Storage records should reject negative capacity"
|
||||||
|
)
|
||||||
|
|
||||||
|
var over_capacity := valid_storage.duplicate(true)
|
||||||
|
over_capacity["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 3.0
|
||||||
|
_check(
|
||||||
|
StorageStateRecord.from_dictionary(over_capacity) == null,
|
||||||
|
"Storage records should reject contents above capacity"
|
||||||
|
)
|
||||||
|
|
||||||
|
var empty_item_id := valid_storage.duplicate(true)
|
||||||
|
empty_item_id["amounts"] = {"": 1.0}
|
||||||
|
_check(
|
||||||
|
StorageStateRecord.from_dictionary(empty_item_id) == null,
|
||||||
|
"Storage records should reject empty item IDs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var storage := StorageStateRecord.from_dictionary(valid_storage)
|
||||||
|
_check(storage != null, "A valid bounded storage record should still parse")
|
||||||
|
if storage != null:
|
||||||
|
_check(
|
||||||
|
storage.deposit(SimulationIds.RESOURCE_FOOD, NAN) == 0.0,
|
||||||
|
"Storage deposits should reject non-finite requests"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
storage.withdraw(SimulationIds.RESOURCE_FOOD, INF) == 0.0,
|
||||||
|
"Storage withdrawals should reject non-finite requests"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
is_equal_approx(storage.get_amount(SimulationIds.RESOURCE_FOOD), 1.0),
|
||||||
|
"Rejected storage requests should not mutate authoritative stock"
|
||||||
|
)
|
||||||
|
|
||||||
|
var manager := _create_manager(42)
|
||||||
|
var mismatched_food: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
mismatched_food["village"]["food"] = float(mismatched_food["village"]["food"]) + 1.0
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(mismatched_food) == null,
|
||||||
|
"World records should reject village food that disagrees with pantry authority"
|
||||||
|
)
|
||||||
|
var mismatched_wood: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
mismatched_wood["village"]["wood"] = float(mismatched_wood["village"]["wood"]) + 1.0
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(mismatched_wood) == null,
|
||||||
|
"World records should reject village wood that disagrees with woodpile authority"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
func _test_legacy_resource_migration() -> void:
|
func _test_legacy_resource_migration() -> void:
|
||||||
var migrated := ResourceStateRecord.from_dictionary(
|
var migrated := ResourceStateRecord.from_dictionary(
|
||||||
{
|
{
|
||||||
@@ -205,6 +280,10 @@ func _test_legacy_world_storage_migration() -> void:
|
|||||||
var legacy_data: Dictionary = manager.create_state_record().to_dictionary()
|
var legacy_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION
|
legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION
|
||||||
legacy_data.erase("storages")
|
legacy_data.erase("storages")
|
||||||
|
var legacy_food := StorageStateRecord.DEFAULT_CAPACITY + 25.0
|
||||||
|
var legacy_wood := StorageStateRecord.DEFAULT_CAPACITY + 35.0
|
||||||
|
legacy_data["village"]["food"] = legacy_food
|
||||||
|
legacy_data["village"]["wood"] = legacy_wood
|
||||||
var migrated := SimulationStateRecord.from_dictionary(legacy_data)
|
var migrated := SimulationStateRecord.from_dictionary(legacy_data)
|
||||||
_check(migrated != null, "World schema v1 should migrate authored storage")
|
_check(migrated != null, "World schema v1 should migrate authored storage")
|
||||||
if migrated != null:
|
if migrated != null:
|
||||||
@@ -219,14 +298,12 @@ func _test_legacy_world_storage_migration() -> void:
|
|||||||
(
|
(
|
||||||
pantry != null
|
pantry != null
|
||||||
and woodpile != null
|
and woodpile != null
|
||||||
and is_equal_approx(
|
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), legacy_food)
|
||||||
pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food
|
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), legacy_wood)
|
||||||
)
|
and is_equal_approx(pantry.get_available_capacity(), 0.0)
|
||||||
and is_equal_approx(
|
and is_equal_approx(woodpile.get_available_capacity(), 0.0)
|
||||||
woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"World migration should preserve legacy village resources in their storage"
|
"World migration should preserve legacy stock above the default storage capacity"
|
||||||
)
|
)
|
||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
@@ -842,6 +919,7 @@ func _test_opportunity_schema_rejection() -> void:
|
|||||||
for storage_data in already_supplied["storages"]:
|
for storage_data in already_supplied["storages"]:
|
||||||
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
||||||
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 1.0
|
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 1.0
|
||||||
|
already_supplied["village"]["food"] = 1.0
|
||||||
_check(
|
_check(
|
||||||
SimulationStateRecord.from_dictionary(already_supplied) == null,
|
SimulationStateRecord.from_dictionary(already_supplied) == null,
|
||||||
"An open pantry need should reject a save whose target is already supplied"
|
"An open pantry need should reject a save whose target is already supplied"
|
||||||
@@ -985,6 +1063,7 @@ func _test_opportunity_schema_rejection() -> void:
|
|||||||
func _build_opportunity_world(manager: Node, resolved: bool) -> Dictionary:
|
func _build_opportunity_world(manager: Node, resolved: bool) -> Dictionary:
|
||||||
var world: Dictionary = manager.create_state_record().to_dictionary()
|
var world: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
world["simulation"]["tick_count"] = 10
|
world["simulation"]["tick_count"] = 10
|
||||||
|
world["village"]["food"] = 1.0 if resolved else 0.0
|
||||||
for storage_data in world["storages"]:
|
for storage_data in world["storages"]:
|
||||||
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
||||||
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = (
|
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = (
|
||||||
|
|||||||
+30
-5
@@ -16,6 +16,7 @@ $OVERALL = $false
|
|||||||
$ERRORS = @()
|
$ERRORS = @()
|
||||||
$FIXES = @()
|
$FIXES = @()
|
||||||
$OWNED_GDSCRIPT_ROOTS = @("player", "simulation", "tests", "tools", "world")
|
$OWNED_GDSCRIPT_ROOTS = @("player", "simulation", "tests", "tools", "world")
|
||||||
|
$REQUIRED_GODOT_SERIES = "4.7"
|
||||||
|
|
||||||
# -- tool detection -----------------------------------------------------------
|
# -- tool detection -----------------------------------------------------------
|
||||||
function ConvertTo-ConsoleGodotPath($Path) {
|
function ConvertTo-ConsoleGodotPath($Path) {
|
||||||
@@ -128,7 +129,10 @@ function To-ResPath($p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-GodotWithTimeout {
|
function Invoke-GodotWithTimeout {
|
||||||
param([string]$ArgumentString)
|
param(
|
||||||
|
[string]$ArgumentString,
|
||||||
|
[int]$TimeoutSeconds = 30
|
||||||
|
)
|
||||||
$psi = New-Object Diagnostics.ProcessStartInfo
|
$psi = New-Object Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $GODOT
|
$psi.FileName = $GODOT
|
||||||
$psi.Arguments = $ArgumentString
|
$psi.Arguments = $ArgumentString
|
||||||
@@ -148,7 +152,7 @@ function Invoke-GodotWithTimeout {
|
|||||||
}
|
}
|
||||||
$outputTask = $p.StandardOutput.ReadToEndAsync()
|
$outputTask = $p.StandardOutput.ReadToEndAsync()
|
||||||
$errorTask = $p.StandardError.ReadToEndAsync()
|
$errorTask = $p.StandardError.ReadToEndAsync()
|
||||||
$exited = $p.WaitForExit(30000)
|
$exited = $p.WaitForExit($TimeoutSeconds * 1000)
|
||||||
if (-not $exited) {
|
if (-not $exited) {
|
||||||
$p.Kill()
|
$p.Kill()
|
||||||
$p.WaitForExit()
|
$p.WaitForExit()
|
||||||
@@ -167,6 +171,18 @@ if (-not $GODOT) {
|
|||||||
Write-Host "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH."
|
Write-Host "ERROR: Godot binary not found. Set GODOT_BIN or add godot/godot4 to PATH."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
$godotVersionOutput = @(& $GODOT --version 2>&1)
|
||||||
|
$godotVersionExit = $LASTEXITCODE
|
||||||
|
if ($godotVersionExit -ne 0 -or $godotVersionOutput.Count -eq 0) {
|
||||||
|
Write-Host "ERROR: Could not query the Godot version from '$GODOT'."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$GODOT_VERSION = ([string]$godotVersionOutput[0]).Trim()
|
||||||
|
$requiredVersionPattern = '^' + [regex]::Escape($REQUIRED_GODOT_SERIES) + '([.-]|$)'
|
||||||
|
if ($GODOT_VERSION -notmatch $requiredVersionPattern) {
|
||||||
|
Write-Host "ERROR: Godot $REQUIRED_GODOT_SERIES.x is required; found '$GODOT_VERSION'."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# -- 0. Godot import bootstrap ------------------------------------------------
|
# -- 0. Godot import bootstrap ------------------------------------------------
|
||||||
$importResult = "PASS"
|
$importResult = "PASS"
|
||||||
@@ -182,10 +198,13 @@ $needsImport = (-not $hasProjectClasses) -or
|
|||||||
($changedGd.Count -gt 0)
|
($changedGd.Count -gt 0)
|
||||||
if ($needsImport) {
|
if ($needsImport) {
|
||||||
$importArgs = "--headless --path `"$ROOT`" --import"
|
$importArgs = "--headless --path `"$ROOT`" --import"
|
||||||
$importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs
|
$importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs -TimeoutSeconds 60
|
||||||
$importOutput | Add-Content -Path "$LOG/godot-import.log" -Encoding utf8
|
$importOutput | Add-Content -Path "$LOG/godot-import.log" -Encoding utf8
|
||||||
if ($importExit -eq $null -or $importExit -ne 0) {
|
if ($importExit -eq $null -or $importExit -ne 0) {
|
||||||
$importResult = "FAIL"
|
$importResult = "FAIL"
|
||||||
|
if ($importExit -eq $null) {
|
||||||
|
Add-Content -Path "$LOG/godot-import.log" -Value "[timed out after 60s]" -Encoding utf8
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$hasProjectClasses = (Test-Path $classCache) -and
|
$hasProjectClasses = (Test-Path $classCache) -and
|
||||||
@@ -210,8 +229,10 @@ $fmtResult = "PASS"
|
|||||||
$fmtFailed = $false
|
$fmtFailed = $false
|
||||||
Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8
|
Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8
|
||||||
if (-not $fmtAvail) {
|
if (-not $fmtAvail) {
|
||||||
$fmtResult = "SKIPPED"
|
$fmtResult = "FAIL"
|
||||||
Add-Content -Path "$LOG/gdformat.log" -Value "gdformat not found -- install requirements-dev.txt (see docs/local_quality_gate.md)" -Encoding utf8
|
Add-Content -Path "$LOG/gdformat.log" -Value "gdformat not found -- install requirements-dev.txt (see docs/local_quality_gate.md)" -Encoding utf8
|
||||||
|
$ERRORS += "gdformat: required formatter not found"
|
||||||
|
$FIXES += "Install the pinned dev tools from requirements-dev.txt"
|
||||||
} else {
|
} else {
|
||||||
if ($Changed) {
|
if ($Changed) {
|
||||||
$files = Get-ChangedGd
|
$files = Get-ChangedGd
|
||||||
@@ -260,8 +281,10 @@ $lintResult = "PASS"
|
|||||||
$lintFailed = $false
|
$lintFailed = $false
|
||||||
Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8
|
Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8
|
||||||
if (-not $lintAvail) {
|
if (-not $lintAvail) {
|
||||||
$lintResult = "SKIPPED"
|
$lintResult = "FAIL"
|
||||||
Add-Content -Path "$LOG/gdlint.log" -Value "gdlint not found -- install requirements-dev.txt (see docs/local_quality_gate.md)" -Encoding utf8
|
Add-Content -Path "$LOG/gdlint.log" -Value "gdlint not found -- install requirements-dev.txt (see docs/local_quality_gate.md)" -Encoding utf8
|
||||||
|
$ERRORS += "gdlint: required linter not found"
|
||||||
|
$FIXES += "Install the pinned dev tools from requirements-dev.txt"
|
||||||
} else {
|
} else {
|
||||||
if ($Changed) {
|
if ($Changed) {
|
||||||
$files = Get-ChangedGd
|
$files = Get-ChangedGd
|
||||||
@@ -393,6 +416,7 @@ if ($importResult -eq 'FAIL' -or $fmtResult -eq 'FAIL' -or $lintResult -eq 'FAIL
|
|||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
if ($OVERALL) { Write-Host "QUALITY RESULT: FAIL" } else { Write-Host "QUALITY RESULT: PASS" }
|
if ($OVERALL) { Write-Host "QUALITY RESULT: FAIL" } else { Write-Host "QUALITY RESULT: PASS" }
|
||||||
|
Write-Host "Godot: $GODOT_VERSION"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " godot-import $importResult"
|
Write-Host " godot-import $importResult"
|
||||||
Write-Host " gdformat $fmtResult"
|
Write-Host " gdformat $fmtResult"
|
||||||
@@ -430,6 +454,7 @@ Write-Host ""
|
|||||||
if ($OVERALL) { $resultText = "FAIL" } else { $resultText = "PASS" }
|
if ($OVERALL) { $resultText = "FAIL" } else { $resultText = "PASS" }
|
||||||
$summary = @"
|
$summary = @"
|
||||||
QUALITY RESULT: $resultText
|
QUALITY RESULT: $resultText
|
||||||
|
Godot: $GODOT_VERSION
|
||||||
|
|
||||||
godot-import $importResult
|
godot-import $importResult
|
||||||
gdformat $fmtResult
|
gdformat $fmtResult
|
||||||
|
|||||||
+25
-4
@@ -18,6 +18,7 @@ OVERALL=false
|
|||||||
ERRORS=()
|
ERRORS=()
|
||||||
FIXES=()
|
FIXES=()
|
||||||
OWNED_GDSCRIPT_ROOTS=(player simulation tests tools world)
|
OWNED_GDSCRIPT_ROOTS=(player simulation tests tools world)
|
||||||
|
REQUIRED_GODOT_SERIES="4.7"
|
||||||
|
|
||||||
# -- tool detection -----------------------------------------------------------
|
# -- tool detection -----------------------------------------------------------
|
||||||
console_godot_path() {
|
console_godot_path() {
|
||||||
@@ -50,6 +51,7 @@ find_godot() {
|
|||||||
if [[ -n "${GODOT_BIN:-}" ]]; then console_godot_path "$GODOT_BIN" && return 0; echo "$GODOT_BIN"; return 0; fi
|
if [[ -n "${GODOT_BIN:-}" ]]; then console_godot_path "$GODOT_BIN" && return 0; echo "$GODOT_BIN"; return 0; fi
|
||||||
find_project_godot && return 0
|
find_project_godot && return 0
|
||||||
for c in godot godot4; do command -v "$c" &>/dev/null && echo "$c" && return 0; done
|
for c in godot godot4; do command -v "$c" &>/dev/null && echo "$c" && return 0; done
|
||||||
|
console_godot_path "/Applications/Godot.app/Contents/MacOS/Godot" && return 0
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,19 @@ 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
|
||||||
|
if ! GODOT_VERSION="$("$GODOT" --version 2>&1)"; then
|
||||||
|
echo "ERROR: Could not query the Godot version from '$GODOT'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
GODOT_VERSION="${GODOT_VERSION%%$'\n'*}"
|
||||||
|
GODOT_VERSION="${GODOT_VERSION%$'\r'}"
|
||||||
|
case "$GODOT_VERSION" in
|
||||||
|
"$REQUIRED_GODOT_SERIES"|"$REQUIRED_GODOT_SERIES".*|"$REQUIRED_GODOT_SERIES"-*) ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: Godot $REQUIRED_GODOT_SERIES.x is required; found '$GODOT_VERSION'."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
GODOT_ENV=(env "HOME=$GODOT_PROFILE" "APPDATA=$GODOT_PROFILE" "LOCALAPPDATA=$GODOT_PROFILE")
|
GODOT_ENV=(env "HOME=$GODOT_PROFILE" "APPDATA=$GODOT_PROFILE" "LOCALAPPDATA=$GODOT_PROFILE")
|
||||||
|
|
||||||
# -- 0. Godot import bootstrap ------------------------------------------------
|
# -- 0. Godot import bootstrap ------------------------------------------------
|
||||||
@@ -185,8 +200,10 @@ fmt_result="PASS"
|
|||||||
fmt_failed=false
|
fmt_failed=false
|
||||||
: > "$LOG/gdformat.log"
|
: > "$LOG/gdformat.log"
|
||||||
if ! has_gdformat; then
|
if ! has_gdformat; then
|
||||||
fmt_result="SKIPPED"
|
fmt_result="FAIL"
|
||||||
echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log"
|
echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log"
|
||||||
|
ERRORS+=("gdformat: required formatter not found")
|
||||||
|
FIXES+=("Install the pinned dev tools from requirements-dev.txt")
|
||||||
else
|
else
|
||||||
if $CHANGED; then
|
if $CHANGED; then
|
||||||
files=$(changed_gd)
|
files=$(changed_gd)
|
||||||
@@ -231,8 +248,10 @@ lint_result="PASS"
|
|||||||
lint_failed=false
|
lint_failed=false
|
||||||
: > "$LOG/gdlint.log"
|
: > "$LOG/gdlint.log"
|
||||||
if ! has_gdlint; then
|
if ! has_gdlint; then
|
||||||
lint_result="SKIPPED"
|
lint_result="FAIL"
|
||||||
echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log"
|
echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log"
|
||||||
|
ERRORS+=("gdlint: required linter not found")
|
||||||
|
FIXES+=("Install the pinned dev tools from requirements-dev.txt")
|
||||||
else
|
else
|
||||||
if $CHANGED; then
|
if $CHANGED; then
|
||||||
files=$(changed_gd)
|
files=$(changed_gd)
|
||||||
@@ -294,9 +313,9 @@ if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check
|
|||||||
godot_result="FAIL"
|
godot_result="FAIL"
|
||||||
fi
|
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
|
while IFS= read -r errline; do
|
||||||
ERRORS+=("godot: $errline")
|
ERRORS+=("godot: $errline")
|
||||||
done
|
done < <(grep -iE "error|warning|parse|syntax" "$LOG/godot-check.log" 2>/dev/null | head -20)
|
||||||
FIXES+=("Fix Godot parser errors")
|
FIXES+=("Fix Godot parser errors")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -356,6 +375,7 @@ if $OVERALL; then
|
|||||||
else
|
else
|
||||||
echo "QUALITY RESULT: PASS"
|
echo "QUALITY RESULT: PASS"
|
||||||
fi
|
fi
|
||||||
|
echo "Godot: $GODOT_VERSION"
|
||||||
echo ""
|
echo ""
|
||||||
echo " godot-import $import_result"
|
echo " godot-import $import_result"
|
||||||
echo " gdformat $fmt_result"
|
echo " gdformat $fmt_result"
|
||||||
@@ -393,6 +413,7 @@ echo ""
|
|||||||
|
|
||||||
{
|
{
|
||||||
echo "QUALITY RESULT: $($OVERALL && echo FAIL || echo PASS)"
|
echo "QUALITY RESULT: $($OVERALL && echo FAIL || echo PASS)"
|
||||||
|
echo "Godot: $GODOT_VERSION"
|
||||||
echo ""
|
echo ""
|
||||||
echo " godot-import $import_result"
|
echo " godot-import $import_result"
|
||||||
echo " gdformat $fmt_result"
|
echo " gdformat $fmt_result"
|
||||||
|
|||||||
Reference in New Issue
Block a user