commit 423fd6dd2e75da926e38b35ac8c435e462b168d9 Author: Codex Date: Wed Sep 9 07:51:16 2026 +0900 Preserve approved pixel tower prototype as development baseline diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..af408bd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.gd] +indent_style = tab + +[*.{json,md,yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..21c4ee5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.ogg binary +*.wav binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96c401a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Godot generated files +.godot/ + +# Builds +build/ +dist/ + +# Local editor files +.DS_Store +*.tmp +*.translation + +# Generated visual QA captures +artifacts/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..682e0cb --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "geequlim.godot-tools" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ead8523 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.insertSpaces": false, + "editor.detectIndentation": false, + "files.trimTrailingWhitespace": true, + "godotTools.editorPath.godot4": "/Applications/Godot.app/Contents/MacOS/Godot" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ac4c6cf --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,19 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "The Tree: Test", + "type": "shell", + "command": "make test", + "group": "test", + "problemMatcher": [] + }, + { + "label": "The Tree: Run", + "type": "shell", + "command": "make run", + "group": "build", + "problemMatcher": [] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bfbbaee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# 制作方針 + +- このゲームの制作では、Kenneyのアセット(https://kenney.nl/assets)を積極的に利用する。UI、操作キー表示、効果音、汎用の2D素材などを追加・差し替えする際は、まず適したKenney素材を探す。 +- 既存の世界観、視認性、素材同士の統一感を考慮して選定する。 +- 導入したパックの配布元URLとライセンスを記録し、同梱のライセンス文書を保存する。 +- 通常の制作作業はローカルの `/Users/korizatou114/Projects/とりにく工房ゲーム制作部` で行う。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..256c5dd --- /dev/null +++ b/Makefile @@ -0,0 +1,114 @@ +GODOT ?= godot + +.PHONY: editor run test test-s0 test-s1 test-s2 test-s3 test-s4 test-s5 test-s6 test-mvp test-acceptance test-ui test-perf test-perf-full check export-windows + +editor: + $(GODOT) --editor --path . + +run: + $(GODOT) --path . + +test: + $(MAKE) test-s0 + $(MAKE) test-s1 + $(MAKE) test-s2 + $(MAKE) test-s3 + $(MAKE) test-s4 + $(MAKE) test-s5 + $(MAKE) test-s6 + $(MAKE) test-mvp + $(MAKE) test-acceptance + $(MAKE) test-ui + $(MAKE) test-perf + +test-s0: + $(GODOT) --headless --path . --script res://tests/run_tests.gd + $(GODOT) --headless --path . --script res://tests/s0/test_definition_registry.gd + $(GODOT) --headless --path . --script res://tests/s0/test_command_transaction.gd + $(GODOT) --headless --path . --script res://tests/s0/test_determinism.gd + $(GODOT) --headless --path . --script res://tests/s0/test_stable_id_generator.gd + $(GODOT) --headless --path . --script res://tests/s0/test_domain_events.gd + $(GODOT) --headless --path . --script res://tests/s0/test_runtime_integration.gd + +test-s1: + $(GODOT) --headless --path . --script res://tests/s1/test_world_service.gd + $(GODOT) --headless --path . --script res://tests/s1/test_path_service.gd + $(GODOT) --headless --path . --script res://tests/s1/test_path_congestion.gd + $(GODOT) --headless --path . --script res://tests/s1/test_construction_service.gd + $(GODOT) --headless --path . --script res://tests/s1/test_excavation_service.gd + $(GODOT) --headless --path . --script res://tests/s1/test_spatial_subslot_service.gd + $(GODOT) --headless --path . --script res://tests/s1/test_route_subslot_path_adapter.gd + $(GODOT) --headless --path . --script res://tests/s1/test_facility_infrastructure_projector.gd + $(GODOT) --headless --path . --script res://tests/s1/test_runtime_infrastructure_integration.gd + $(GODOT) --headless --path . --script res://tests/s1/test_runtime_world_construction.gd + +test-s2: + $(GODOT) --headless --path . --script res://tests/s2/test_facility_service.gd + $(GODOT) --headless --path . --script res://tests/s2/test_inventory_logistics.gd + $(GODOT) --headless --path . --script res://tests/s2/test_economy_service.gd + $(GODOT) --headless --path . --script res://tests/s2/test_ledger_backed_resources.gd + $(GODOT) --headless --path . --script res://tests/s2/test_mana_network_service.gd + $(GODOT) --headless --path . --script res://tests/s2/test_runtime_operations_cycle.gd + +test-s3: + $(GODOT) --headless --path . --script res://tests/s3/test_guest_service.gd + $(GODOT) --headless --path . --script res://tests/s3/test_staff_service.gd + $(GODOT) --headless --path . --script res://tests/s3/test_staff_payroll_operations.gd + $(GODOT) --headless --path . --script res://tests/s3/test_runtime_actor_cycle.gd + $(GODOT) --headless --path . --script res://tests/s3/test_runtime_actor_hardening.gd + $(GODOT) --headless --path . --script res://tests/s3/test_runtime_path_traffic.gd + +test-s4: + $(GODOT) --headless --path . --script res://tests/s4/test_relationship_event_service.gd + $(GODOT) --headless --path . --script res://tests/s4/test_placement_risk_preview_service.gd + $(GODOT) --headless --path . --script res://tests/s4/test_runtime_relationship_events.gd + +test-s5: + $(GODOT) --headless --path . --script res://tests/s5/test_authority_service.gd + $(GODOT) --headless --path . --script res://tests/s5/test_defense_service.gd + $(GODOT) --headless --path . --script res://tests/s5/test_runtime_authority_cycle.gd + $(GODOT) --headless --path . --script res://tests/s5/test_runtime_defense_cycle.gd + +test-s6: + $(GODOT) --headless --path . --script res://tests/s6/test_save_operations.gd + $(GODOT) --headless --path . --script res://tests/s6/test_performance_budget.gd + $(GODOT) --headless --path . --script res://tests/s6/test_runtime_save_lifecycle.gd + +test-mvp: + $(GODOT) --headless --path . --script res://tests/mvp/test_operations_coordinator.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_mvp_content_catalog.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_progression_service.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_content_progression.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_operations_integration.gd + +test-acceptance: + $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_operations_acceptance.gd + +test-ui: + $(MAKE) test-pixel-ui + $(GODOT) --headless --path . --script res://tests/ui/test_main_dashboard.gd + +test-perf: + $(GODOT) --headless --path . --script res://tests/performance/test_perf_mvp_001.gd + +test-perf-full: + PERF_MVP_FULL=1 $(GODOT) --headless --path . --script res://tests/performance/test_perf_mvp_001.gd + +check: + $(GODOT) --headless --editor --path . --quit + $(GODOT) --headless --path . --quit-after 2 + +export-windows: + mkdir -p build/windows + $(GODOT) --headless --path . --export-release "Windows Desktop" + +# Pixel cutaway: interactive disposable inn, capture and input regression. +.PHONY: preview-pixel capture-pixel test-pixel-ui +preview-pixel: + THE_TREE_PIXEL_PLAY=1 $(GODOT) --path . --script res://tests/ui/capture_pixel_tower.gd + +capture-pixel: + $(GODOT) --path . --script res://tests/ui/capture_pixel_tower.gd + +test-pixel-ui: + $(GODOT) --headless --path . --script res://tests/ui/test_pixel_tower.gd diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d6d89 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# The Tree + +『The Tree』のゲーム制作環境です。Godot 4.7系、GDScript、2Dサイドビューを採用しています。 + +## 起動 + + make editor + +Godotが開いたら、右上の実行ボタンまたは `F5` でプロジェクトを起動できます。エディタを開かず直接実行する場合は `make run` を使用します。 + +## ドット絵の世界樹を見る + +『The Tower SP』のように断面の施設と小さな人物の営みを眺める方向で、マップをドット絵主体へ変更しました。KenneyのTiny Dungeon・Tiny Townの16px素材と、専用の部屋・家具描画を組み合わせています。従来の大きな生成背景は現在のマップ描画では使いません。上部の情報と操作バーを小さくし、建築・運営パネルは必要なときに開きます。 + + make preview-pixel # 受付・食堂・客室と実際の客・従業員が動く見学モード + make capture-pixel # 時間差のPNG 2枚と運営状態JSONを artifacts/pixel-tower に出力 + make test-pixel-ui # ズーム、移動、実施設クリック、アニメーション停止の回帰テスト + +見学モードは専用の仮設シナリオで、通常の新規ゲーム・セーブには反映しません。操作、確認できる内容、現時点の制約は [ドット絵表示と見学ガイド](docs/PIXEL_TOWER_VISUALS.md) を参照してください。 + +## 確認と書き出し + + make test # S0〜S6と操作画面の全Headless自動テスト + make test-acceptance # 24時間実Tickと14日運営境界の受入試験 + make test-perf-full # 200客・50従業員・150施設の24時間完全負荷試験 + make check # 読み込みとメイン画面の起動確認 + make export-windows # Windows版を build/windows に生成 + +## 設計方針 + +マップは次の2軸を混同しないように実装します。 + +- ゾーン:地上/境界/地下。合法性、客層、環境効果を決める。 +- レイヤー:フロント/バック。客用空間と従業員・物流空間を決める。 + +ゲーム状態と画面を分離しているため、施設、客AI、シフト、関係状態を追加してもUI全体を書き直す必要はありません。現在は、決定的な時間進行、世界・掘削・実通路投影・建築、施設Queue、在庫・物流、Mana、複式台帳、客と従業員、雇用候補20人の選択画面、日次給与と破綻、関係Event、行政監査、世代保存とMigrationまでを実装しています。内部の状態遷移と異常時の扱いは [詳細システム仕様書](docs/SYSTEM_SPECIFICATION.md)、コード構成は [アーキテクチャ](docs/ARCHITECTURE.md)、実装済み範囲は [実装状況](docs/IMPLEMENTATION_STATUS.md) を参照してください。 + +## 仕様書 + +- [全体制作計画](docs/DEVELOPMENT_PLAN.md) + +- [ドット絵表示と見学ガイド](docs/PIXEL_TOWER_VISUALS.md) +- [画像アセット制作・差し替え仕様書](docs/ART_ASSET_PRODUCTION_SPECIFICATION.md) +- [試遊ガイド](docs/PLAYTEST_GUIDE.md) +- [ゲーム仕様書](docs/GAME_SPECIFICATION.md) +- [詳細システム仕様書](docs/SYSTEM_SPECIFICATION.md) +- [設計判断記録](docs/DESIGN_DECISIONS.md) +- [アーキテクチャ](docs/ARCHITECTURE.md) +- [実装状況](docs/IMPLEMENTATION_STATUS.md) +- [自動テスト対応表](docs/TEST_MATRIX.md) +- [MVP 14日受入シナリオ](docs/MVP_ACCEPTANCE_SCENARIOS.md) +- [MVP施設カタログ注記](docs/data/FACILITY_CATALOG.md) +- [施設一覧・レイヤー分類](docs/FACILITY_LIST.md) +- [施設詳細データ設計](docs/FACILITY_DATA_DESIGN.md) diff --git a/assets/icons/project_icon.svg b/assets/icons/project_icon.svg new file mode 100644 index 0000000..8e8d8b7 --- /dev/null +++ b/assets/icons/project_icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/icons/project_icon.svg.import b/assets/icons/project_icon.svg.import new file mode 100644 index 0000000..b27eb6a --- /dev/null +++ b/assets/icons/project_icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://e81rc82dmmk2" +path="res://.godot/imported/project_icon.svg-ef22e425687afbb6208aaeea95a96443.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/project_icon.svg" +dest_files=["res://.godot/imported/project_icon.svg-ef22e425687afbb6208aaeea95a96443.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/kenney/README.md b/assets/kenney/README.md new file mode 100644 index 0000000..22f26de --- /dev/null +++ b/assets/kenney/README.md @@ -0,0 +1,71 @@ +# Kenney pixel assets for The Tree + +Imported from Kenney's official asset pages on 2026-09-09. Original archive contents are preserved inside each pack directory, including the original `License.txt` files. No existing project art was replaced. + +## Packs and licenses + +| Pack | Version | Source | Intended use | +| --- | --- | --- | --- | +| Tiny Town | 1.1 | https://kenney.nl/assets/tiny-town | Village details, timber walls, doors, vegetation, signs, barrels and tools | +| Tiny Dungeon | 1.0 | https://kenney.nl/assets/tiny-dungeon | Small fantasy guests/staff, stone walls, doors, chests, bottles and props | + +Both official pages and the included licenses identify these packs as **Creative Commons Zero (CC0)**. Attribution is optional; crediting “Kenney — https://kenney.nl/” is appreciated. Preserve the included license documents when redistributing source assets. Official download URLs, archive SHA-256 digests and sizes are recorded in `sources.json`. + +## File layout and Godot usage + +Each pack contains: + +- `Tilemap/tilemap_packed.png`: **192 × 176 px**, 12 columns × 11 rows, **16 × 16 px** cells, no gap. Recommended for `draw_texture_rect_region` or `AtlasTexture`. +- `Tilemap/tilemap.png`: same tiles with **1 px** spacing between cells (17 px pitch). +- `Tiles/tile_0000.png` ... `Tiles/tile_0131.png`: individual **16 × 16 px** sprites. +- `Tilesheet.txt`, `License.txt`, `Preview.png`, `Sample.png`: original metadata and references. + +Packed atlas rectangle, with zero-based cell coordinates: + +```gdscript +var region := Rect2(column * 16, row * 16, 16, 16) +# Equivalent tile ID: row * 12 + column +# Use CanvasItem.TEXTURE_FILTER_NEAREST and integer scale where practical. +``` + +Do not use the 17 px pitch against the packed atlas. Each downloaded archive actually contains 132 individual tile PNGs and a 12 × 11 atlas; the official landing pages list 130 assets. Use the archive dimensions and files as the implementation reference. + +## Useful checked atlas positions + +The following locations were checked against the original preview and packed atlas. Names below describe proposed in-game uses, not official Kenney asset names. All rectangles have width and height 16 px. + +| Pack | Column, row | Pixel x, y | Tile ID / individual file | Appearance / possible use | +| --- | --- | --- | --- | --- | +| Tiny Dungeon | 0, 7 | 0, 112 | 84 / `tile_0084.png` | Purple-haired fantasy visitor | +| Tiny Dungeon | 1, 7 | 16, 112 | 85 / `tile_0085.png` | Brown-haired visitor | +| Tiny Dungeon | 2, 7 | 32, 112 | 86 / `tile_0086.png` | Fair-haired visitor | +| Tiny Dungeon | 3, 7 | 48, 112 | 87 / `tile_0087.png` | Grey-haired / headgear visitor | +| Tiny Dungeon | 4, 7 | 64, 112 | 88 / `tile_0088.png` | Brown-haired visitor | +| Tiny Dungeon | 0–4, 8 | 0–64, 128 | 96–100 | More human / helmeted visitor variants | +| Tiny Dungeon | 0–4, 9 | 0–64, 144 | 108–112 | Green creature, people and fantasy visitor variants | +| Tiny Dungeon | 3, 5 | 48, 80 | 63 / `tile_0063.png` | Wooden crate / cabinet | +| Tiny Dungeon | 0, 6 | 0, 96 | 72 / `tile_0072.png` | Low wooden furnishing / counter | +| Tiny Dungeon | 1, 6 | 16, 96 | 73 / `tile_0073.png` | Small stool / crate | +| Tiny Dungeon | 2, 6 | 32, 96 | 74 / `tile_0074.png` | Metal pot / basin | +| Tiny Dungeon | 5–8, 7 | 80–128, 112 | 89–92 | Chest variants for storage | +| Tiny Dungeon | 5–8, 9 | 80–128, 144 | 113–116 | Coloured bottles for food, mana or medicine props | +| Tiny Town | 0, 0 | 0, 0 | 0 / `tile_0000.png` | Grass ground | +| Tiny Town | 1, 0 | 16, 0 | 1 / `tile_0001.png` | Textured grass | +| Tiny Town | 2, 0 | 32, 0 | 2 / `tile_0002.png` | Flowers | +| Tiny Town | 3, 0 and 3, 1 | 48, 0 and 48, 16 | 3, 15 | Tall orange tree, two tiles | +| Tiny Town | 4, 0 and 4, 1 | 64, 0 and 64, 16 | 4, 16 | Tall green tree, two tiles | +| Tiny Town | 0, 6 | 0, 96 | 72 / `tile_0072.png` | Timber wall | +| Tiny Town | 4, 6 | 64, 96 | 76 / `tile_0076.png` | Stone wall | +| Tiny Town | 0, 7 | 0, 112 | 84 / `tile_0084.png` | Framed crate / wall panel | +| Tiny Town | 1, 7 | 16, 112 | 85 / `tile_0085.png` | Timber door | +| Tiny Town | 5, 7 | 80, 112 | 89 / `tile_0089.png` | Stone-framed door | +| Tiny Town | 11, 6 | 176, 96 | 83 / `tile_0083.png` | Wooden sign | +| Tiny Town | 9, 7 | 144, 112 | 93 / `tile_0093.png` | Small coin / gold marker | +| Tiny Town | 10, 7 | 160, 112 | 94 / `tile_0094.png` | Rounded wooden barrel | +| Tiny Town | 10–11, 10 | 160–176, 160 | 130–131 | Open barrel and water-filled barrel | + +## Animation and art-direction notes + +These packs provide **static sprites**, not authored walk cycles. Use them as base art for small characters with procedural step/bob/flip motion, or author distinct walking and working frames as a separate derivative asset. Adjacent character cells are different people, so cycling through them is not a valid walk animation. + +The art is 16 px, front/top-down fantasy art. It works well for the compact cast and decorative props in a side-on cutaway, but full-width reception desks, beds, dining tables, lift machinery and the world's tree silhouette need purpose-built side-view construction. Keep nearest filtering, a consistent pixel density and a restrained matching palette; avoid stretching individual pixel tiles non-uniformly. diff --git a/assets/kenney/sources.json b/assets/kenney/sources.json new file mode 100644 index 0000000..836e2bc --- /dev/null +++ b/assets/kenney/sources.json @@ -0,0 +1,20 @@ +[ + { + "slug": "tiny-town", + "page": "https://kenney.nl/assets/tiny-town", + "download_url": "https://kenney.nl/media/pages/assets/tiny-town/a415fbeb49-1735736916/kenney_tiny-town.zip", + "zip_sha256": "9768692dccff1d706408a5aedd6ca4f6cd1409506cbc84cb2f862919764be977", + "zip_bytes": 182243, + "files": 143, + "imported": "2026-09-09" + }, + { + "slug": "tiny-dungeon", + "page": "https://kenney.nl/assets/tiny-dungeon", + "download_url": "https://kenney.nl/media/pages/assets/tiny-dungeon/f8422efb44-1674742415/kenney_tiny-dungeon.zip", + "zip_sha256": "c109438ab06f65fd80f9b2686a4cf9c7c11dc64444b47333ec71d602f8bb5fc7", + "zip_bytes": 98530, + "files": 145, + "imported": "2026-09-09" + } +] diff --git a/assets/kenney/tiny_dungeon/License.txt b/assets/kenney/tiny_dungeon/License.txt new file mode 100644 index 0000000..1b269f6 --- /dev/null +++ b/assets/kenney/tiny_dungeon/License.txt @@ -0,0 +1,22 @@ + + + Tiny Dungeon (1.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 05-07-2022 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/kenney/tiny_dungeon/Preview.png b/assets/kenney/tiny_dungeon/Preview.png new file mode 100644 index 0000000..01c23ad Binary files /dev/null and b/assets/kenney/tiny_dungeon/Preview.png differ diff --git a/assets/kenney/tiny_dungeon/Preview.png.import b/assets/kenney/tiny_dungeon/Preview.png.import new file mode 100644 index 0000000..78b5801 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bi3kdx10dhfyk" +path="res://.godot/imported/Preview.png-856282984c2d0b5d626a247000547f78.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Preview.png" +dest_files=["res://.godot/imported/Preview.png-856282984c2d0b5d626a247000547f78.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Sample.png b/assets/kenney/tiny_dungeon/Sample.png new file mode 100644 index 0000000..505a6d6 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Sample.png differ diff --git a/assets/kenney/tiny_dungeon/Sample.png.import b/assets/kenney/tiny_dungeon/Sample.png.import new file mode 100644 index 0000000..31de59a --- /dev/null +++ b/assets/kenney/tiny_dungeon/Sample.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dys3u3b4duwxs" +path="res://.godot/imported/Sample.png-ae111285f78b53b83604c9349820c589.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Sample.png" +dest_files=["res://.godot/imported/Sample.png-ae111285f78b53b83604c9349820c589.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiled/sampleMap.tmx b/assets/kenney/tiny_dungeon/Tiled/sampleMap.tmx new file mode 100644 index 0000000..1b816c0 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiled/sampleMap.tmx @@ -0,0 +1,76 @@ + + + + + +14,1610612787,16,1,1,3221225485,1,1,1,2,3,3,3,3,3,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, +14,1610612787,17,3,3,4,1,1,1,14,41,41,41,41,41,16,1,1,1,1,1,25,1,1,1,1,1,1,1,1,1073741837,1, +14,1610612787,58,41,41,16,25,1,1,14,1610612789,51,51,52,51,16,2,3,4,1,1,1,1,2,3,7,3,3,3,7,3,4, +14,1610612787,51,52,51,16,1,1,1,14,1610612787,49,3221225522,49,49,16,14,41,16,1,1,536870925,1,14,41,19,41,30,41,19,41,16, +14,1610612787,5,27,27,28,1,1073741837,1,14,1610612787,49,49,49,49,16,14,1610612789,16,1,1,1,1073741837,14,1610612789,31,51,51,52,31,51,16, +14,1610612787,16,2,3,3,3,3,4,26,27,27,27,27,27,28,14,1610612787,17,7,3,3,4,14,1610612787,49,49,49,49,49,49,16, +14,1610612787,16,14,41,41,41,41,17,3,3,3,3,4,1,1,14,1610612787,58,19,41,41,16,14,1610612787,49,49,49,49,49,49,16, +18,1610612787,17,18,1610612789,51,52,51,58,41,41,22,41,16,2684354573,1,14,1610612787,51,31,51,51,16,14,1610612787,49,49,49,49,49,49,16, +60,1610612787,58,60,1610612787,49,49,49,51,51,51,51,51,17,3,3,18,1610612787,49,43,49,3221225522,17,18,1610612787,49,49,49,49,49,2684354610,17, +51,54,51,51,54,43,49,50,49,49,49,49,49,58,41,41,60,1610612787,49,49,43,49,58,60,37,38,38,38,38,38,39,58, +49,43,49,49,49,49,49,49,49,49,49,49,49,51,52,51,51,54,49,49,49,49,51,52,54,49,49,49,49,49,49,51, +49,49,49,5,27,6,1610612790,49,49,2147483698,49,49,49,49,49,49,49,49,49,49,49,49,49,5,27,27,27,27,27,27,27,27, +49,49,49,16,1,14,1610612787,49,49,49,49,49,49,5,27,27,27,27,27,27,6,1610612790,49,16,1,1,1,1,1,1,1,1, +27,27,27,28,3221225485,14,1610612787,49,49,5,27,27,27,28,2,3,3,3,3,3,18,1610612787,49,16,1,1,1,2,3,3,3,3, +1,1,1,1,2,18,1610612787,49,49,17,3,4,1,1,14,41,20,41,41,21,60,1610612787,49,16,1,1,1073741837,14,41,11,12,41, +1,2,3,3,18,60,1610612787,49,49,58,41,17,3,3,18,1610612789,32,51,51,33,51,54,2147483698,16,1,1,1,14,1610612789,51,51,51, +1,14,41,41,60,1610612789,54,49,49,51,51,58,41,41,60,1610612787,49,43,49,49,49,49,49,17,3,3,3,18,1610612787,49,49,49, +1,14,1610612789,52,51,54,49,49,2147483698,42,49,51,51,51,51,54,43,49,49,49,49,49,49,58,41,41,41,60,1610612787,49,49,49, +1,14,1610612787,3758096434,49,49,49,49,49,3758096434,49,49,49,49,49,49,49,49,49,49,49,43,49,51,51,51,51,51,54,49,49,49, +1,14,1610612787,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,3758096434,49 + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,76,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,87,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,75,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,66,0,65,0,65,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,67,0,67,0,67,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,121,0,0,0,0,0,0, +0,0,0,0,0,112,0,0,0,62,0,0,0,0,0,0,0,0,74,0,73,0,0,0,0,0,121,0,0,0,0,0, +0,0,65,0,0,0,0,0,62,88,62,0,0,0,0,0,0,0,0,0,0,74,0,0,0,0,0,0,0,0,0,0, +0,0,67,0,0,0,0,0,98,62,0,0,0,0,0,85,0,0,0,2147483758,0,0,0,0,0,0,0,0,0,0,0,0, +0,65,0,0,0,0,0,0,0,0,99,61,0,0,0,0,0,0,0,0,0,123,0,0,0,0,0,0,0,0,0,0, +0,67,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,68,0,0,0,0,0,0,0,0,0,0,0,0,0,78,78,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,82,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,82,0,0,0,0,0,0,0,0,0,0,0,0,0,124,0,0,0,0,0,0,0,0,0,83, +0,0,0,0,0,0,0,82,0,0,0,0,0,0,0,0,0,0,0,124,0,0,0,0,0,0,0,0,0,90,0,0, +0,0,0,0,0,0,0,82,0,0,0,0,0,0,0,0,0,0,0,0,0,2147483773,0,0,0,0,0,0,0,0,2147483749,0, +0,0,0,0,0,0,0,94,71,71,71,71,71,72,0,0,0,0,0,0,0,0,0,0,0,0,83,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,82,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,56,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,55,55,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + diff --git a/assets/kenney/tiny_dungeon/Tiled/sampleSheet.tsx b/assets/kenney/tiny_dungeon/Tiled/sampleSheet.tsx new file mode 100644 index 0000000..39ac392 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiled/sampleSheet.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/assets/kenney/tiny_dungeon/Tilemap/tilemap.png b/assets/kenney/tiny_dungeon/Tilemap/tilemap.png new file mode 100644 index 0000000..a893ccb Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tilemap/tilemap.png differ diff --git a/assets/kenney/tiny_dungeon/Tilemap/tilemap.png.import b/assets/kenney/tiny_dungeon/Tilemap/tilemap.png.import new file mode 100644 index 0000000..ab6e087 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tilemap/tilemap.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://badsqkcionl2d" +path="res://.godot/imported/tilemap.png-70299bd7d950329bb60018561d8fcad6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tilemap/tilemap.png" +dest_files=["res://.godot/imported/tilemap.png-70299bd7d950329bb60018561d8fcad6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png b/assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png new file mode 100644 index 0000000..f6e8b93 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png differ diff --git a/assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png.import b/assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png.import new file mode 100644 index 0000000..d1207d9 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xfa4js5k3hav" +path="res://.godot/imported/tilemap_packed.png-88972e097b835877f956a0a6df47a7cb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png" +dest_files=["res://.godot/imported/tilemap_packed.png-88972e097b835877f956a0a6df47a7cb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0000.png b/assets/kenney/tiny_dungeon/Tiles/tile_0000.png new file mode 100644 index 0000000..6bd64a7 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0000.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0000.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0000.png.import new file mode 100644 index 0000000..01f043c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0000.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dach2sy3vdqyi" +path="res://.godot/imported/tile_0000.png-1f54944496bce4f2fe377a347a3ddf28.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0000.png" +dest_files=["res://.godot/imported/tile_0000.png-1f54944496bce4f2fe377a347a3ddf28.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0001.png b/assets/kenney/tiny_dungeon/Tiles/tile_0001.png new file mode 100644 index 0000000..cdaffc0 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0001.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0001.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0001.png.import new file mode 100644 index 0000000..1a7198d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0001.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgl7d4l2fql3r" +path="res://.godot/imported/tile_0001.png-70a274132322de48da7051b5675e6b37.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0001.png" +dest_files=["res://.godot/imported/tile_0001.png-70a274132322de48da7051b5675e6b37.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0002.png b/assets/kenney/tiny_dungeon/Tiles/tile_0002.png new file mode 100644 index 0000000..0b4f047 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0002.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0002.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0002.png.import new file mode 100644 index 0000000..a4a7074 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0002.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xin3ygowecru" +path="res://.godot/imported/tile_0002.png-cd57ea819a94d0daec5892f130ee659f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0002.png" +dest_files=["res://.godot/imported/tile_0002.png-cd57ea819a94d0daec5892f130ee659f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0003.png b/assets/kenney/tiny_dungeon/Tiles/tile_0003.png new file mode 100644 index 0000000..c03b478 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0003.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0003.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0003.png.import new file mode 100644 index 0000000..e2faa73 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0003.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtclfjxvam4dg" +path="res://.godot/imported/tile_0003.png-318e0847e82a64926b8777927e38af43.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0003.png" +dest_files=["res://.godot/imported/tile_0003.png-318e0847e82a64926b8777927e38af43.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0004.png b/assets/kenney/tiny_dungeon/Tiles/tile_0004.png new file mode 100644 index 0000000..e55ab28 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0004.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0004.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0004.png.import new file mode 100644 index 0000000..45354bc --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0004.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0oc8ck15fchh" +path="res://.godot/imported/tile_0004.png-7be57b073e93bdfab358e256a95a5a98.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0004.png" +dest_files=["res://.godot/imported/tile_0004.png-7be57b073e93bdfab358e256a95a5a98.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0005.png b/assets/kenney/tiny_dungeon/Tiles/tile_0005.png new file mode 100644 index 0000000..e4d148b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0005.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0005.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0005.png.import new file mode 100644 index 0000000..7611dae --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0005.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bybo0vhfmwxss" +path="res://.godot/imported/tile_0005.png-5603978cdd596e69d34feefc608ed453.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0005.png" +dest_files=["res://.godot/imported/tile_0005.png-5603978cdd596e69d34feefc608ed453.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0006.png b/assets/kenney/tiny_dungeon/Tiles/tile_0006.png new file mode 100644 index 0000000..fe7f7b3 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0006.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0006.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0006.png.import new file mode 100644 index 0000000..84ca56e --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0006.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cdjuxs3ubiir3" +path="res://.godot/imported/tile_0006.png-67572a97da51748b1bce1e3d2b55f20d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0006.png" +dest_files=["res://.godot/imported/tile_0006.png-67572a97da51748b1bce1e3d2b55f20d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0007.png b/assets/kenney/tiny_dungeon/Tiles/tile_0007.png new file mode 100644 index 0000000..029bf8c Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0007.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0007.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0007.png.import new file mode 100644 index 0000000..b4c15f3 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0007.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btqaqkwpmcqsq" +path="res://.godot/imported/tile_0007.png-84ee5ee5a92d7465824ff695c1cd9748.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0007.png" +dest_files=["res://.godot/imported/tile_0007.png-84ee5ee5a92d7465824ff695c1cd9748.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0008.png b/assets/kenney/tiny_dungeon/Tiles/tile_0008.png new file mode 100644 index 0000000..0ee6237 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0008.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0008.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0008.png.import new file mode 100644 index 0000000..d51b9b7 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0008.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b53moqsoybwd0" +path="res://.godot/imported/tile_0008.png-c65e23f40a62d9b43b0e5b1ae5d94a24.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0008.png" +dest_files=["res://.godot/imported/tile_0008.png-c65e23f40a62d9b43b0e5b1ae5d94a24.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0009.png b/assets/kenney/tiny_dungeon/Tiles/tile_0009.png new file mode 100644 index 0000000..3a47228 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0009.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0009.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0009.png.import new file mode 100644 index 0000000..cd48582 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0009.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfuyd1i72cw38" +path="res://.godot/imported/tile_0009.png-efe26f32d84d8db600f6c177a0289386.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0009.png" +dest_files=["res://.godot/imported/tile_0009.png-efe26f32d84d8db600f6c177a0289386.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0010.png b/assets/kenney/tiny_dungeon/Tiles/tile_0010.png new file mode 100644 index 0000000..3e7352b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0010.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0010.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0010.png.import new file mode 100644 index 0000000..4a3659c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0010.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dw1cipgrvn1q" +path="res://.godot/imported/tile_0010.png-7db2719b7208c3dd54fa55f73bc8455b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0010.png" +dest_files=["res://.godot/imported/tile_0010.png-7db2719b7208c3dd54fa55f73bc8455b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0011.png b/assets/kenney/tiny_dungeon/Tiles/tile_0011.png new file mode 100644 index 0000000..2ceaed8 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0011.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0011.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0011.png.import new file mode 100644 index 0000000..0e2f40b --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0011.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5l735o34cxyh" +path="res://.godot/imported/tile_0011.png-2c123c23ccb9950284e66339745f38a3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0011.png" +dest_files=["res://.godot/imported/tile_0011.png-2c123c23ccb9950284e66339745f38a3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0012.png b/assets/kenney/tiny_dungeon/Tiles/tile_0012.png new file mode 100644 index 0000000..4320cb2 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0012.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0012.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0012.png.import new file mode 100644 index 0000000..9b49b23 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0012.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5caiyn7sa8v7" +path="res://.godot/imported/tile_0012.png-34b373f53eeef0363765a81ff712a214.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0012.png" +dest_files=["res://.godot/imported/tile_0012.png-34b373f53eeef0363765a81ff712a214.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0013.png b/assets/kenney/tiny_dungeon/Tiles/tile_0013.png new file mode 100644 index 0000000..3a5b250 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0013.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0013.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0013.png.import new file mode 100644 index 0000000..7ad60c6 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0013.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://daml3axkyrqci" +path="res://.godot/imported/tile_0013.png-c62274e1f2d8eabdabde65227f3deef1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0013.png" +dest_files=["res://.godot/imported/tile_0013.png-c62274e1f2d8eabdabde65227f3deef1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0014.png b/assets/kenney/tiny_dungeon/Tiles/tile_0014.png new file mode 100644 index 0000000..bec979d Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0014.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0014.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0014.png.import new file mode 100644 index 0000000..8bc9b21 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0014.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://4nl2nxsyqomw" +path="res://.godot/imported/tile_0014.png-cc9b863851017f7148dea20dbab392d7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0014.png" +dest_files=["res://.godot/imported/tile_0014.png-cc9b863851017f7148dea20dbab392d7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0015.png b/assets/kenney/tiny_dungeon/Tiles/tile_0015.png new file mode 100644 index 0000000..32e03a9 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0015.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0015.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0015.png.import new file mode 100644 index 0000000..5cdc64e --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0015.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bns8qsidahplc" +path="res://.godot/imported/tile_0015.png-85157d853443f240bde8074751647df2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0015.png" +dest_files=["res://.godot/imported/tile_0015.png-85157d853443f240bde8074751647df2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0016.png b/assets/kenney/tiny_dungeon/Tiles/tile_0016.png new file mode 100644 index 0000000..12634df Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0016.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0016.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0016.png.import new file mode 100644 index 0000000..1750a3e --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0016.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dqyrrjfr4uvax" +path="res://.godot/imported/tile_0016.png-0478dc2c795d10c9412a860861aeca33.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0016.png" +dest_files=["res://.godot/imported/tile_0016.png-0478dc2c795d10c9412a860861aeca33.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0017.png b/assets/kenney/tiny_dungeon/Tiles/tile_0017.png new file mode 100644 index 0000000..148bc27 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0017.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0017.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0017.png.import new file mode 100644 index 0000000..61b2a3b --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0017.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://botkkq8i84a6w" +path="res://.godot/imported/tile_0017.png-1398af679f9a58ade766f14be237ce1f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0017.png" +dest_files=["res://.godot/imported/tile_0017.png-1398af679f9a58ade766f14be237ce1f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0018.png b/assets/kenney/tiny_dungeon/Tiles/tile_0018.png new file mode 100644 index 0000000..10d95da Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0018.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0018.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0018.png.import new file mode 100644 index 0000000..baf890d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0018.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://db3yvqamwo1bw" +path="res://.godot/imported/tile_0018.png-98210d398176525dbf5d681f85260a21.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0018.png" +dest_files=["res://.godot/imported/tile_0018.png-98210d398176525dbf5d681f85260a21.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0019.png b/assets/kenney/tiny_dungeon/Tiles/tile_0019.png new file mode 100644 index 0000000..8c1285f Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0019.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0019.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0019.png.import new file mode 100644 index 0000000..483ffe7 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0019.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://beoh3ulocd622" +path="res://.godot/imported/tile_0019.png-b13381f1ece8b0e700d0f3fe3a30301a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0019.png" +dest_files=["res://.godot/imported/tile_0019.png-b13381f1ece8b0e700d0f3fe3a30301a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0020.png b/assets/kenney/tiny_dungeon/Tiles/tile_0020.png new file mode 100644 index 0000000..6dfd70e Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0020.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0020.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0020.png.import new file mode 100644 index 0000000..59b83cb --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0020.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dkm3ltyattol" +path="res://.godot/imported/tile_0020.png-628551f861560e3a6bb504349a5c53ab.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0020.png" +dest_files=["res://.godot/imported/tile_0020.png-628551f861560e3a6bb504349a5c53ab.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0021.png b/assets/kenney/tiny_dungeon/Tiles/tile_0021.png new file mode 100644 index 0000000..e7d1850 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0021.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0021.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0021.png.import new file mode 100644 index 0000000..ebe909d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0021.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://y23p1ewfknwp" +path="res://.godot/imported/tile_0021.png-fb6f12958ae08429deb850abd3fb3237.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0021.png" +dest_files=["res://.godot/imported/tile_0021.png-fb6f12958ae08429deb850abd3fb3237.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0022.png b/assets/kenney/tiny_dungeon/Tiles/tile_0022.png new file mode 100644 index 0000000..4e9bc12 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0022.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0022.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0022.png.import new file mode 100644 index 0000000..b8cf424 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0022.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djxpo7addkfuj" +path="res://.godot/imported/tile_0022.png-4589c177e41fa45a9d7fa7ab4881f6fb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0022.png" +dest_files=["res://.godot/imported/tile_0022.png-4589c177e41fa45a9d7fa7ab4881f6fb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0023.png b/assets/kenney/tiny_dungeon/Tiles/tile_0023.png new file mode 100644 index 0000000..07921a6 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0023.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0023.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0023.png.import new file mode 100644 index 0000000..184f724 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0023.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xor1tevoh5fb" +path="res://.godot/imported/tile_0023.png-a5ca5859710310ee278dcfd0ea60e4b9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0023.png" +dest_files=["res://.godot/imported/tile_0023.png-a5ca5859710310ee278dcfd0ea60e4b9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0024.png b/assets/kenney/tiny_dungeon/Tiles/tile_0024.png new file mode 100644 index 0000000..ec6d903 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0024.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0024.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0024.png.import new file mode 100644 index 0000000..8223125 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0024.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b21i1sg7tppmj" +path="res://.godot/imported/tile_0024.png-389c9d28b879c545b11d833832c147fc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0024.png" +dest_files=["res://.godot/imported/tile_0024.png-389c9d28b879c545b11d833832c147fc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0025.png b/assets/kenney/tiny_dungeon/Tiles/tile_0025.png new file mode 100644 index 0000000..da5f19a Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0025.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0025.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0025.png.import new file mode 100644 index 0000000..b56524b --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0025.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://diom3w5ooi7ns" +path="res://.godot/imported/tile_0025.png-6f449a5a8b648f6a61008a4197f26cbe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0025.png" +dest_files=["res://.godot/imported/tile_0025.png-6f449a5a8b648f6a61008a4197f26cbe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0026.png b/assets/kenney/tiny_dungeon/Tiles/tile_0026.png new file mode 100644 index 0000000..e93246a Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0026.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0026.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0026.png.import new file mode 100644 index 0000000..45ed8fd --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0026.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ci63ysgfhqvv4" +path="res://.godot/imported/tile_0026.png-ab8e7b394afba190a7aa1e7e83ee3584.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0026.png" +dest_files=["res://.godot/imported/tile_0026.png-ab8e7b394afba190a7aa1e7e83ee3584.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0027.png b/assets/kenney/tiny_dungeon/Tiles/tile_0027.png new file mode 100644 index 0000000..1ce15f4 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0027.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0027.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0027.png.import new file mode 100644 index 0000000..5173f3d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0027.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://v5yhpso15cq4" +path="res://.godot/imported/tile_0027.png-e939bfde781c04069b6cf05b5d51190e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0027.png" +dest_files=["res://.godot/imported/tile_0027.png-e939bfde781c04069b6cf05b5d51190e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0028.png b/assets/kenney/tiny_dungeon/Tiles/tile_0028.png new file mode 100644 index 0000000..bf1c3a2 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0028.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0028.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0028.png.import new file mode 100644 index 0000000..6928c33 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0028.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dcfio574oc6tl" +path="res://.godot/imported/tile_0028.png-20315d0a8767cfa7267f4085c79c0f5d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0028.png" +dest_files=["res://.godot/imported/tile_0028.png-20315d0a8767cfa7267f4085c79c0f5d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0029.png b/assets/kenney/tiny_dungeon/Tiles/tile_0029.png new file mode 100644 index 0000000..7b0e33d Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0029.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0029.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0029.png.import new file mode 100644 index 0000000..8df27b0 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0029.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cs7d37dp0jwjt" +path="res://.godot/imported/tile_0029.png-c3e4e35bfcbb8f6c5a3ba61a1044c909.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0029.png" +dest_files=["res://.godot/imported/tile_0029.png-c3e4e35bfcbb8f6c5a3ba61a1044c909.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0030.png b/assets/kenney/tiny_dungeon/Tiles/tile_0030.png new file mode 100644 index 0000000..1b597ca Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0030.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0030.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0030.png.import new file mode 100644 index 0000000..c3c75e4 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0030.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds126p4chb5oe" +path="res://.godot/imported/tile_0030.png-e9b525b0ab65da7d55334738288d9e22.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0030.png" +dest_files=["res://.godot/imported/tile_0030.png-e9b525b0ab65da7d55334738288d9e22.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0031.png b/assets/kenney/tiny_dungeon/Tiles/tile_0031.png new file mode 100644 index 0000000..acd9203 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0031.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0031.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0031.png.import new file mode 100644 index 0000000..ea7983f --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0031.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjqf53cl2awop" +path="res://.godot/imported/tile_0031.png-2734e57c34faf3d3b366f2d61833666d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0031.png" +dest_files=["res://.godot/imported/tile_0031.png-2734e57c34faf3d3b366f2d61833666d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0032.png b/assets/kenney/tiny_dungeon/Tiles/tile_0032.png new file mode 100644 index 0000000..77ca1a7 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0032.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0032.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0032.png.import new file mode 100644 index 0000000..14a6f16 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0032.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bycni7gd4mmnw" +path="res://.godot/imported/tile_0032.png-db34542fe2068dd4daeb770524cd1179.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0032.png" +dest_files=["res://.godot/imported/tile_0032.png-db34542fe2068dd4daeb770524cd1179.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0033.png b/assets/kenney/tiny_dungeon/Tiles/tile_0033.png new file mode 100644 index 0000000..2cf8cb1 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0033.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0033.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0033.png.import new file mode 100644 index 0000000..6f702fb --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0033.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dunpfu45dfril" +path="res://.godot/imported/tile_0033.png-1f28c426d30a08a0ab14b7489da89e9b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0033.png" +dest_files=["res://.godot/imported/tile_0033.png-1f28c426d30a08a0ab14b7489da89e9b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0034.png b/assets/kenney/tiny_dungeon/Tiles/tile_0034.png new file mode 100644 index 0000000..98076aa Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0034.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0034.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0034.png.import new file mode 100644 index 0000000..79c9d1f --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0034.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl4se3efim6em" +path="res://.godot/imported/tile_0034.png-698568c7d4121f287a242ae5938c311d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0034.png" +dest_files=["res://.godot/imported/tile_0034.png-698568c7d4121f287a242ae5938c311d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0035.png b/assets/kenney/tiny_dungeon/Tiles/tile_0035.png new file mode 100644 index 0000000..1e41498 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0035.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0035.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0035.png.import new file mode 100644 index 0000000..c69a82f --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0035.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cl8whvbr8tic2" +path="res://.godot/imported/tile_0035.png-765e65f83dd1dd9c4816cd044e91f406.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0035.png" +dest_files=["res://.godot/imported/tile_0035.png-765e65f83dd1dd9c4816cd044e91f406.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0036.png b/assets/kenney/tiny_dungeon/Tiles/tile_0036.png new file mode 100644 index 0000000..23c3a83 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0036.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0036.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0036.png.import new file mode 100644 index 0000000..c464468 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0036.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3gjbasfjgym6" +path="res://.godot/imported/tile_0036.png-4173c1473c12a9ffa4cd2dbfd37aa2e8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0036.png" +dest_files=["res://.godot/imported/tile_0036.png-4173c1473c12a9ffa4cd2dbfd37aa2e8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0037.png b/assets/kenney/tiny_dungeon/Tiles/tile_0037.png new file mode 100644 index 0000000..1d2d962 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0037.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0037.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0037.png.import new file mode 100644 index 0000000..9a74ec5 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0037.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crbcdh1fsh2yf" +path="res://.godot/imported/tile_0037.png-746986d9a6fccaff771441bdcb95ebe4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0037.png" +dest_files=["res://.godot/imported/tile_0037.png-746986d9a6fccaff771441bdcb95ebe4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0038.png b/assets/kenney/tiny_dungeon/Tiles/tile_0038.png new file mode 100644 index 0000000..96972bd Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0038.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0038.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0038.png.import new file mode 100644 index 0000000..d035354 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0038.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://beowsc6dwuos2" +path="res://.godot/imported/tile_0038.png-ae2b1af6725d1e5d2e7bc027455c94f5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0038.png" +dest_files=["res://.godot/imported/tile_0038.png-ae2b1af6725d1e5d2e7bc027455c94f5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0039.png b/assets/kenney/tiny_dungeon/Tiles/tile_0039.png new file mode 100644 index 0000000..75667ca Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0039.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0039.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0039.png.import new file mode 100644 index 0000000..cf57c11 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0039.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccl7sjkov3pn4" +path="res://.godot/imported/tile_0039.png-df999bdf5f5cd37ae42d4ec6e28eda84.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0039.png" +dest_files=["res://.godot/imported/tile_0039.png-df999bdf5f5cd37ae42d4ec6e28eda84.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0040.png b/assets/kenney/tiny_dungeon/Tiles/tile_0040.png new file mode 100644 index 0000000..1191cd3 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0040.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0040.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0040.png.import new file mode 100644 index 0000000..f82a842 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0040.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ybv1yff0lh8l" +path="res://.godot/imported/tile_0040.png-385a035137025ce217df42bc9c10e1ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0040.png" +dest_files=["res://.godot/imported/tile_0040.png-385a035137025ce217df42bc9c10e1ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0041.png b/assets/kenney/tiny_dungeon/Tiles/tile_0041.png new file mode 100644 index 0000000..dbcdc56 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0041.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0041.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0041.png.import new file mode 100644 index 0000000..74d7566 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0041.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://x0src8wuieyk" +path="res://.godot/imported/tile_0041.png-b51d791a7dcfc3e9d319cea44d494e80.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0041.png" +dest_files=["res://.godot/imported/tile_0041.png-b51d791a7dcfc3e9d319cea44d494e80.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0042.png b/assets/kenney/tiny_dungeon/Tiles/tile_0042.png new file mode 100644 index 0000000..4ac7fa5 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0042.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0042.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0042.png.import new file mode 100644 index 0000000..2c5375f --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0042.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b31n8dioi2o8i" +path="res://.godot/imported/tile_0042.png-26161ea745185bab8caa392e0a3bebf2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0042.png" +dest_files=["res://.godot/imported/tile_0042.png-26161ea745185bab8caa392e0a3bebf2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0043.png b/assets/kenney/tiny_dungeon/Tiles/tile_0043.png new file mode 100644 index 0000000..f12545f Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0043.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0043.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0043.png.import new file mode 100644 index 0000000..c114c95 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0043.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://duta2i2a3qcql" +path="res://.godot/imported/tile_0043.png-64b299265c6ee007c29e856644e8dac2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0043.png" +dest_files=["res://.godot/imported/tile_0043.png-64b299265c6ee007c29e856644e8dac2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0044.png b/assets/kenney/tiny_dungeon/Tiles/tile_0044.png new file mode 100644 index 0000000..3d05e58 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0044.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0044.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0044.png.import new file mode 100644 index 0000000..2980f3f --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0044.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6ktym71fgq8j" +path="res://.godot/imported/tile_0044.png-8ab24e48e95b7c5b80f42bb5ecbbdaeb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0044.png" +dest_files=["res://.godot/imported/tile_0044.png-8ab24e48e95b7c5b80f42bb5ecbbdaeb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0045.png b/assets/kenney/tiny_dungeon/Tiles/tile_0045.png new file mode 100644 index 0000000..22e1625 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0045.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0045.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0045.png.import new file mode 100644 index 0000000..9a68d90 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0045.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmg1n4c86v6yo" +path="res://.godot/imported/tile_0045.png-d7005535ecc62f2b3a24e3c3d456ceb4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0045.png" +dest_files=["res://.godot/imported/tile_0045.png-d7005535ecc62f2b3a24e3c3d456ceb4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0046.png b/assets/kenney/tiny_dungeon/Tiles/tile_0046.png new file mode 100644 index 0000000..1dc1213 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0046.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0046.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0046.png.import new file mode 100644 index 0000000..e3fc9c6 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0046.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwbrrvs0vfmh5" +path="res://.godot/imported/tile_0046.png-521ba5aa3ec86dbe99c9e7eb695842a0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0046.png" +dest_files=["res://.godot/imported/tile_0046.png-521ba5aa3ec86dbe99c9e7eb695842a0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0047.png b/assets/kenney/tiny_dungeon/Tiles/tile_0047.png new file mode 100644 index 0000000..e9a7f9f Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0047.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0047.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0047.png.import new file mode 100644 index 0000000..911a2ec --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0047.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyjp8unuj65ww" +path="res://.godot/imported/tile_0047.png-3a427a3ad34195094460690eb0817487.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0047.png" +dest_files=["res://.godot/imported/tile_0047.png-3a427a3ad34195094460690eb0817487.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0048.png b/assets/kenney/tiny_dungeon/Tiles/tile_0048.png new file mode 100644 index 0000000..6e59a44 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0048.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0048.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0048.png.import new file mode 100644 index 0000000..15e48f5 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0048.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtmpiwg6v7o28" +path="res://.godot/imported/tile_0048.png-0b47f2a19b276aa4b2b67c292584cf49.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0048.png" +dest_files=["res://.godot/imported/tile_0048.png-0b47f2a19b276aa4b2b67c292584cf49.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0049.png b/assets/kenney/tiny_dungeon/Tiles/tile_0049.png new file mode 100644 index 0000000..c7d0722 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0049.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0049.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0049.png.import new file mode 100644 index 0000000..fb29a32 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0049.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n7n4oj5w2fe5" +path="res://.godot/imported/tile_0049.png-3d3ee0d7c03f7525a49079777c6a03e3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0049.png" +dest_files=["res://.godot/imported/tile_0049.png-3d3ee0d7c03f7525a49079777c6a03e3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0050.png b/assets/kenney/tiny_dungeon/Tiles/tile_0050.png new file mode 100644 index 0000000..b4464f3 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0050.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0050.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0050.png.import new file mode 100644 index 0000000..9ca373a --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0050.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8rm3184ifpxj" +path="res://.godot/imported/tile_0050.png-f22295bfc37893f3a83daaca8d05e241.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0050.png" +dest_files=["res://.godot/imported/tile_0050.png-f22295bfc37893f3a83daaca8d05e241.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0051.png b/assets/kenney/tiny_dungeon/Tiles/tile_0051.png new file mode 100644 index 0000000..7bc6fe4 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0051.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0051.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0051.png.import new file mode 100644 index 0000000..041429c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0051.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4k8ggqyyro2d" +path="res://.godot/imported/tile_0051.png-148d537188e0173b051444137e60ce86.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0051.png" +dest_files=["res://.godot/imported/tile_0051.png-148d537188e0173b051444137e60ce86.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0052.png b/assets/kenney/tiny_dungeon/Tiles/tile_0052.png new file mode 100644 index 0000000..8a17adb Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0052.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0052.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0052.png.import new file mode 100644 index 0000000..03a31de --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0052.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccwutw5nt4oyx" +path="res://.godot/imported/tile_0052.png-736efa642c905da888cd40e1d6e166a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0052.png" +dest_files=["res://.godot/imported/tile_0052.png-736efa642c905da888cd40e1d6e166a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0053.png b/assets/kenney/tiny_dungeon/Tiles/tile_0053.png new file mode 100644 index 0000000..a8ac994 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0053.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0053.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0053.png.import new file mode 100644 index 0000000..cca9057 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0053.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ble5lrake8u0b" +path="res://.godot/imported/tile_0053.png-b421c1d725816d8a47894688277c9b8c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0053.png" +dest_files=["res://.godot/imported/tile_0053.png-b421c1d725816d8a47894688277c9b8c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0054.png b/assets/kenney/tiny_dungeon/Tiles/tile_0054.png new file mode 100644 index 0000000..39b9cf6 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0054.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0054.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0054.png.import new file mode 100644 index 0000000..f22e2b1 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0054.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cvfwq5oflgsck" +path="res://.godot/imported/tile_0054.png-20db7f96d52f6cbd823dd4e73b810d25.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0054.png" +dest_files=["res://.godot/imported/tile_0054.png-20db7f96d52f6cbd823dd4e73b810d25.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0055.png b/assets/kenney/tiny_dungeon/Tiles/tile_0055.png new file mode 100644 index 0000000..655077e Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0055.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0055.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0055.png.import new file mode 100644 index 0000000..08cea92 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0055.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cnuuqwe5ub3py" +path="res://.godot/imported/tile_0055.png-0a97c2a38333fe473dfd7ceec3605f8b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0055.png" +dest_files=["res://.godot/imported/tile_0055.png-0a97c2a38333fe473dfd7ceec3605f8b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0056.png b/assets/kenney/tiny_dungeon/Tiles/tile_0056.png new file mode 100644 index 0000000..0dfcb21 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0056.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0056.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0056.png.import new file mode 100644 index 0000000..12a1cd5 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0056.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5y814omcjps7" +path="res://.godot/imported/tile_0056.png-dc21d24b67a7fbe1e08115a6e46a825f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0056.png" +dest_files=["res://.godot/imported/tile_0056.png-dc21d24b67a7fbe1e08115a6e46a825f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0057.png b/assets/kenney/tiny_dungeon/Tiles/tile_0057.png new file mode 100644 index 0000000..f9ad9b7 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0057.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0057.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0057.png.import new file mode 100644 index 0000000..928cf66 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0057.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bng3sttst1b8e" +path="res://.godot/imported/tile_0057.png-e07ea65e161c567198059d19d8b50f5a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0057.png" +dest_files=["res://.godot/imported/tile_0057.png-e07ea65e161c567198059d19d8b50f5a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0058.png b/assets/kenney/tiny_dungeon/Tiles/tile_0058.png new file mode 100644 index 0000000..e498583 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0058.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0058.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0058.png.import new file mode 100644 index 0000000..9434458 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0058.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1qu05crd0nl8" +path="res://.godot/imported/tile_0058.png-5d3dbb5149c9b5610a7279c71bdb8373.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0058.png" +dest_files=["res://.godot/imported/tile_0058.png-5d3dbb5149c9b5610a7279c71bdb8373.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0059.png b/assets/kenney/tiny_dungeon/Tiles/tile_0059.png new file mode 100644 index 0000000..f47e75b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0059.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0059.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0059.png.import new file mode 100644 index 0000000..ff7f8ff --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0059.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bum6tey3trehh" +path="res://.godot/imported/tile_0059.png-33a882c65f725611edddb2e5acc59a9c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0059.png" +dest_files=["res://.godot/imported/tile_0059.png-33a882c65f725611edddb2e5acc59a9c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0060.png b/assets/kenney/tiny_dungeon/Tiles/tile_0060.png new file mode 100644 index 0000000..ad4779c Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0060.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0060.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0060.png.import new file mode 100644 index 0000000..5e96fcd --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0060.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cu0s53y8k1a4n" +path="res://.godot/imported/tile_0060.png-b1486daa9da78b2ea8ddbbd4dd58d8d2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0060.png" +dest_files=["res://.godot/imported/tile_0060.png-b1486daa9da78b2ea8ddbbd4dd58d8d2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0061.png b/assets/kenney/tiny_dungeon/Tiles/tile_0061.png new file mode 100644 index 0000000..308dc05 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0061.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0061.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0061.png.import new file mode 100644 index 0000000..b974f03 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0061.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvpy43iph6dbn" +path="res://.godot/imported/tile_0061.png-bd6085e4abaa8f66ffb0078bd6d0b51f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0061.png" +dest_files=["res://.godot/imported/tile_0061.png-bd6085e4abaa8f66ffb0078bd6d0b51f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0062.png b/assets/kenney/tiny_dungeon/Tiles/tile_0062.png new file mode 100644 index 0000000..e679062 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0062.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0062.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0062.png.import new file mode 100644 index 0000000..6b1f637 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0062.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxig8rrtv0tif" +path="res://.godot/imported/tile_0062.png-0f9148be2f45d674e6164eac5d0d5eca.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0062.png" +dest_files=["res://.godot/imported/tile_0062.png-0f9148be2f45d674e6164eac5d0d5eca.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0063.png b/assets/kenney/tiny_dungeon/Tiles/tile_0063.png new file mode 100644 index 0000000..11b5cf7 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0063.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0063.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0063.png.import new file mode 100644 index 0000000..7bb4983 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0063.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://clsr1q10efklx" +path="res://.godot/imported/tile_0063.png-22c652be04542a05677f1bb64722c774.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0063.png" +dest_files=["res://.godot/imported/tile_0063.png-22c652be04542a05677f1bb64722c774.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0064.png b/assets/kenney/tiny_dungeon/Tiles/tile_0064.png new file mode 100644 index 0000000..c574d11 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0064.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0064.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0064.png.import new file mode 100644 index 0000000..f453686 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0064.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtwhmfv7cgqwq" +path="res://.godot/imported/tile_0064.png-bc592b4afe7927ead54dffaa46f7f567.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0064.png" +dest_files=["res://.godot/imported/tile_0064.png-bc592b4afe7927ead54dffaa46f7f567.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0065.png b/assets/kenney/tiny_dungeon/Tiles/tile_0065.png new file mode 100644 index 0000000..8dba401 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0065.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0065.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0065.png.import new file mode 100644 index 0000000..9332a2e --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0065.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://1tw7vs6s02jd" +path="res://.godot/imported/tile_0065.png-a40ec84e5a313236f7b7315a6106159e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0065.png" +dest_files=["res://.godot/imported/tile_0065.png-a40ec84e5a313236f7b7315a6106159e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0066.png b/assets/kenney/tiny_dungeon/Tiles/tile_0066.png new file mode 100644 index 0000000..f5ae1b6 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0066.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0066.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0066.png.import new file mode 100644 index 0000000..cf76ede --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0066.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dbq5leaj2dlx5" +path="res://.godot/imported/tile_0066.png-e1f50610f85b748a14f8ab63cce59b74.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0066.png" +dest_files=["res://.godot/imported/tile_0066.png-e1f50610f85b748a14f8ab63cce59b74.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0067.png b/assets/kenney/tiny_dungeon/Tiles/tile_0067.png new file mode 100644 index 0000000..db9f646 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0067.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0067.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0067.png.import new file mode 100644 index 0000000..fcc7269 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0067.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctyolroneadke" +path="res://.godot/imported/tile_0067.png-0edc6fba637fcd8bea0faa6477cc68e0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0067.png" +dest_files=["res://.godot/imported/tile_0067.png-0edc6fba637fcd8bea0faa6477cc68e0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0068.png b/assets/kenney/tiny_dungeon/Tiles/tile_0068.png new file mode 100644 index 0000000..a03571a Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0068.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0068.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0068.png.import new file mode 100644 index 0000000..785bc51 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0068.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcnksojp8is8m" +path="res://.godot/imported/tile_0068.png-2921e17adada6472d2cc36a8d1dd7b1a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0068.png" +dest_files=["res://.godot/imported/tile_0068.png-2921e17adada6472d2cc36a8d1dd7b1a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0069.png b/assets/kenney/tiny_dungeon/Tiles/tile_0069.png new file mode 100644 index 0000000..fceb76e Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0069.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0069.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0069.png.import new file mode 100644 index 0000000..db8e06d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0069.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpntr8d20e7ra" +path="res://.godot/imported/tile_0069.png-ceef0f1ace646b0b6d08ab44b8a87b13.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0069.png" +dest_files=["res://.godot/imported/tile_0069.png-ceef0f1ace646b0b6d08ab44b8a87b13.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0070.png b/assets/kenney/tiny_dungeon/Tiles/tile_0070.png new file mode 100644 index 0000000..c77f8ae Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0070.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0070.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0070.png.import new file mode 100644 index 0000000..d91bce8 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0070.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dh5qleka0nnag" +path="res://.godot/imported/tile_0070.png-0dbf042f4b7101d9b77a0842f5df32fb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0070.png" +dest_files=["res://.godot/imported/tile_0070.png-0dbf042f4b7101d9b77a0842f5df32fb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0071.png b/assets/kenney/tiny_dungeon/Tiles/tile_0071.png new file mode 100644 index 0000000..9624f3f Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0071.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0071.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0071.png.import new file mode 100644 index 0000000..622f3e4 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0071.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://u6fr5d33ft3r" +path="res://.godot/imported/tile_0071.png-31b59596619b8f465380fc9fe081d8ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0071.png" +dest_files=["res://.godot/imported/tile_0071.png-31b59596619b8f465380fc9fe081d8ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0072.png b/assets/kenney/tiny_dungeon/Tiles/tile_0072.png new file mode 100644 index 0000000..6ca51a3 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0072.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0072.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0072.png.import new file mode 100644 index 0000000..1d4aa03 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0072.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btb6sgw56e6uy" +path="res://.godot/imported/tile_0072.png-bf169f0b9da739ba3f8429b330b7b1bd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0072.png" +dest_files=["res://.godot/imported/tile_0072.png-bf169f0b9da739ba3f8429b330b7b1bd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0073.png b/assets/kenney/tiny_dungeon/Tiles/tile_0073.png new file mode 100644 index 0000000..00ff065 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0073.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0073.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0073.png.import new file mode 100644 index 0000000..def119b --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0073.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djakv8wf1yq6q" +path="res://.godot/imported/tile_0073.png-43d0096413f8ee9d090e85dd815406ae.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0073.png" +dest_files=["res://.godot/imported/tile_0073.png-43d0096413f8ee9d090e85dd815406ae.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0074.png b/assets/kenney/tiny_dungeon/Tiles/tile_0074.png new file mode 100644 index 0000000..407e2d1 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0074.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0074.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0074.png.import new file mode 100644 index 0000000..dd99fa5 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0074.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://gk1c5ldl2a8b" +path="res://.godot/imported/tile_0074.png-80cedd581712fcc1e457fd6572d23ab9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0074.png" +dest_files=["res://.godot/imported/tile_0074.png-80cedd581712fcc1e457fd6572d23ab9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0075.png b/assets/kenney/tiny_dungeon/Tiles/tile_0075.png new file mode 100644 index 0000000..4b91993 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0075.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0075.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0075.png.import new file mode 100644 index 0000000..bc6f7ac --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0075.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cykiqcys6n64v" +path="res://.godot/imported/tile_0075.png-d492e14695a7362ff0d04305542cc812.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0075.png" +dest_files=["res://.godot/imported/tile_0075.png-d492e14695a7362ff0d04305542cc812.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0076.png b/assets/kenney/tiny_dungeon/Tiles/tile_0076.png new file mode 100644 index 0000000..08f2649 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0076.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0076.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0076.png.import new file mode 100644 index 0000000..9c5276d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0076.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfov36u0u04l5" +path="res://.godot/imported/tile_0076.png-4c1813435ff8d67b81acaa7d17647b79.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0076.png" +dest_files=["res://.godot/imported/tile_0076.png-4c1813435ff8d67b81acaa7d17647b79.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0077.png b/assets/kenney/tiny_dungeon/Tiles/tile_0077.png new file mode 100644 index 0000000..4e67040 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0077.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0077.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0077.png.import new file mode 100644 index 0000000..8d5a990 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0077.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://chnwipwdtm6vc" +path="res://.godot/imported/tile_0077.png-33dd473884687cf6344262ea7543dd51.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0077.png" +dest_files=["res://.godot/imported/tile_0077.png-33dd473884687cf6344262ea7543dd51.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0078.png b/assets/kenney/tiny_dungeon/Tiles/tile_0078.png new file mode 100644 index 0000000..c395235 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0078.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0078.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0078.png.import new file mode 100644 index 0000000..fc2f9f2 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0078.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ubh6mb0v8ddc" +path="res://.godot/imported/tile_0078.png-842e78a79e13c20c69e2227b5c539114.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0078.png" +dest_files=["res://.godot/imported/tile_0078.png-842e78a79e13c20c69e2227b5c539114.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0079.png b/assets/kenney/tiny_dungeon/Tiles/tile_0079.png new file mode 100644 index 0000000..765965a Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0079.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0079.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0079.png.import new file mode 100644 index 0000000..8af9bf3 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0079.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d2wlagylydnrb" +path="res://.godot/imported/tile_0079.png-198d8342e2f5eb0a80fe93c06ca5c37c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0079.png" +dest_files=["res://.godot/imported/tile_0079.png-198d8342e2f5eb0a80fe93c06ca5c37c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0080.png b/assets/kenney/tiny_dungeon/Tiles/tile_0080.png new file mode 100644 index 0000000..3336418 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0080.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0080.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0080.png.import new file mode 100644 index 0000000..fac8e6b --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0080.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sv0n600ft5ic" +path="res://.godot/imported/tile_0080.png-6ff7e6adce60d5e0f84ac0ac4d464d81.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0080.png" +dest_files=["res://.godot/imported/tile_0080.png-6ff7e6adce60d5e0f84ac0ac4d464d81.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0081.png b/assets/kenney/tiny_dungeon/Tiles/tile_0081.png new file mode 100644 index 0000000..19e40a9 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0081.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0081.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0081.png.import new file mode 100644 index 0000000..e48102f --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0081.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ca07gji6qdg2h" +path="res://.godot/imported/tile_0081.png-c8e67c4c874c5137920f7d82dacccc6e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0081.png" +dest_files=["res://.godot/imported/tile_0081.png-c8e67c4c874c5137920f7d82dacccc6e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0082.png b/assets/kenney/tiny_dungeon/Tiles/tile_0082.png new file mode 100644 index 0000000..78f95bc Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0082.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0082.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0082.png.import new file mode 100644 index 0000000..3aa3466 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0082.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crljny7n7ldtm" +path="res://.godot/imported/tile_0082.png-676cf6a4f2f5452106fbe2f6691568da.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0082.png" +dest_files=["res://.godot/imported/tile_0082.png-676cf6a4f2f5452106fbe2f6691568da.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0083.png b/assets/kenney/tiny_dungeon/Tiles/tile_0083.png new file mode 100644 index 0000000..19e40a9 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0083.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0083.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0083.png.import new file mode 100644 index 0000000..788149c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0083.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtkt8phg4qjqh" +path="res://.godot/imported/tile_0083.png-ace68b18b43c5b72711dd6a8dedfb651.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0083.png" +dest_files=["res://.godot/imported/tile_0083.png-ace68b18b43c5b72711dd6a8dedfb651.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0084.png b/assets/kenney/tiny_dungeon/Tiles/tile_0084.png new file mode 100644 index 0000000..83aef72 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0084.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0084.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0084.png.import new file mode 100644 index 0000000..f064af5 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0084.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctvrptww2imqs" +path="res://.godot/imported/tile_0084.png-660b509625b616d130b450813b50e260.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0084.png" +dest_files=["res://.godot/imported/tile_0084.png-660b509625b616d130b450813b50e260.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0085.png b/assets/kenney/tiny_dungeon/Tiles/tile_0085.png new file mode 100644 index 0000000..5f12f94 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0085.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0085.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0085.png.import new file mode 100644 index 0000000..83a14c8 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0085.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c66jsjk5pbtvr" +path="res://.godot/imported/tile_0085.png-605b2d248530963cd82f637eb5d5cb1d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0085.png" +dest_files=["res://.godot/imported/tile_0085.png-605b2d248530963cd82f637eb5d5cb1d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0086.png b/assets/kenney/tiny_dungeon/Tiles/tile_0086.png new file mode 100644 index 0000000..0a1cb83 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0086.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0086.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0086.png.import new file mode 100644 index 0000000..81ee17a --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0086.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d177rvd5ww3ed" +path="res://.godot/imported/tile_0086.png-db1b411293eafad9d9d0927ecec3585c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0086.png" +dest_files=["res://.godot/imported/tile_0086.png-db1b411293eafad9d9d0927ecec3585c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0087.png b/assets/kenney/tiny_dungeon/Tiles/tile_0087.png new file mode 100644 index 0000000..1b47647 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0087.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0087.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0087.png.import new file mode 100644 index 0000000..257d075 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0087.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cva51kebc2hst" +path="res://.godot/imported/tile_0087.png-cb98124211d5c00057ce111dc9342bc7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0087.png" +dest_files=["res://.godot/imported/tile_0087.png-cb98124211d5c00057ce111dc9342bc7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0088.png b/assets/kenney/tiny_dungeon/Tiles/tile_0088.png new file mode 100644 index 0000000..94aa932 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0088.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0088.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0088.png.import new file mode 100644 index 0000000..330bb94 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0088.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://fgmpeckbh1l4" +path="res://.godot/imported/tile_0088.png-e4a49ececa2457623f093c496a714d3c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0088.png" +dest_files=["res://.godot/imported/tile_0088.png-e4a49ececa2457623f093c496a714d3c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0089.png b/assets/kenney/tiny_dungeon/Tiles/tile_0089.png new file mode 100644 index 0000000..d462435 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0089.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0089.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0089.png.import new file mode 100644 index 0000000..de238b0 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0089.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cs4ojfm3r16fa" +path="res://.godot/imported/tile_0089.png-497e008166cd0c158b1ae6a593315532.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0089.png" +dest_files=["res://.godot/imported/tile_0089.png-497e008166cd0c158b1ae6a593315532.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0090.png b/assets/kenney/tiny_dungeon/Tiles/tile_0090.png new file mode 100644 index 0000000..008737e Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0090.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0090.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0090.png.import new file mode 100644 index 0000000..c614c62 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0090.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2h56tghe6f1l" +path="res://.godot/imported/tile_0090.png-e9547a13a1451a3b7cbd77b241c28e0c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0090.png" +dest_files=["res://.godot/imported/tile_0090.png-e9547a13a1451a3b7cbd77b241c28e0c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0091.png b/assets/kenney/tiny_dungeon/Tiles/tile_0091.png new file mode 100644 index 0000000..2b322b9 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0091.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0091.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0091.png.import new file mode 100644 index 0000000..791bc7d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0091.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dc1avs6pr8346" +path="res://.godot/imported/tile_0091.png-cbbab1c34774a6c75875cc5a157f22a2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0091.png" +dest_files=["res://.godot/imported/tile_0091.png-cbbab1c34774a6c75875cc5a157f22a2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0092.png b/assets/kenney/tiny_dungeon/Tiles/tile_0092.png new file mode 100644 index 0000000..906f46b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0092.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0092.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0092.png.import new file mode 100644 index 0000000..dafb656 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0092.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfmo01djlbocq" +path="res://.godot/imported/tile_0092.png-39396323368e6ea6d0feee9cf4b7f583.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0092.png" +dest_files=["res://.godot/imported/tile_0092.png-39396323368e6ea6d0feee9cf4b7f583.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0093.png b/assets/kenney/tiny_dungeon/Tiles/tile_0093.png new file mode 100644 index 0000000..4914e05 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0093.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0093.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0093.png.import new file mode 100644 index 0000000..d4a2272 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0093.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://grflom31n18e" +path="res://.godot/imported/tile_0093.png-e31770ecc473e4b58b6b4a2bfc26c21e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0093.png" +dest_files=["res://.godot/imported/tile_0093.png-e31770ecc473e4b58b6b4a2bfc26c21e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0094.png b/assets/kenney/tiny_dungeon/Tiles/tile_0094.png new file mode 100644 index 0000000..c77f8ae Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0094.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0094.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0094.png.import new file mode 100644 index 0000000..e6f98a9 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0094.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blretd1ole2or" +path="res://.godot/imported/tile_0094.png-1c19c349da4f5c444e5b0451b5168edb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0094.png" +dest_files=["res://.godot/imported/tile_0094.png-1c19c349da4f5c444e5b0451b5168edb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0095.png b/assets/kenney/tiny_dungeon/Tiles/tile_0095.png new file mode 100644 index 0000000..95b220b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0095.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0095.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0095.png.import new file mode 100644 index 0000000..0ce59b3 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0095.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dl8k38md43fvx" +path="res://.godot/imported/tile_0095.png-51216969f8f233225b4198529b2dcf06.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0095.png" +dest_files=["res://.godot/imported/tile_0095.png-51216969f8f233225b4198529b2dcf06.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0096.png b/assets/kenney/tiny_dungeon/Tiles/tile_0096.png new file mode 100644 index 0000000..ed3029b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0096.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0096.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0096.png.import new file mode 100644 index 0000000..e1493d3 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0096.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dkr0cv53a7jgm" +path="res://.godot/imported/tile_0096.png-9066f579078f86f47a98afee25ffd0cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0096.png" +dest_files=["res://.godot/imported/tile_0096.png-9066f579078f86f47a98afee25ffd0cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0097.png b/assets/kenney/tiny_dungeon/Tiles/tile_0097.png new file mode 100644 index 0000000..b0106b2 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0097.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0097.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0097.png.import new file mode 100644 index 0000000..92e0ca3 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0097.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://duvmtauxdg1ko" +path="res://.godot/imported/tile_0097.png-896c688a6b5e8c90cbf3cd691f55263b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0097.png" +dest_files=["res://.godot/imported/tile_0097.png-896c688a6b5e8c90cbf3cd691f55263b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0098.png b/assets/kenney/tiny_dungeon/Tiles/tile_0098.png new file mode 100644 index 0000000..3395371 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0098.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0098.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0098.png.import new file mode 100644 index 0000000..dfb8351 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0098.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://betq841r5qi6a" +path="res://.godot/imported/tile_0098.png-f6974486dd115585a1bb8b0a8c01f7de.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0098.png" +dest_files=["res://.godot/imported/tile_0098.png-f6974486dd115585a1bb8b0a8c01f7de.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0099.png b/assets/kenney/tiny_dungeon/Tiles/tile_0099.png new file mode 100644 index 0000000..2278e3a Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0099.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0099.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0099.png.import new file mode 100644 index 0000000..96808fb --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0099.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://s1nkh8w1nrqe" +path="res://.godot/imported/tile_0099.png-254d7bb8b91b51510360896ab2a68da3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0099.png" +dest_files=["res://.godot/imported/tile_0099.png-254d7bb8b91b51510360896ab2a68da3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0100.png b/assets/kenney/tiny_dungeon/Tiles/tile_0100.png new file mode 100644 index 0000000..be9422c Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0100.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0100.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0100.png.import new file mode 100644 index 0000000..7be913c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0100.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjtxag0hwuelm" +path="res://.godot/imported/tile_0100.png-4ae9320270c29b9f28274b1e9309441b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0100.png" +dest_files=["res://.godot/imported/tile_0100.png-4ae9320270c29b9f28274b1e9309441b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0101.png b/assets/kenney/tiny_dungeon/Tiles/tile_0101.png new file mode 100644 index 0000000..4897150 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0101.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0101.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0101.png.import new file mode 100644 index 0000000..d5bfefa --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0101.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dln6u32dayxrb" +path="res://.godot/imported/tile_0101.png-974905799daa74058be68a7ba1558bcc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0101.png" +dest_files=["res://.godot/imported/tile_0101.png-974905799daa74058be68a7ba1558bcc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0102.png b/assets/kenney/tiny_dungeon/Tiles/tile_0102.png new file mode 100644 index 0000000..c9681ed Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0102.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0102.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0102.png.import new file mode 100644 index 0000000..068bdbf --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0102.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0wmvdnw3f3oy" +path="res://.godot/imported/tile_0102.png-d0cabecaab56974ea7edb7a470feb95b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0102.png" +dest_files=["res://.godot/imported/tile_0102.png-d0cabecaab56974ea7edb7a470feb95b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0103.png b/assets/kenney/tiny_dungeon/Tiles/tile_0103.png new file mode 100644 index 0000000..23f289d Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0103.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0103.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0103.png.import new file mode 100644 index 0000000..125fd08 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0103.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xyt6n3atpyiw" +path="res://.godot/imported/tile_0103.png-fc7ace035fde0afed09c6c67b7ecf696.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0103.png" +dest_files=["res://.godot/imported/tile_0103.png-fc7ace035fde0afed09c6c67b7ecf696.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0104.png b/assets/kenney/tiny_dungeon/Tiles/tile_0104.png new file mode 100644 index 0000000..bb7c702 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0104.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0104.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0104.png.import new file mode 100644 index 0000000..f4df0ce --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0104.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btrbhl2qmjmvr" +path="res://.godot/imported/tile_0104.png-4853f459b5ee31c2ec9f2ea6e068e319.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0104.png" +dest_files=["res://.godot/imported/tile_0104.png-4853f459b5ee31c2ec9f2ea6e068e319.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0105.png b/assets/kenney/tiny_dungeon/Tiles/tile_0105.png new file mode 100644 index 0000000..b699b1b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0105.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0105.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0105.png.import new file mode 100644 index 0000000..621cf67 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0105.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdtjp05vevk3a" +path="res://.godot/imported/tile_0105.png-0102901e2cb3141a79ef74caaae46f10.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0105.png" +dest_files=["res://.godot/imported/tile_0105.png-0102901e2cb3141a79ef74caaae46f10.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0106.png b/assets/kenney/tiny_dungeon/Tiles/tile_0106.png new file mode 100644 index 0000000..905ebcb Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0106.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0106.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0106.png.import new file mode 100644 index 0000000..bb1a5b7 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0106.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxdydx37exk4q" +path="res://.godot/imported/tile_0106.png-c074e72208fdbba498ab6b285c3f4cf5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0106.png" +dest_files=["res://.godot/imported/tile_0106.png-c074e72208fdbba498ab6b285c3f4cf5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0107.png b/assets/kenney/tiny_dungeon/Tiles/tile_0107.png new file mode 100644 index 0000000..8651722 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0107.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0107.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0107.png.import new file mode 100644 index 0000000..fa0308d --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0107.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://srxtgbgsql0j" +path="res://.godot/imported/tile_0107.png-6801b129a99ad3fba37b5a1bdadb5b31.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0107.png" +dest_files=["res://.godot/imported/tile_0107.png-6801b129a99ad3fba37b5a1bdadb5b31.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0108.png b/assets/kenney/tiny_dungeon/Tiles/tile_0108.png new file mode 100644 index 0000000..7b6f8ef Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0108.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0108.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0108.png.import new file mode 100644 index 0000000..201a737 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0108.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://clrelw4df6loo" +path="res://.godot/imported/tile_0108.png-9c2275836cb50e6e12ad5edb08300b05.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0108.png" +dest_files=["res://.godot/imported/tile_0108.png-9c2275836cb50e6e12ad5edb08300b05.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0109.png b/assets/kenney/tiny_dungeon/Tiles/tile_0109.png new file mode 100644 index 0000000..705fa2d Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0109.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0109.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0109.png.import new file mode 100644 index 0000000..4ece369 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0109.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c28mx7j4g2xpg" +path="res://.godot/imported/tile_0109.png-d380bca10237e46ace70cafae154a298.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0109.png" +dest_files=["res://.godot/imported/tile_0109.png-d380bca10237e46ace70cafae154a298.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0110.png b/assets/kenney/tiny_dungeon/Tiles/tile_0110.png new file mode 100644 index 0000000..93866d1 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0110.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0110.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0110.png.import new file mode 100644 index 0000000..3187036 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0110.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://fjvxpjghjpqu" +path="res://.godot/imported/tile_0110.png-61083a0cc01e9489d8e500e57f572511.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0110.png" +dest_files=["res://.godot/imported/tile_0110.png-61083a0cc01e9489d8e500e57f572511.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0111.png b/assets/kenney/tiny_dungeon/Tiles/tile_0111.png new file mode 100644 index 0000000..564b39c Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0111.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0111.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0111.png.import new file mode 100644 index 0000000..2bb3f72 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0111.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cugsllr486rhp" +path="res://.godot/imported/tile_0111.png-b1b92bd8a71cbcbc5c26497bdb1456ea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0111.png" +dest_files=["res://.godot/imported/tile_0111.png-b1b92bd8a71cbcbc5c26497bdb1456ea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0112.png b/assets/kenney/tiny_dungeon/Tiles/tile_0112.png new file mode 100644 index 0000000..6026b28 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0112.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0112.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0112.png.import new file mode 100644 index 0000000..1d73c3c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0112.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgn8lvetviaoh" +path="res://.godot/imported/tile_0112.png-82a1a46d46d29bd9dda4c17ee77e92a8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0112.png" +dest_files=["res://.godot/imported/tile_0112.png-82a1a46d46d29bd9dda4c17ee77e92a8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0113.png b/assets/kenney/tiny_dungeon/Tiles/tile_0113.png new file mode 100644 index 0000000..0d070f9 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0113.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0113.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0113.png.import new file mode 100644 index 0000000..a4dee1c --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0113.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://tr3j8lvpdyag" +path="res://.godot/imported/tile_0113.png-84fb5824da285d38615da816ed64d2bf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0113.png" +dest_files=["res://.godot/imported/tile_0113.png-84fb5824da285d38615da816ed64d2bf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0114.png b/assets/kenney/tiny_dungeon/Tiles/tile_0114.png new file mode 100644 index 0000000..54e4df4 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0114.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0114.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0114.png.import new file mode 100644 index 0000000..02eb85a --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0114.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drqhlvi6dlljv" +path="res://.godot/imported/tile_0114.png-9b957e2e5eb55d4cbb26254696ee3a56.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0114.png" +dest_files=["res://.godot/imported/tile_0114.png-9b957e2e5eb55d4cbb26254696ee3a56.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0115.png b/assets/kenney/tiny_dungeon/Tiles/tile_0115.png new file mode 100644 index 0000000..00e3e0d Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0115.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0115.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0115.png.import new file mode 100644 index 0000000..750f51a --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0115.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://buvitln7mraj3" +path="res://.godot/imported/tile_0115.png-c76be097ef8d5e48987f8d6d21a258f0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0115.png" +dest_files=["res://.godot/imported/tile_0115.png-c76be097ef8d5e48987f8d6d21a258f0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0116.png b/assets/kenney/tiny_dungeon/Tiles/tile_0116.png new file mode 100644 index 0000000..caae164 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0116.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0116.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0116.png.import new file mode 100644 index 0000000..59af467 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0116.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6fjfj6o28fan" +path="res://.godot/imported/tile_0116.png-519e68083dcf5bdef37a5202b13bba89.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0116.png" +dest_files=["res://.godot/imported/tile_0116.png-519e68083dcf5bdef37a5202b13bba89.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0117.png b/assets/kenney/tiny_dungeon/Tiles/tile_0117.png new file mode 100644 index 0000000..eaea357 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0117.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0117.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0117.png.import new file mode 100644 index 0000000..00bba66 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0117.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3kpm7r3us4lq" +path="res://.godot/imported/tile_0117.png-26b108dbc9809bcaebfd70f0483ae743.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0117.png" +dest_files=["res://.godot/imported/tile_0117.png-26b108dbc9809bcaebfd70f0483ae743.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0118.png b/assets/kenney/tiny_dungeon/Tiles/tile_0118.png new file mode 100644 index 0000000..68ab043 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0118.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0118.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0118.png.import new file mode 100644 index 0000000..d039a88 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0118.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xm6p6cjyu78n" +path="res://.godot/imported/tile_0118.png-34e8bb1665946e8db55cda4861035af1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0118.png" +dest_files=["res://.godot/imported/tile_0118.png-34e8bb1665946e8db55cda4861035af1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0119.png b/assets/kenney/tiny_dungeon/Tiles/tile_0119.png new file mode 100644 index 0000000..98f1093 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0119.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0119.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0119.png.import new file mode 100644 index 0000000..def5162 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0119.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dj2gq281tg120" +path="res://.godot/imported/tile_0119.png-12d735b7bd21cfde7c6f6edb63c911ea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0119.png" +dest_files=["res://.godot/imported/tile_0119.png-12d735b7bd21cfde7c6f6edb63c911ea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0120.png b/assets/kenney/tiny_dungeon/Tiles/tile_0120.png new file mode 100644 index 0000000..2f0d9b1 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0120.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0120.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0120.png.import new file mode 100644 index 0000000..f5fc4d0 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0120.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cleag7ggk1y47" +path="res://.godot/imported/tile_0120.png-30d4cc65490babc37efda563bb8f3339.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0120.png" +dest_files=["res://.godot/imported/tile_0120.png-30d4cc65490babc37efda563bb8f3339.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0121.png b/assets/kenney/tiny_dungeon/Tiles/tile_0121.png new file mode 100644 index 0000000..fca32b8 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0121.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0121.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0121.png.import new file mode 100644 index 0000000..1a6ec22 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0121.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dv2qew55efj2o" +path="res://.godot/imported/tile_0121.png-6dccb172f67d55a08f69bed077db3b16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0121.png" +dest_files=["res://.godot/imported/tile_0121.png-6dccb172f67d55a08f69bed077db3b16.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0122.png b/assets/kenney/tiny_dungeon/Tiles/tile_0122.png new file mode 100644 index 0000000..179f42f Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0122.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0122.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0122.png.import new file mode 100644 index 0000000..4a9d725 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0122.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://chrrif7jc7qbh" +path="res://.godot/imported/tile_0122.png-a212b108de43e5a0df2236695cabf75b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0122.png" +dest_files=["res://.godot/imported/tile_0122.png-a212b108de43e5a0df2236695cabf75b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0123.png b/assets/kenney/tiny_dungeon/Tiles/tile_0123.png new file mode 100644 index 0000000..be4b83e Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0123.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0123.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0123.png.import new file mode 100644 index 0000000..cf5a00a --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0123.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dau32ot3g1rgs" +path="res://.godot/imported/tile_0123.png-d6797a0fea5dca0f81d44eee066a9a9f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0123.png" +dest_files=["res://.godot/imported/tile_0123.png-d6797a0fea5dca0f81d44eee066a9a9f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0124.png b/assets/kenney/tiny_dungeon/Tiles/tile_0124.png new file mode 100644 index 0000000..3fe67e4 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0124.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0124.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0124.png.import new file mode 100644 index 0000000..2293337 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0124.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cupvqxtqqu7su" +path="res://.godot/imported/tile_0124.png-3e7ac3137c1da9f5b64c8fa5eed0c5f2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0124.png" +dest_files=["res://.godot/imported/tile_0124.png-3e7ac3137c1da9f5b64c8fa5eed0c5f2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0125.png b/assets/kenney/tiny_dungeon/Tiles/tile_0125.png new file mode 100644 index 0000000..3029b20 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0125.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0125.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0125.png.import new file mode 100644 index 0000000..c21cf38 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0125.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyewm5nf5nkjg" +path="res://.godot/imported/tile_0125.png-b526221d421b49de89bc6da0080dbeb2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0125.png" +dest_files=["res://.godot/imported/tile_0125.png-b526221d421b49de89bc6da0080dbeb2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0126.png b/assets/kenney/tiny_dungeon/Tiles/tile_0126.png new file mode 100644 index 0000000..d28ba06 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0126.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0126.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0126.png.import new file mode 100644 index 0000000..496d0b5 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0126.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c35r08dkfne1o" +path="res://.godot/imported/tile_0126.png-751deb4ba3efa8297ae2a1b3f3d5bc7f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0126.png" +dest_files=["res://.godot/imported/tile_0126.png-751deb4ba3efa8297ae2a1b3f3d5bc7f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0127.png b/assets/kenney/tiny_dungeon/Tiles/tile_0127.png new file mode 100644 index 0000000..20e0cc9 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0127.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0127.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0127.png.import new file mode 100644 index 0000000..8a866ac --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0127.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blj5dg060mj6a" +path="res://.godot/imported/tile_0127.png-0d3030b84b8df7a404593f5ce6e8cf20.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0127.png" +dest_files=["res://.godot/imported/tile_0127.png-0d3030b84b8df7a404593f5ce6e8cf20.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0128.png b/assets/kenney/tiny_dungeon/Tiles/tile_0128.png new file mode 100644 index 0000000..208da4d Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0128.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0128.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0128.png.import new file mode 100644 index 0000000..389cab4 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0128.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b83f2p5x7hh28" +path="res://.godot/imported/tile_0128.png-b4920f8c7147c54a22d082ce04483ae6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0128.png" +dest_files=["res://.godot/imported/tile_0128.png-b4920f8c7147c54a22d082ce04483ae6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0129.png b/assets/kenney/tiny_dungeon/Tiles/tile_0129.png new file mode 100644 index 0000000..ac7fe54 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0129.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0129.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0129.png.import new file mode 100644 index 0000000..e6b8bfc --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0129.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhjexv5s5rp5y" +path="res://.godot/imported/tile_0129.png-ea45a45a9b80ea2c00b90cd03708a71e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0129.png" +dest_files=["res://.godot/imported/tile_0129.png-ea45a45a9b80ea2c00b90cd03708a71e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0130.png b/assets/kenney/tiny_dungeon/Tiles/tile_0130.png new file mode 100644 index 0000000..2cf4d02 Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0130.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0130.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0130.png.import new file mode 100644 index 0000000..a31886b --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0130.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsta0ti8v6xk7" +path="res://.godot/imported/tile_0130.png-06d03212094b5118984eeabfb38719d5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0130.png" +dest_files=["res://.godot/imported/tile_0130.png-06d03212094b5118984eeabfb38719d5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0131.png b/assets/kenney/tiny_dungeon/Tiles/tile_0131.png new file mode 100644 index 0000000..ca4935b Binary files /dev/null and b/assets/kenney/tiny_dungeon/Tiles/tile_0131.png differ diff --git a/assets/kenney/tiny_dungeon/Tiles/tile_0131.png.import b/assets/kenney/tiny_dungeon/Tiles/tile_0131.png.import new file mode 100644 index 0000000..e401797 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tiles/tile_0131.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkofumwxvkrvv" +path="res://.godot/imported/tile_0131.png-1babc8d5d2b6d73ace5d0880eec4a86c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_dungeon/Tiles/tile_0131.png" +dest_files=["res://.godot/imported/tile_0131.png-1babc8d5d2b6d73ace5d0880eec4a86c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_dungeon/Tilesheet.txt b/assets/kenney/tiny_dungeon/Tilesheet.txt new file mode 100644 index 0000000..3ecbdd2 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 16px × 16px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 12 tiles +Total tiles (vertical) • 11 tiles +--- +Total tiles in sheet • 132 tiles \ No newline at end of file diff --git a/assets/kenney/tiny_dungeon/Visit Kenney.url b/assets/kenney/tiny_dungeon/Visit Kenney.url new file mode 100644 index 0000000..fbdde43 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Visit Kenney.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=http://www.kenney.nl/ \ No newline at end of file diff --git a/assets/kenney/tiny_dungeon/Visit Patreon.url b/assets/kenney/tiny_dungeon/Visit Patreon.url new file mode 100644 index 0000000..439ef26 --- /dev/null +++ b/assets/kenney/tiny_dungeon/Visit Patreon.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://www.patreon.com/kenney/ \ No newline at end of file diff --git a/assets/kenney/tiny_town/License.txt b/assets/kenney/tiny_town/License.txt new file mode 100644 index 0000000..21ea372 --- /dev/null +++ b/assets/kenney/tiny_town/License.txt @@ -0,0 +1,22 @@ + + + Tiny Town (1.1) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 11-01-2023 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/kenney/tiny_town/Preview.png b/assets/kenney/tiny_town/Preview.png new file mode 100644 index 0000000..127e13c Binary files /dev/null and b/assets/kenney/tiny_town/Preview.png differ diff --git a/assets/kenney/tiny_town/Preview.png.import b/assets/kenney/tiny_town/Preview.png.import new file mode 100644 index 0000000..6689214 --- /dev/null +++ b/assets/kenney/tiny_town/Preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkq1hcdevu5nv" +path="res://.godot/imported/Preview.png-7987beef7b9815e8f78b4e5c01ced021.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Preview.png" +dest_files=["res://.godot/imported/Preview.png-7987beef7b9815e8f78b4e5c01ced021.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Sample.png b/assets/kenney/tiny_town/Sample.png new file mode 100644 index 0000000..10e4a71 Binary files /dev/null and b/assets/kenney/tiny_town/Sample.png differ diff --git a/assets/kenney/tiny_town/Sample.png.import b/assets/kenney/tiny_town/Sample.png.import new file mode 100644 index 0000000..a2ddb93 --- /dev/null +++ b/assets/kenney/tiny_town/Sample.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxkch4c0gi7rb" +path="res://.godot/imported/Sample.png-824679a61677316c0d15bdf8f279a35c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Sample.png" +dest_files=["res://.godot/imported/Sample.png-824679a61677316c0d15bdf8f279a35c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tilemap/tilemap.png b/assets/kenney/tiny_town/Tilemap/tilemap.png new file mode 100644 index 0000000..21b5a0f Binary files /dev/null and b/assets/kenney/tiny_town/Tilemap/tilemap.png differ diff --git a/assets/kenney/tiny_town/Tilemap/tilemap.png.import b/assets/kenney/tiny_town/Tilemap/tilemap.png.import new file mode 100644 index 0000000..81d5bc5 --- /dev/null +++ b/assets/kenney/tiny_town/Tilemap/tilemap.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://4jfw4u8hhp1w" +path="res://.godot/imported/tilemap.png-ad2d30784a381d1876f2e8552b6c064b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tilemap/tilemap.png" +dest_files=["res://.godot/imported/tilemap.png-ad2d30784a381d1876f2e8552b6c064b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tilemap/tilemap_packed.png b/assets/kenney/tiny_town/Tilemap/tilemap_packed.png new file mode 100644 index 0000000..1655d1d Binary files /dev/null and b/assets/kenney/tiny_town/Tilemap/tilemap_packed.png differ diff --git a/assets/kenney/tiny_town/Tilemap/tilemap_packed.png.import b/assets/kenney/tiny_town/Tilemap/tilemap_packed.png.import new file mode 100644 index 0000000..5d13228 --- /dev/null +++ b/assets/kenney/tiny_town/Tilemap/tilemap_packed.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://co6s0qffbxtsa" +path="res://.godot/imported/tilemap_packed.png-a2cb8bb651bb1364a268d0b44f82a665.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tilemap/tilemap_packed.png" +dest_files=["res://.godot/imported/tilemap_packed.png-a2cb8bb651bb1364a268d0b44f82a665.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0000.png b/assets/kenney/tiny_town/Tiles/tile_0000.png new file mode 100644 index 0000000..e743c54 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0000.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0000.png.import b/assets/kenney/tiny_town/Tiles/tile_0000.png.import new file mode 100644 index 0000000..49644d2 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0000.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://iwbiyripg6hs" +path="res://.godot/imported/tile_0000.png-4c934d18121dc99aebc1cbb8d8b97e8d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0000.png" +dest_files=["res://.godot/imported/tile_0000.png-4c934d18121dc99aebc1cbb8d8b97e8d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0001.png b/assets/kenney/tiny_town/Tiles/tile_0001.png new file mode 100644 index 0000000..9f8471a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0001.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0001.png.import b/assets/kenney/tiny_town/Tiles/tile_0001.png.import new file mode 100644 index 0000000..7a69ba9 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0001.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://duj6orgvrgmlc" +path="res://.godot/imported/tile_0001.png-24119e0a873a08307bc60d5655f52d72.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0001.png" +dest_files=["res://.godot/imported/tile_0001.png-24119e0a873a08307bc60d5655f52d72.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0002.png b/assets/kenney/tiny_town/Tiles/tile_0002.png new file mode 100644 index 0000000..8f24c62 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0002.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0002.png.import b/assets/kenney/tiny_town/Tiles/tile_0002.png.import new file mode 100644 index 0000000..6bfdb6e --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0002.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qnchg0nmc5yk" +path="res://.godot/imported/tile_0002.png-0a18008e9543bb008029327e08f69ba5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0002.png" +dest_files=["res://.godot/imported/tile_0002.png-0a18008e9543bb008029327e08f69ba5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0003.png b/assets/kenney/tiny_town/Tiles/tile_0003.png new file mode 100644 index 0000000..2caf210 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0003.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0003.png.import b/assets/kenney/tiny_town/Tiles/tile_0003.png.import new file mode 100644 index 0000000..5ef8d6f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0003.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmwxipnuuub1d" +path="res://.godot/imported/tile_0003.png-f497f1aed0c1a6e602a1083c40682bfe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0003.png" +dest_files=["res://.godot/imported/tile_0003.png-f497f1aed0c1a6e602a1083c40682bfe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0004.png b/assets/kenney/tiny_town/Tiles/tile_0004.png new file mode 100644 index 0000000..4bbe8cd Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0004.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0004.png.import b/assets/kenney/tiny_town/Tiles/tile_0004.png.import new file mode 100644 index 0000000..aea14d4 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0004.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr1n1cds48ym0" +path="res://.godot/imported/tile_0004.png-cac039243b8e6981176491ff81245fc5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0004.png" +dest_files=["res://.godot/imported/tile_0004.png-cac039243b8e6981176491ff81245fc5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0005.png b/assets/kenney/tiny_town/Tiles/tile_0005.png new file mode 100644 index 0000000..f535495 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0005.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0005.png.import b/assets/kenney/tiny_town/Tiles/tile_0005.png.import new file mode 100644 index 0000000..341188f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0005.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cqormec5wfdtb" +path="res://.godot/imported/tile_0005.png-e0a216b66d69e17f8f800e7d49f5d093.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0005.png" +dest_files=["res://.godot/imported/tile_0005.png-e0a216b66d69e17f8f800e7d49f5d093.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0006.png b/assets/kenney/tiny_town/Tiles/tile_0006.png new file mode 100644 index 0000000..20f607b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0006.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0006.png.import b/assets/kenney/tiny_town/Tiles/tile_0006.png.import new file mode 100644 index 0000000..de6c196 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0006.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bc0dstj37l57h" +path="res://.godot/imported/tile_0006.png-77de87c8f09ac7202c0bf367211a4b2b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0006.png" +dest_files=["res://.godot/imported/tile_0006.png-77de87c8f09ac7202c0bf367211a4b2b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0007.png b/assets/kenney/tiny_town/Tiles/tile_0007.png new file mode 100644 index 0000000..f1cab07 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0007.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0007.png.import b/assets/kenney/tiny_town/Tiles/tile_0007.png.import new file mode 100644 index 0000000..61c707f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0007.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://beaydngek18sp" +path="res://.godot/imported/tile_0007.png-18a9b714728ad18edd13fb9050aaca13.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0007.png" +dest_files=["res://.godot/imported/tile_0007.png-18a9b714728ad18edd13fb9050aaca13.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0008.png b/assets/kenney/tiny_town/Tiles/tile_0008.png new file mode 100644 index 0000000..037cef7 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0008.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0008.png.import b/assets/kenney/tiny_town/Tiles/tile_0008.png.import new file mode 100644 index 0000000..2b6cca1 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0008.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cf6fq0dol53rb" +path="res://.godot/imported/tile_0008.png-2715e7d6a5f29040551c20b6ac232922.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0008.png" +dest_files=["res://.godot/imported/tile_0008.png-2715e7d6a5f29040551c20b6ac232922.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0009.png b/assets/kenney/tiny_town/Tiles/tile_0009.png new file mode 100644 index 0000000..ce63263 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0009.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0009.png.import b/assets/kenney/tiny_town/Tiles/tile_0009.png.import new file mode 100644 index 0000000..cf987d7 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0009.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwhoewrwu0p6" +path="res://.godot/imported/tile_0009.png-4b8eae5ba0c0905a7c643db79514325d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0009.png" +dest_files=["res://.godot/imported/tile_0009.png-4b8eae5ba0c0905a7c643db79514325d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0010.png b/assets/kenney/tiny_town/Tiles/tile_0010.png new file mode 100644 index 0000000..1aa0449 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0010.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0010.png.import b/assets/kenney/tiny_town/Tiles/tile_0010.png.import new file mode 100644 index 0000000..bf9bb0c --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0010.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5ajn7i85n1l1" +path="res://.godot/imported/tile_0010.png-501111d65aaf8cae70dfd952a1e6b057.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0010.png" +dest_files=["res://.godot/imported/tile_0010.png-501111d65aaf8cae70dfd952a1e6b057.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0011.png b/assets/kenney/tiny_town/Tiles/tile_0011.png new file mode 100644 index 0000000..b1b7842 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0011.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0011.png.import b/assets/kenney/tiny_town/Tiles/tile_0011.png.import new file mode 100644 index 0000000..d075df7 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0011.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvhvt3l0hhf4a" +path="res://.godot/imported/tile_0011.png-d0024037045859f9e490ca590a44a9e9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0011.png" +dest_files=["res://.godot/imported/tile_0011.png-d0024037045859f9e490ca590a44a9e9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0012.png b/assets/kenney/tiny_town/Tiles/tile_0012.png new file mode 100644 index 0000000..c664900 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0012.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0012.png.import b/assets/kenney/tiny_town/Tiles/tile_0012.png.import new file mode 100644 index 0000000..c1ca70c --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0012.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfwha5mjpc48g" +path="res://.godot/imported/tile_0012.png-60f75950441d6ae69c2b2ac8d04226b9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0012.png" +dest_files=["res://.godot/imported/tile_0012.png-60f75950441d6ae69c2b2ac8d04226b9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0013.png b/assets/kenney/tiny_town/Tiles/tile_0013.png new file mode 100644 index 0000000..47bec78 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0013.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0013.png.import b/assets/kenney/tiny_town/Tiles/tile_0013.png.import new file mode 100644 index 0000000..6b197d6 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0013.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bq40xdxv1fvjt" +path="res://.godot/imported/tile_0013.png-45078a9fdd348d7f766b7d378a9cfa3d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0013.png" +dest_files=["res://.godot/imported/tile_0013.png-45078a9fdd348d7f766b7d378a9cfa3d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0014.png b/assets/kenney/tiny_town/Tiles/tile_0014.png new file mode 100644 index 0000000..20737fc Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0014.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0014.png.import b/assets/kenney/tiny_town/Tiles/tile_0014.png.import new file mode 100644 index 0000000..7957ae1 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0014.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bnbfxkoqxbjhv" +path="res://.godot/imported/tile_0014.png-62923ac049d148e89570c256cf8705ff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0014.png" +dest_files=["res://.godot/imported/tile_0014.png-62923ac049d148e89570c256cf8705ff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0015.png b/assets/kenney/tiny_town/Tiles/tile_0015.png new file mode 100644 index 0000000..d6302ed Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0015.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0015.png.import b/assets/kenney/tiny_town/Tiles/tile_0015.png.import new file mode 100644 index 0000000..d15ebce --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0015.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://w47pch4n5gyg" +path="res://.godot/imported/tile_0015.png-c6032045ab1174f20f9c7f61a32e3923.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0015.png" +dest_files=["res://.godot/imported/tile_0015.png-c6032045ab1174f20f9c7f61a32e3923.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0016.png b/assets/kenney/tiny_town/Tiles/tile_0016.png new file mode 100644 index 0000000..bafb634 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0016.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0016.png.import b/assets/kenney/tiny_town/Tiles/tile_0016.png.import new file mode 100644 index 0000000..77163ee --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0016.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d36ith0n3h5em" +path="res://.godot/imported/tile_0016.png-be24b4ff09d39bd4367482c14d0f0a55.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0016.png" +dest_files=["res://.godot/imported/tile_0016.png-be24b4ff09d39bd4367482c14d0f0a55.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0017.png b/assets/kenney/tiny_town/Tiles/tile_0017.png new file mode 100644 index 0000000..4889f5b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0017.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0017.png.import b/assets/kenney/tiny_town/Tiles/tile_0017.png.import new file mode 100644 index 0000000..87169b0 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0017.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b5tmy17yb5yk1" +path="res://.godot/imported/tile_0017.png-a9594180355178c65b42d912e6655dc9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0017.png" +dest_files=["res://.godot/imported/tile_0017.png-a9594180355178c65b42d912e6655dc9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0018.png b/assets/kenney/tiny_town/Tiles/tile_0018.png new file mode 100644 index 0000000..f9f97f8 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0018.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0018.png.import b/assets/kenney/tiny_town/Tiles/tile_0018.png.import new file mode 100644 index 0000000..f40506c --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0018.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3u5t3p1diqn5" +path="res://.godot/imported/tile_0018.png-2828bff6022d09644b3cae63b8e058eb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0018.png" +dest_files=["res://.godot/imported/tile_0018.png-2828bff6022d09644b3cae63b8e058eb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0019.png b/assets/kenney/tiny_town/Tiles/tile_0019.png new file mode 100644 index 0000000..edcb1c1 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0019.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0019.png.import b/assets/kenney/tiny_town/Tiles/tile_0019.png.import new file mode 100644 index 0000000..9bb81e4 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0019.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://csykatwleocby" +path="res://.godot/imported/tile_0019.png-00879792567353e6b464183542399660.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0019.png" +dest_files=["res://.godot/imported/tile_0019.png-00879792567353e6b464183542399660.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0020.png b/assets/kenney/tiny_town/Tiles/tile_0020.png new file mode 100644 index 0000000..33d8690 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0020.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0020.png.import b/assets/kenney/tiny_town/Tiles/tile_0020.png.import new file mode 100644 index 0000000..94483bc --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0020.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://weadevofpxo8" +path="res://.godot/imported/tile_0020.png-1e5cf71322be1e0339f1f708ed65c4b3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0020.png" +dest_files=["res://.godot/imported/tile_0020.png-1e5cf71322be1e0339f1f708ed65c4b3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0021.png b/assets/kenney/tiny_town/Tiles/tile_0021.png new file mode 100644 index 0000000..f39998d Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0021.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0021.png.import b/assets/kenney/tiny_town/Tiles/tile_0021.png.import new file mode 100644 index 0000000..58acd3e --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0021.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://devtq3n21kh6p" +path="res://.godot/imported/tile_0021.png-86fbab4d903ab43330eb27dc9f35da70.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0021.png" +dest_files=["res://.godot/imported/tile_0021.png-86fbab4d903ab43330eb27dc9f35da70.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0022.png b/assets/kenney/tiny_town/Tiles/tile_0022.png new file mode 100644 index 0000000..e7e722a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0022.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0022.png.import b/assets/kenney/tiny_town/Tiles/tile_0022.png.import new file mode 100644 index 0000000..288f25a --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0022.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl2mn8078k8po" +path="res://.godot/imported/tile_0022.png-ebeb97639153f95acdafa6b452ae240a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0022.png" +dest_files=["res://.godot/imported/tile_0022.png-ebeb97639153f95acdafa6b452ae240a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0023.png b/assets/kenney/tiny_town/Tiles/tile_0023.png new file mode 100644 index 0000000..14ba8d1 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0023.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0023.png.import b/assets/kenney/tiny_town/Tiles/tile_0023.png.import new file mode 100644 index 0000000..71e8a76 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0023.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qwx62qqllb6p" +path="res://.godot/imported/tile_0023.png-b90481acb5feaade2a571c73ac4aee72.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0023.png" +dest_files=["res://.godot/imported/tile_0023.png-b90481acb5feaade2a571c73ac4aee72.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0024.png b/assets/kenney/tiny_town/Tiles/tile_0024.png new file mode 100644 index 0000000..7fec97c Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0024.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0024.png.import b/assets/kenney/tiny_town/Tiles/tile_0024.png.import new file mode 100644 index 0000000..3fd4461 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0024.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckx2fvkwtbmq8" +path="res://.godot/imported/tile_0024.png-05521455d887d4d6c2095c92974f36c8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0024.png" +dest_files=["res://.godot/imported/tile_0024.png-05521455d887d4d6c2095c92974f36c8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0025.png b/assets/kenney/tiny_town/Tiles/tile_0025.png new file mode 100644 index 0000000..6e59a44 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0025.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0025.png.import b/assets/kenney/tiny_town/Tiles/tile_0025.png.import new file mode 100644 index 0000000..252149e --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0025.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c46r48mfbu1yw" +path="res://.godot/imported/tile_0025.png-e6cfc19bea44563968505dd8e80e9aa0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0025.png" +dest_files=["res://.godot/imported/tile_0025.png-e6cfc19bea44563968505dd8e80e9aa0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0026.png b/assets/kenney/tiny_town/Tiles/tile_0026.png new file mode 100644 index 0000000..36b21a0 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0026.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0026.png.import b/assets/kenney/tiny_town/Tiles/tile_0026.png.import new file mode 100644 index 0000000..2e13f7f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0026.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://f78jmsprksst" +path="res://.godot/imported/tile_0026.png-fd1ade69482d6d8261c088db8cc47565.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0026.png" +dest_files=["res://.godot/imported/tile_0026.png-fd1ade69482d6d8261c088db8cc47565.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0027.png b/assets/kenney/tiny_town/Tiles/tile_0027.png new file mode 100644 index 0000000..53ce600 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0027.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0027.png.import b/assets/kenney/tiny_town/Tiles/tile_0027.png.import new file mode 100644 index 0000000..7be01a6 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0027.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dn3a8flwnwpj3" +path="res://.godot/imported/tile_0027.png-7995782b0a7246a9aa9719dea859f6b6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0027.png" +dest_files=["res://.godot/imported/tile_0027.png-7995782b0a7246a9aa9719dea859f6b6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0028.png b/assets/kenney/tiny_town/Tiles/tile_0028.png new file mode 100644 index 0000000..ccce87c Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0028.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0028.png.import b/assets/kenney/tiny_town/Tiles/tile_0028.png.import new file mode 100644 index 0000000..111a1f2 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0028.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cco14xug2gdt5" +path="res://.godot/imported/tile_0028.png-a7445d494415887a23ba6b6ad193354b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0028.png" +dest_files=["res://.godot/imported/tile_0028.png-a7445d494415887a23ba6b6ad193354b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0029.png b/assets/kenney/tiny_town/Tiles/tile_0029.png new file mode 100644 index 0000000..44735ba Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0029.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0029.png.import b/assets/kenney/tiny_town/Tiles/tile_0029.png.import new file mode 100644 index 0000000..f73bb81 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0029.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dt3wp3sehjujd" +path="res://.godot/imported/tile_0029.png-420bf1175478a56e5677f225de232c57.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0029.png" +dest_files=["res://.godot/imported/tile_0029.png-420bf1175478a56e5677f225de232c57.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0030.png b/assets/kenney/tiny_town/Tiles/tile_0030.png new file mode 100644 index 0000000..03956c6 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0030.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0030.png.import b/assets/kenney/tiny_town/Tiles/tile_0030.png.import new file mode 100644 index 0000000..3052b89 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0030.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wg2erjgkoa1w" +path="res://.godot/imported/tile_0030.png-9b14e19436a7e1936b868697f0b9d741.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0030.png" +dest_files=["res://.godot/imported/tile_0030.png-9b14e19436a7e1936b868697f0b9d741.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0031.png b/assets/kenney/tiny_town/Tiles/tile_0031.png new file mode 100644 index 0000000..f857d72 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0031.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0031.png.import b/assets/kenney/tiny_town/Tiles/tile_0031.png.import new file mode 100644 index 0000000..97a839c --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0031.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjlhmqow1snux" +path="res://.godot/imported/tile_0031.png-91d8a506434d91e6aaad4d4eeab773f7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0031.png" +dest_files=["res://.godot/imported/tile_0031.png-91d8a506434d91e6aaad4d4eeab773f7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0032.png b/assets/kenney/tiny_town/Tiles/tile_0032.png new file mode 100644 index 0000000..a3b4563 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0032.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0032.png.import b/assets/kenney/tiny_town/Tiles/tile_0032.png.import new file mode 100644 index 0000000..1480f35 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0032.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bab5nsd3wusmc" +path="res://.godot/imported/tile_0032.png-44c0d5c00ec51e9529d472ab367c060a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0032.png" +dest_files=["res://.godot/imported/tile_0032.png-44c0d5c00ec51e9529d472ab367c060a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0033.png b/assets/kenney/tiny_town/Tiles/tile_0033.png new file mode 100644 index 0000000..1942373 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0033.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0033.png.import b/assets/kenney/tiny_town/Tiles/tile_0033.png.import new file mode 100644 index 0000000..c3befc9 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0033.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvmfrcn03teve" +path="res://.godot/imported/tile_0033.png-98f3849997207c27e0c023ba695f4715.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0033.png" +dest_files=["res://.godot/imported/tile_0033.png-98f3849997207c27e0c023ba695f4715.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0034.png b/assets/kenney/tiny_town/Tiles/tile_0034.png new file mode 100644 index 0000000..36aba0c Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0034.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0034.png.import b/assets/kenney/tiny_town/Tiles/tile_0034.png.import new file mode 100644 index 0000000..f5e5db6 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0034.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtxmvgqwmmisg" +path="res://.godot/imported/tile_0034.png-a1ce925427ba3600455772793c6a5695.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0034.png" +dest_files=["res://.godot/imported/tile_0034.png-a1ce925427ba3600455772793c6a5695.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0035.png b/assets/kenney/tiny_town/Tiles/tile_0035.png new file mode 100644 index 0000000..391b766 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0035.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0035.png.import b/assets/kenney/tiny_town/Tiles/tile_0035.png.import new file mode 100644 index 0000000..ed02956 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0035.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sd5q8y7gi3lm" +path="res://.godot/imported/tile_0035.png-f15564037705d066012e263b3c8ac952.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0035.png" +dest_files=["res://.godot/imported/tile_0035.png-f15564037705d066012e263b3c8ac952.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0036.png b/assets/kenney/tiny_town/Tiles/tile_0036.png new file mode 100644 index 0000000..483b603 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0036.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0036.png.import b/assets/kenney/tiny_town/Tiles/tile_0036.png.import new file mode 100644 index 0000000..b5d346d --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0036.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btijqu777slxl" +path="res://.godot/imported/tile_0036.png-4ba9f23d8cc92381bd268e7a525fc7b0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0036.png" +dest_files=["res://.godot/imported/tile_0036.png-4ba9f23d8cc92381bd268e7a525fc7b0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0037.png b/assets/kenney/tiny_town/Tiles/tile_0037.png new file mode 100644 index 0000000..ffa7578 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0037.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0037.png.import b/assets/kenney/tiny_town/Tiles/tile_0037.png.import new file mode 100644 index 0000000..bc3b256 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0037.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4kfyiusinfy3" +path="res://.godot/imported/tile_0037.png-e2875e61c273d4d3f88f4715ddb23077.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0037.png" +dest_files=["res://.godot/imported/tile_0037.png-e2875e61c273d4d3f88f4715ddb23077.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0038.png b/assets/kenney/tiny_town/Tiles/tile_0038.png new file mode 100644 index 0000000..8b786b6 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0038.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0038.png.import b/assets/kenney/tiny_town/Tiles/tile_0038.png.import new file mode 100644 index 0000000..d7758db --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0038.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4bat5h6x8bj8" +path="res://.godot/imported/tile_0038.png-e1580730b841b1c58d788382c31c7719.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0038.png" +dest_files=["res://.godot/imported/tile_0038.png-e1580730b841b1c58d788382c31c7719.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0039.png b/assets/kenney/tiny_town/Tiles/tile_0039.png new file mode 100644 index 0000000..ba565da Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0039.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0039.png.import b/assets/kenney/tiny_town/Tiles/tile_0039.png.import new file mode 100644 index 0000000..ae95bb0 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0039.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://h2kg2nie84jk" +path="res://.godot/imported/tile_0039.png-093c4adaaa0c07cac476bb82b59a9c1d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0039.png" +dest_files=["res://.godot/imported/tile_0039.png-093c4adaaa0c07cac476bb82b59a9c1d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0040.png b/assets/kenney/tiny_town/Tiles/tile_0040.png new file mode 100644 index 0000000..92284af Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0040.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0040.png.import b/assets/kenney/tiny_town/Tiles/tile_0040.png.import new file mode 100644 index 0000000..e344b4d --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0040.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rrip270t8xrl" +path="res://.godot/imported/tile_0040.png-9cc99a789513e68f4fd18201cb8d5e54.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0040.png" +dest_files=["res://.godot/imported/tile_0040.png-9cc99a789513e68f4fd18201cb8d5e54.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0041.png b/assets/kenney/tiny_town/Tiles/tile_0041.png new file mode 100644 index 0000000..f57be42 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0041.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0041.png.import b/assets/kenney/tiny_town/Tiles/tile_0041.png.import new file mode 100644 index 0000000..2f5b274 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0041.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3cm5hdpbaqkx" +path="res://.godot/imported/tile_0041.png-b0f802e062d28a95805b7a0b627cd213.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0041.png" +dest_files=["res://.godot/imported/tile_0041.png-b0f802e062d28a95805b7a0b627cd213.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0042.png b/assets/kenney/tiny_town/Tiles/tile_0042.png new file mode 100644 index 0000000..4e06403 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0042.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0042.png.import b/assets/kenney/tiny_town/Tiles/tile_0042.png.import new file mode 100644 index 0000000..8049c28 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0042.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://gm81sg5cwj" +path="res://.godot/imported/tile_0042.png-38165110dca0feaf300df8d0910edd72.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0042.png" +dest_files=["res://.godot/imported/tile_0042.png-38165110dca0feaf300df8d0910edd72.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0043.png b/assets/kenney/tiny_town/Tiles/tile_0043.png new file mode 100644 index 0000000..cf9b562 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0043.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0043.png.import b/assets/kenney/tiny_town/Tiles/tile_0043.png.import new file mode 100644 index 0000000..c22d444 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0043.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl1pq1puean8u" +path="res://.godot/imported/tile_0043.png-2840790e67791a1e1296c09ac6f3b71d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0043.png" +dest_files=["res://.godot/imported/tile_0043.png-2840790e67791a1e1296c09ac6f3b71d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0044.png b/assets/kenney/tiny_town/Tiles/tile_0044.png new file mode 100644 index 0000000..4a0799d Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0044.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0044.png.import b/assets/kenney/tiny_town/Tiles/tile_0044.png.import new file mode 100644 index 0000000..af31e9e --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0044.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sxdkcdbfpg43" +path="res://.godot/imported/tile_0044.png-41f01663bb482fb34a6fbee28aec49e2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0044.png" +dest_files=["res://.godot/imported/tile_0044.png-41f01663bb482fb34a6fbee28aec49e2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0045.png b/assets/kenney/tiny_town/Tiles/tile_0045.png new file mode 100644 index 0000000..11e1591 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0045.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0045.png.import b/assets/kenney/tiny_town/Tiles/tile_0045.png.import new file mode 100644 index 0000000..7eb7e9a --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0045.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3s3x8oikdhej" +path="res://.godot/imported/tile_0045.png-584cff6189fa47a7945fff8438ee4bc5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0045.png" +dest_files=["res://.godot/imported/tile_0045.png-584cff6189fa47a7945fff8438ee4bc5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0046.png b/assets/kenney/tiny_town/Tiles/tile_0046.png new file mode 100644 index 0000000..1ff3718 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0046.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0046.png.import b/assets/kenney/tiny_town/Tiles/tile_0046.png.import new file mode 100644 index 0000000..a02d610 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0046.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl5m051mu12sw" +path="res://.godot/imported/tile_0046.png-8270123adb005b41f08586b366b6cbc9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0046.png" +dest_files=["res://.godot/imported/tile_0046.png-8270123adb005b41f08586b366b6cbc9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0047.png b/assets/kenney/tiny_town/Tiles/tile_0047.png new file mode 100644 index 0000000..154f3fa Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0047.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0047.png.import b/assets/kenney/tiny_town/Tiles/tile_0047.png.import new file mode 100644 index 0000000..e56e0ca --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0047.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c52esh0q70ghh" +path="res://.godot/imported/tile_0047.png-c52eb9b21012adff0682b390eed42a1c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0047.png" +dest_files=["res://.godot/imported/tile_0047.png-c52eb9b21012adff0682b390eed42a1c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0048.png b/assets/kenney/tiny_town/Tiles/tile_0048.png new file mode 100644 index 0000000..3d50013 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0048.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0048.png.import b/assets/kenney/tiny_town/Tiles/tile_0048.png.import new file mode 100644 index 0000000..72278ae --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0048.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtxl6mn665fsh" +path="res://.godot/imported/tile_0048.png-2b6be6461e3b47b4dec173117ac55e6b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0048.png" +dest_files=["res://.godot/imported/tile_0048.png-2b6be6461e3b47b4dec173117ac55e6b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0049.png b/assets/kenney/tiny_town/Tiles/tile_0049.png new file mode 100644 index 0000000..fbe1d9f Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0049.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0049.png.import b/assets/kenney/tiny_town/Tiles/tile_0049.png.import new file mode 100644 index 0000000..3e805f5 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0049.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfnlvqplsraw7" +path="res://.godot/imported/tile_0049.png-9e6471a5c732832368495ff600d96691.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0049.png" +dest_files=["res://.godot/imported/tile_0049.png-9e6471a5c732832368495ff600d96691.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0050.png b/assets/kenney/tiny_town/Tiles/tile_0050.png new file mode 100644 index 0000000..c17255c Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0050.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0050.png.import b/assets/kenney/tiny_town/Tiles/tile_0050.png.import new file mode 100644 index 0000000..08a03f3 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0050.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xkk5jfd308wn" +path="res://.godot/imported/tile_0050.png-0a7946b886c23b3b41f972201db068ef.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0050.png" +dest_files=["res://.godot/imported/tile_0050.png-0a7946b886c23b3b41f972201db068ef.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0051.png b/assets/kenney/tiny_town/Tiles/tile_0051.png new file mode 100644 index 0000000..d9f64fd Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0051.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0051.png.import b/assets/kenney/tiny_town/Tiles/tile_0051.png.import new file mode 100644 index 0000000..6dd5946 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0051.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds45jb16wqesg" +path="res://.godot/imported/tile_0051.png-889d9b6cc13964ea14ef4228a646087f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0051.png" +dest_files=["res://.godot/imported/tile_0051.png-889d9b6cc13964ea14ef4228a646087f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0052.png b/assets/kenney/tiny_town/Tiles/tile_0052.png new file mode 100644 index 0000000..f94e72d Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0052.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0052.png.import b/assets/kenney/tiny_town/Tiles/tile_0052.png.import new file mode 100644 index 0000000..365c8ef --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0052.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cbohrrchj0rhg" +path="res://.godot/imported/tile_0052.png-d362f1ee4957fda18e06e6df85a44425.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0052.png" +dest_files=["res://.godot/imported/tile_0052.png-d362f1ee4957fda18e06e6df85a44425.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0053.png b/assets/kenney/tiny_town/Tiles/tile_0053.png new file mode 100644 index 0000000..ce34887 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0053.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0053.png.import b/assets/kenney/tiny_town/Tiles/tile_0053.png.import new file mode 100644 index 0000000..5aa7a01 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0053.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bh2ahps2f2vbr" +path="res://.godot/imported/tile_0053.png-f489ee9a6ec85ea9f19a7e4e0ab69ff5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0053.png" +dest_files=["res://.godot/imported/tile_0053.png-f489ee9a6ec85ea9f19a7e4e0ab69ff5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0054.png b/assets/kenney/tiny_town/Tiles/tile_0054.png new file mode 100644 index 0000000..d04ac27 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0054.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0054.png.import b/assets/kenney/tiny_town/Tiles/tile_0054.png.import new file mode 100644 index 0000000..5ddc03a --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0054.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhw3glmk26oen" +path="res://.godot/imported/tile_0054.png-32942d552bb1723467bde3bedd008c36.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0054.png" +dest_files=["res://.godot/imported/tile_0054.png-32942d552bb1723467bde3bedd008c36.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0055.png b/assets/kenney/tiny_town/Tiles/tile_0055.png new file mode 100644 index 0000000..7fffe67 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0055.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0055.png.import b/assets/kenney/tiny_town/Tiles/tile_0055.png.import new file mode 100644 index 0000000..266cf63 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0055.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://u24kts5mm3de" +path="res://.godot/imported/tile_0055.png-12e29c905f9a7162bf5f5ed93a392785.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0055.png" +dest_files=["res://.godot/imported/tile_0055.png-12e29c905f9a7162bf5f5ed93a392785.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0056.png b/assets/kenney/tiny_town/Tiles/tile_0056.png new file mode 100644 index 0000000..6873383 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0056.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0056.png.import b/assets/kenney/tiny_town/Tiles/tile_0056.png.import new file mode 100644 index 0000000..08a2c60 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0056.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bui7lg7cb25ud" +path="res://.godot/imported/tile_0056.png-f3752840c8c1006921cc0265bc8bdf47.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0056.png" +dest_files=["res://.godot/imported/tile_0056.png-f3752840c8c1006921cc0265bc8bdf47.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0057.png b/assets/kenney/tiny_town/Tiles/tile_0057.png new file mode 100644 index 0000000..2b78dd6 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0057.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0057.png.import b/assets/kenney/tiny_town/Tiles/tile_0057.png.import new file mode 100644 index 0000000..127689a --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0057.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctnej54of5001" +path="res://.godot/imported/tile_0057.png-8332b1452ed68674a961fe26b13404ce.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0057.png" +dest_files=["res://.godot/imported/tile_0057.png-8332b1452ed68674a961fe26b13404ce.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0058.png b/assets/kenney/tiny_town/Tiles/tile_0058.png new file mode 100644 index 0000000..a27b994 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0058.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0058.png.import b/assets/kenney/tiny_town/Tiles/tile_0058.png.import new file mode 100644 index 0000000..ada59e2 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0058.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://q0bmvn86srxe" +path="res://.godot/imported/tile_0058.png-8126744b58c3d3ab002b017aeda42d57.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0058.png" +dest_files=["res://.godot/imported/tile_0058.png-8126744b58c3d3ab002b017aeda42d57.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0059.png b/assets/kenney/tiny_town/Tiles/tile_0059.png new file mode 100644 index 0000000..b86df4b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0059.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0059.png.import b/assets/kenney/tiny_town/Tiles/tile_0059.png.import new file mode 100644 index 0000000..98f88f3 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0059.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://s3naps00d8xq" +path="res://.godot/imported/tile_0059.png-ad444d921d9f9623b2ef58c939f33bff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0059.png" +dest_files=["res://.godot/imported/tile_0059.png-ad444d921d9f9623b2ef58c939f33bff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0060.png b/assets/kenney/tiny_town/Tiles/tile_0060.png new file mode 100644 index 0000000..f8e8441 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0060.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0060.png.import b/assets/kenney/tiny_town/Tiles/tile_0060.png.import new file mode 100644 index 0000000..404afbb --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0060.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dcr6x30fq6gsc" +path="res://.godot/imported/tile_0060.png-98072d75aa9f5bc65ebc69869ebe4486.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0060.png" +dest_files=["res://.godot/imported/tile_0060.png-98072d75aa9f5bc65ebc69869ebe4486.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0061.png b/assets/kenney/tiny_town/Tiles/tile_0061.png new file mode 100644 index 0000000..4b014cf Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0061.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0061.png.import b/assets/kenney/tiny_town/Tiles/tile_0061.png.import new file mode 100644 index 0000000..9aae5d2 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0061.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://673bihe5q7wk" +path="res://.godot/imported/tile_0061.png-18af1e1ba10b53b345f187e5a5243517.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0061.png" +dest_files=["res://.godot/imported/tile_0061.png-18af1e1ba10b53b345f187e5a5243517.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0062.png b/assets/kenney/tiny_town/Tiles/tile_0062.png new file mode 100644 index 0000000..1dc3f1a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0062.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0062.png.import b/assets/kenney/tiny_town/Tiles/tile_0062.png.import new file mode 100644 index 0000000..ed55d73 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0062.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nj66e1duh06m" +path="res://.godot/imported/tile_0062.png-461d06ced9278d6674b4e27b34bb5726.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0062.png" +dest_files=["res://.godot/imported/tile_0062.png-461d06ced9278d6674b4e27b34bb5726.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0063.png b/assets/kenney/tiny_town/Tiles/tile_0063.png new file mode 100644 index 0000000..2964d62 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0063.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0063.png.import b/assets/kenney/tiny_town/Tiles/tile_0063.png.import new file mode 100644 index 0000000..df061b9 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0063.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://by4aojpir6qsb" +path="res://.godot/imported/tile_0063.png-097447f8dfcfee1efcbd76d8a48f5bfa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0063.png" +dest_files=["res://.godot/imported/tile_0063.png-097447f8dfcfee1efcbd76d8a48f5bfa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0064.png b/assets/kenney/tiny_town/Tiles/tile_0064.png new file mode 100644 index 0000000..ec8d299 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0064.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0064.png.import b/assets/kenney/tiny_town/Tiles/tile_0064.png.import new file mode 100644 index 0000000..d837a13 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0064.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bht7x7s0i7trq" +path="res://.godot/imported/tile_0064.png-b25f97ef6bb4b412a1bdbe2ef09d080f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0064.png" +dest_files=["res://.godot/imported/tile_0064.png-b25f97ef6bb4b412a1bdbe2ef09d080f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0065.png b/assets/kenney/tiny_town/Tiles/tile_0065.png new file mode 100644 index 0000000..ed6a0fa Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0065.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0065.png.import b/assets/kenney/tiny_town/Tiles/tile_0065.png.import new file mode 100644 index 0000000..62c4126 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0065.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c2ljfj2b423ao" +path="res://.godot/imported/tile_0065.png-a84ced0e76ee9016f0c2e103488bd342.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0065.png" +dest_files=["res://.godot/imported/tile_0065.png-a84ced0e76ee9016f0c2e103488bd342.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0066.png b/assets/kenney/tiny_town/Tiles/tile_0066.png new file mode 100644 index 0000000..ee9ef04 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0066.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0066.png.import b/assets/kenney/tiny_town/Tiles/tile_0066.png.import new file mode 100644 index 0000000..f81501e --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0066.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1hqo6vir0y8t" +path="res://.godot/imported/tile_0066.png-6114e5fe6406f68c85f65a5feba3adc4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0066.png" +dest_files=["res://.godot/imported/tile_0066.png-6114e5fe6406f68c85f65a5feba3adc4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0067.png b/assets/kenney/tiny_town/Tiles/tile_0067.png new file mode 100644 index 0000000..d1ac6e7 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0067.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0067.png.import b/assets/kenney/tiny_town/Tiles/tile_0067.png.import new file mode 100644 index 0000000..449f124 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0067.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://110u273jn0ts" +path="res://.godot/imported/tile_0067.png-50e0133d7dcdd1615d4fb9ee6aae65c7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0067.png" +dest_files=["res://.godot/imported/tile_0067.png-50e0133d7dcdd1615d4fb9ee6aae65c7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0068.png b/assets/kenney/tiny_town/Tiles/tile_0068.png new file mode 100644 index 0000000..7272687 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0068.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0068.png.import b/assets/kenney/tiny_town/Tiles/tile_0068.png.import new file mode 100644 index 0000000..07c4f96 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0068.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bw6xueixcq58a" +path="res://.godot/imported/tile_0068.png-b44adc2664501fc30b3f50898e3a335e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0068.png" +dest_files=["res://.godot/imported/tile_0068.png-b44adc2664501fc30b3f50898e3a335e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0069.png b/assets/kenney/tiny_town/Tiles/tile_0069.png new file mode 100644 index 0000000..7d362fa Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0069.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0069.png.import b/assets/kenney/tiny_town/Tiles/tile_0069.png.import new file mode 100644 index 0000000..c18ff9a --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0069.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7emlg40nv6lc" +path="res://.godot/imported/tile_0069.png-481183288a27bb7c6bfe62b84e811311.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0069.png" +dest_files=["res://.godot/imported/tile_0069.png-481183288a27bb7c6bfe62b84e811311.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0070.png b/assets/kenney/tiny_town/Tiles/tile_0070.png new file mode 100644 index 0000000..e9aafdf Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0070.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0070.png.import b/assets/kenney/tiny_town/Tiles/tile_0070.png.import new file mode 100644 index 0000000..c8f8a2f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0070.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1412ufbkfpxa" +path="res://.godot/imported/tile_0070.png-bd91cf3ab941ab7164673adaa399bb78.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0070.png" +dest_files=["res://.godot/imported/tile_0070.png-bd91cf3ab941ab7164673adaa399bb78.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0071.png b/assets/kenney/tiny_town/Tiles/tile_0071.png new file mode 100644 index 0000000..fe5156a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0071.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0071.png.import b/assets/kenney/tiny_town/Tiles/tile_0071.png.import new file mode 100644 index 0000000..3439e8d --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0071.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4j46hcs25avw" +path="res://.godot/imported/tile_0071.png-c079a390a4da12beb1453cabec592456.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0071.png" +dest_files=["res://.godot/imported/tile_0071.png-c079a390a4da12beb1453cabec592456.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0072.png b/assets/kenney/tiny_town/Tiles/tile_0072.png new file mode 100644 index 0000000..b3cc264 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0072.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0072.png.import b/assets/kenney/tiny_town/Tiles/tile_0072.png.import new file mode 100644 index 0000000..26df390 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0072.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpfptj0wek8yd" +path="res://.godot/imported/tile_0072.png-3842de4dc535f510f83025ce52d99ddf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0072.png" +dest_files=["res://.godot/imported/tile_0072.png-3842de4dc535f510f83025ce52d99ddf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0073.png b/assets/kenney/tiny_town/Tiles/tile_0073.png new file mode 100644 index 0000000..d370436 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0073.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0073.png.import b/assets/kenney/tiny_town/Tiles/tile_0073.png.import new file mode 100644 index 0000000..58c4ee9 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0073.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://detxba1o0wuma" +path="res://.godot/imported/tile_0073.png-8d840a15f35dd96b2b0d3a011b3a5f3f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0073.png" +dest_files=["res://.godot/imported/tile_0073.png-8d840a15f35dd96b2b0d3a011b3a5f3f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0074.png b/assets/kenney/tiny_town/Tiles/tile_0074.png new file mode 100644 index 0000000..df9febc Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0074.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0074.png.import b/assets/kenney/tiny_town/Tiles/tile_0074.png.import new file mode 100644 index 0000000..df116e4 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0074.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfdhr58lyn2wu" +path="res://.godot/imported/tile_0074.png-3db8f355e5640890d76da6d02a15be6a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0074.png" +dest_files=["res://.godot/imported/tile_0074.png-3db8f355e5640890d76da6d02a15be6a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0075.png b/assets/kenney/tiny_town/Tiles/tile_0075.png new file mode 100644 index 0000000..ff1c205 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0075.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0075.png.import b/assets/kenney/tiny_town/Tiles/tile_0075.png.import new file mode 100644 index 0000000..bf2e608 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0075.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccrd0uwva6xxh" +path="res://.godot/imported/tile_0075.png-d3770145fc0e7007c6257c3b83c3d6cb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0075.png" +dest_files=["res://.godot/imported/tile_0075.png-d3770145fc0e7007c6257c3b83c3d6cb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0076.png b/assets/kenney/tiny_town/Tiles/tile_0076.png new file mode 100644 index 0000000..1262306 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0076.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0076.png.import b/assets/kenney/tiny_town/Tiles/tile_0076.png.import new file mode 100644 index 0000000..691082e --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0076.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://u3v0vbcvuycx" +path="res://.godot/imported/tile_0076.png-1b825ee2a94bd2861ccaf742c3efa76f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0076.png" +dest_files=["res://.godot/imported/tile_0076.png-1b825ee2a94bd2861ccaf742c3efa76f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0077.png b/assets/kenney/tiny_town/Tiles/tile_0077.png new file mode 100644 index 0000000..4a35d9b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0077.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0077.png.import b/assets/kenney/tiny_town/Tiles/tile_0077.png.import new file mode 100644 index 0000000..40495fe --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0077.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bovqs8ysnfmkk" +path="res://.godot/imported/tile_0077.png-c9a132e2ade3b428e51164c01829dbea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0077.png" +dest_files=["res://.godot/imported/tile_0077.png-c9a132e2ade3b428e51164c01829dbea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0078.png b/assets/kenney/tiny_town/Tiles/tile_0078.png new file mode 100644 index 0000000..2fa0920 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0078.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0078.png.import b/assets/kenney/tiny_town/Tiles/tile_0078.png.import new file mode 100644 index 0000000..f3d72dd --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0078.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cm0qawdh5m17i" +path="res://.godot/imported/tile_0078.png-e0f527316db5d937a5accc8d7d910cab.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0078.png" +dest_files=["res://.godot/imported/tile_0078.png-e0f527316db5d937a5accc8d7d910cab.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0079.png b/assets/kenney/tiny_town/Tiles/tile_0079.png new file mode 100644 index 0000000..dbbb00d Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0079.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0079.png.import b/assets/kenney/tiny_town/Tiles/tile_0079.png.import new file mode 100644 index 0000000..f49628b --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0079.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jjwohccr72f8" +path="res://.godot/imported/tile_0079.png-6ba62c4a158add9befc61d3b311080d0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0079.png" +dest_files=["res://.godot/imported/tile_0079.png-6ba62c4a158add9befc61d3b311080d0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0080.png b/assets/kenney/tiny_town/Tiles/tile_0080.png new file mode 100644 index 0000000..30f1b3a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0080.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0080.png.import b/assets/kenney/tiny_town/Tiles/tile_0080.png.import new file mode 100644 index 0000000..bb5f6f6 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0080.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dky7344q5wjrp" +path="res://.godot/imported/tile_0080.png-534280846d8f44aa0c5a9805eb38d577.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0080.png" +dest_files=["res://.godot/imported/tile_0080.png-534280846d8f44aa0c5a9805eb38d577.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0081.png b/assets/kenney/tiny_town/Tiles/tile_0081.png new file mode 100644 index 0000000..60fd8ab Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0081.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0081.png.import b/assets/kenney/tiny_town/Tiles/tile_0081.png.import new file mode 100644 index 0000000..2ed5fd1 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0081.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://i26af54vvct2" +path="res://.godot/imported/tile_0081.png-524189fe46688a1dd767beae2d80f1c4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0081.png" +dest_files=["res://.godot/imported/tile_0081.png-524189fe46688a1dd767beae2d80f1c4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0082.png b/assets/kenney/tiny_town/Tiles/tile_0082.png new file mode 100644 index 0000000..cf199cf Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0082.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0082.png.import b/assets/kenney/tiny_town/Tiles/tile_0082.png.import new file mode 100644 index 0000000..00250e6 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0082.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d7e04acp6sh1" +path="res://.godot/imported/tile_0082.png-2daecb9d138bb81ca021798799a4f894.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0082.png" +dest_files=["res://.godot/imported/tile_0082.png-2daecb9d138bb81ca021798799a4f894.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0083.png b/assets/kenney/tiny_town/Tiles/tile_0083.png new file mode 100644 index 0000000..23c6208 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0083.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0083.png.import b/assets/kenney/tiny_town/Tiles/tile_0083.png.import new file mode 100644 index 0000000..ecbcdfd --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0083.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvoq3o67ey8g0" +path="res://.godot/imported/tile_0083.png-f75792aeb7ce0b4e6704059341a54eb7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0083.png" +dest_files=["res://.godot/imported/tile_0083.png-f75792aeb7ce0b4e6704059341a54eb7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0084.png b/assets/kenney/tiny_town/Tiles/tile_0084.png new file mode 100644 index 0000000..a6e113e Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0084.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0084.png.import b/assets/kenney/tiny_town/Tiles/tile_0084.png.import new file mode 100644 index 0000000..9cfc930 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0084.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://emldh7fmddmr" +path="res://.godot/imported/tile_0084.png-3d25c03ce4ade22c2ec53cdd878b1608.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0084.png" +dest_files=["res://.godot/imported/tile_0084.png-3d25c03ce4ade22c2ec53cdd878b1608.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0085.png b/assets/kenney/tiny_town/Tiles/tile_0085.png new file mode 100644 index 0000000..110d522 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0085.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0085.png.import b/assets/kenney/tiny_town/Tiles/tile_0085.png.import new file mode 100644 index 0000000..75d1d07 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0085.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://chc0acxw4kjey" +path="res://.godot/imported/tile_0085.png-5dd615c5b406a9857747cdbabeb6557a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0085.png" +dest_files=["res://.godot/imported/tile_0085.png-5dd615c5b406a9857747cdbabeb6557a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0086.png b/assets/kenney/tiny_town/Tiles/tile_0086.png new file mode 100644 index 0000000..65e9ece Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0086.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0086.png.import b/assets/kenney/tiny_town/Tiles/tile_0086.png.import new file mode 100644 index 0000000..f3f8394 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0086.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rux6j22cd0y1" +path="res://.godot/imported/tile_0086.png-75b7cdd7234def3f363127bb6a75b633.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0086.png" +dest_files=["res://.godot/imported/tile_0086.png-75b7cdd7234def3f363127bb6a75b633.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0087.png b/assets/kenney/tiny_town/Tiles/tile_0087.png new file mode 100644 index 0000000..5944e65 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0087.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0087.png.import b/assets/kenney/tiny_town/Tiles/tile_0087.png.import new file mode 100644 index 0000000..13d6483 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0087.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b55lvt76tkue6" +path="res://.godot/imported/tile_0087.png-4c3815b8636c3c4df473e27b8c314c4a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0087.png" +dest_files=["res://.godot/imported/tile_0087.png-4c3815b8636c3c4df473e27b8c314c4a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0088.png b/assets/kenney/tiny_town/Tiles/tile_0088.png new file mode 100644 index 0000000..fc2d671 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0088.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0088.png.import b/assets/kenney/tiny_town/Tiles/tile_0088.png.import new file mode 100644 index 0000000..d011091 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0088.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgwoek0n2n6dt" +path="res://.godot/imported/tile_0088.png-855e8e5e50a308f67c6c505c46b4cbd0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0088.png" +dest_files=["res://.godot/imported/tile_0088.png-855e8e5e50a308f67c6c505c46b4cbd0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0089.png b/assets/kenney/tiny_town/Tiles/tile_0089.png new file mode 100644 index 0000000..8807a4b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0089.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0089.png.import b/assets/kenney/tiny_town/Tiles/tile_0089.png.import new file mode 100644 index 0000000..dcd34e4 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0089.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cs1a5mwyblsh2" +path="res://.godot/imported/tile_0089.png-dcdd862d69a8cde77d780ee2d0c47dd6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0089.png" +dest_files=["res://.godot/imported/tile_0089.png-dcdd862d69a8cde77d780ee2d0c47dd6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0090.png b/assets/kenney/tiny_town/Tiles/tile_0090.png new file mode 100644 index 0000000..902e628 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0090.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0090.png.import b/assets/kenney/tiny_town/Tiles/tile_0090.png.import new file mode 100644 index 0000000..508edb4 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0090.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dr1pa6w6k1ons" +path="res://.godot/imported/tile_0090.png-2c75262bb3eafac6bd63263005429d51.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0090.png" +dest_files=["res://.godot/imported/tile_0090.png-2c75262bb3eafac6bd63263005429d51.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0091.png b/assets/kenney/tiny_town/Tiles/tile_0091.png new file mode 100644 index 0000000..7fa1886 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0091.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0091.png.import b/assets/kenney/tiny_town/Tiles/tile_0091.png.import new file mode 100644 index 0000000..ea64ab9 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0091.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://diswhnx0qb76k" +path="res://.godot/imported/tile_0091.png-e8a28519a55313dc72a185db36bd0c86.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0091.png" +dest_files=["res://.godot/imported/tile_0091.png-e8a28519a55313dc72a185db36bd0c86.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0092.png b/assets/kenney/tiny_town/Tiles/tile_0092.png new file mode 100644 index 0000000..3024fbc Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0092.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0092.png.import b/assets/kenney/tiny_town/Tiles/tile_0092.png.import new file mode 100644 index 0000000..c02af6b --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0092.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bx652nwudxnb5" +path="res://.godot/imported/tile_0092.png-2624896b66159f19ebad65c0332b2351.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0092.png" +dest_files=["res://.godot/imported/tile_0092.png-2624896b66159f19ebad65c0332b2351.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0093.png b/assets/kenney/tiny_town/Tiles/tile_0093.png new file mode 100644 index 0000000..18dd436 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0093.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0093.png.import b/assets/kenney/tiny_town/Tiles/tile_0093.png.import new file mode 100644 index 0000000..f055596 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0093.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b66qtneepw5mp" +path="res://.godot/imported/tile_0093.png-8aa9eba07bbc9dce9350f1df038e8883.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0093.png" +dest_files=["res://.godot/imported/tile_0093.png-8aa9eba07bbc9dce9350f1df038e8883.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0094.png b/assets/kenney/tiny_town/Tiles/tile_0094.png new file mode 100644 index 0000000..6906b6e Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0094.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0094.png.import b/assets/kenney/tiny_town/Tiles/tile_0094.png.import new file mode 100644 index 0000000..1663074 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0094.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfaiarpv8yqnq" +path="res://.godot/imported/tile_0094.png-cd675a055fb382977d4ec99296b840fe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0094.png" +dest_files=["res://.godot/imported/tile_0094.png-cd675a055fb382977d4ec99296b840fe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0095.png b/assets/kenney/tiny_town/Tiles/tile_0095.png new file mode 100644 index 0000000..3035e45 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0095.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0095.png.import b/assets/kenney/tiny_town/Tiles/tile_0095.png.import new file mode 100644 index 0000000..786b333 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0095.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dixarsln1vee3" +path="res://.godot/imported/tile_0095.png-be07533f5d2648495eea183e68423bcf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0095.png" +dest_files=["res://.godot/imported/tile_0095.png-be07533f5d2648495eea183e68423bcf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0096.png b/assets/kenney/tiny_town/Tiles/tile_0096.png new file mode 100644 index 0000000..32d667f Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0096.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0096.png.import b/assets/kenney/tiny_town/Tiles/tile_0096.png.import new file mode 100644 index 0000000..b71f45d --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0096.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwxj6sgfrvp0r" +path="res://.godot/imported/tile_0096.png-ff6099b71117054a4dae36881c28490f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0096.png" +dest_files=["res://.godot/imported/tile_0096.png-ff6099b71117054a4dae36881c28490f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0097.png b/assets/kenney/tiny_town/Tiles/tile_0097.png new file mode 100644 index 0000000..08636d5 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0097.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0097.png.import b/assets/kenney/tiny_town/Tiles/tile_0097.png.import new file mode 100644 index 0000000..387813f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0097.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxw7yhllnw146" +path="res://.godot/imported/tile_0097.png-fb961a454024d66cc806caff38081ccb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0097.png" +dest_files=["res://.godot/imported/tile_0097.png-fb961a454024d66cc806caff38081ccb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0098.png b/assets/kenney/tiny_town/Tiles/tile_0098.png new file mode 100644 index 0000000..17799e0 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0098.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0098.png.import b/assets/kenney/tiny_town/Tiles/tile_0098.png.import new file mode 100644 index 0000000..70a6287 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0098.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c130vk8tpeq74" +path="res://.godot/imported/tile_0098.png-3282d98c6557950e909aefac3f650f97.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0098.png" +dest_files=["res://.godot/imported/tile_0098.png-3282d98c6557950e909aefac3f650f97.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0099.png b/assets/kenney/tiny_town/Tiles/tile_0099.png new file mode 100644 index 0000000..eafb989 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0099.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0099.png.import b/assets/kenney/tiny_town/Tiles/tile_0099.png.import new file mode 100644 index 0000000..2142db3 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0099.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdm8cej0e6t45" +path="res://.godot/imported/tile_0099.png-7af0cf603bfd9524ccd0bef138505969.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0099.png" +dest_files=["res://.godot/imported/tile_0099.png-7af0cf603bfd9524ccd0bef138505969.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0100.png b/assets/kenney/tiny_town/Tiles/tile_0100.png new file mode 100644 index 0000000..da234e5 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0100.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0100.png.import b/assets/kenney/tiny_town/Tiles/tile_0100.png.import new file mode 100644 index 0000000..7184d03 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0100.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drlt1rsk7bglr" +path="res://.godot/imported/tile_0100.png-5fd23beb34b8485b7200bfea1d33c9f9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0100.png" +dest_files=["res://.godot/imported/tile_0100.png-5fd23beb34b8485b7200bfea1d33c9f9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0101.png b/assets/kenney/tiny_town/Tiles/tile_0101.png new file mode 100644 index 0000000..f3dedcb Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0101.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0101.png.import b/assets/kenney/tiny_town/Tiles/tile_0101.png.import new file mode 100644 index 0000000..339e9e7 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0101.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxpvp4l6nflqq" +path="res://.godot/imported/tile_0101.png-986889a099c87182a635b163ffc96a0f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0101.png" +dest_files=["res://.godot/imported/tile_0101.png-986889a099c87182a635b163ffc96a0f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0102.png b/assets/kenney/tiny_town/Tiles/tile_0102.png new file mode 100644 index 0000000..321ff7a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0102.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0102.png.import b/assets/kenney/tiny_town/Tiles/tile_0102.png.import new file mode 100644 index 0000000..ec4f3ac --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0102.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddcd5c4w1bma0" +path="res://.godot/imported/tile_0102.png-addb61e8d5ebd4f574e8dcdc1b4189f0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0102.png" +dest_files=["res://.godot/imported/tile_0102.png-addb61e8d5ebd4f574e8dcdc1b4189f0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0103.png b/assets/kenney/tiny_town/Tiles/tile_0103.png new file mode 100644 index 0000000..46653e0 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0103.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0103.png.import b/assets/kenney/tiny_town/Tiles/tile_0103.png.import new file mode 100644 index 0000000..3a26599 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0103.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwwsbbmrerfsd" +path="res://.godot/imported/tile_0103.png-989590ed929e5ce2c12de112347e5230.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0103.png" +dest_files=["res://.godot/imported/tile_0103.png-989590ed929e5ce2c12de112347e5230.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0104.png b/assets/kenney/tiny_town/Tiles/tile_0104.png new file mode 100644 index 0000000..9c7c16a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0104.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0104.png.import b/assets/kenney/tiny_town/Tiles/tile_0104.png.import new file mode 100644 index 0000000..926d9bb --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0104.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dr38mj00ue411" +path="res://.godot/imported/tile_0104.png-1de896743421d72d402003bb0dcb88ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0104.png" +dest_files=["res://.godot/imported/tile_0104.png-1de896743421d72d402003bb0dcb88ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0105.png b/assets/kenney/tiny_town/Tiles/tile_0105.png new file mode 100644 index 0000000..5e1d449 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0105.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0105.png.import b/assets/kenney/tiny_town/Tiles/tile_0105.png.import new file mode 100644 index 0000000..8d1df60 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0105.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pi4h0pyp6dc6" +path="res://.godot/imported/tile_0105.png-3ee7b413edf445f690831d491591f750.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0105.png" +dest_files=["res://.godot/imported/tile_0105.png-3ee7b413edf445f690831d491591f750.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0106.png b/assets/kenney/tiny_town/Tiles/tile_0106.png new file mode 100644 index 0000000..5330944 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0106.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0106.png.import b/assets/kenney/tiny_town/Tiles/tile_0106.png.import new file mode 100644 index 0000000..8a37ec7 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0106.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://epmjgowhkpst" +path="res://.godot/imported/tile_0106.png-b39bde8ff7b997f25921f235c2702261.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0106.png" +dest_files=["res://.godot/imported/tile_0106.png-b39bde8ff7b997f25921f235c2702261.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0107.png b/assets/kenney/tiny_town/Tiles/tile_0107.png new file mode 100644 index 0000000..498d999 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0107.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0107.png.import b/assets/kenney/tiny_town/Tiles/tile_0107.png.import new file mode 100644 index 0000000..73d80d7 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0107.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://42vboljoslxg" +path="res://.godot/imported/tile_0107.png-21830718515fdee722603403210131a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0107.png" +dest_files=["res://.godot/imported/tile_0107.png-21830718515fdee722603403210131a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0108.png b/assets/kenney/tiny_town/Tiles/tile_0108.png new file mode 100644 index 0000000..f1d4db2 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0108.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0108.png.import b/assets/kenney/tiny_town/Tiles/tile_0108.png.import new file mode 100644 index 0000000..d859c16 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0108.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfcfe2ucavejr" +path="res://.godot/imported/tile_0108.png-45ff77d5f468de68b76d8a4d18ba268e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0108.png" +dest_files=["res://.godot/imported/tile_0108.png-45ff77d5f468de68b76d8a4d18ba268e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0109.png b/assets/kenney/tiny_town/Tiles/tile_0109.png new file mode 100644 index 0000000..2c97ef1 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0109.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0109.png.import b/assets/kenney/tiny_town/Tiles/tile_0109.png.import new file mode 100644 index 0000000..466d53c --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0109.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bmekr7o7itgfm" +path="res://.godot/imported/tile_0109.png-012316e17279d27e0363f1ceb2e32dc8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0109.png" +dest_files=["res://.godot/imported/tile_0109.png-012316e17279d27e0363f1ceb2e32dc8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0110.png b/assets/kenney/tiny_town/Tiles/tile_0110.png new file mode 100644 index 0000000..638bdc4 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0110.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0110.png.import b/assets/kenney/tiny_town/Tiles/tile_0110.png.import new file mode 100644 index 0000000..4266254 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0110.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjjcfa8f0ow24" +path="res://.godot/imported/tile_0110.png-62852641332d082addc9df43efd3c8d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0110.png" +dest_files=["res://.godot/imported/tile_0110.png-62852641332d082addc9df43efd3c8d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0111.png b/assets/kenney/tiny_town/Tiles/tile_0111.png new file mode 100644 index 0000000..d188375 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0111.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0111.png.import b/assets/kenney/tiny_town/Tiles/tile_0111.png.import new file mode 100644 index 0000000..371c569 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0111.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://vq5047tckp4e" +path="res://.godot/imported/tile_0111.png-025137e666bf4621000cace9dac0c391.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0111.png" +dest_files=["res://.godot/imported/tile_0111.png-025137e666bf4621000cace9dac0c391.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0112.png b/assets/kenney/tiny_town/Tiles/tile_0112.png new file mode 100644 index 0000000..e573c74 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0112.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0112.png.import b/assets/kenney/tiny_town/Tiles/tile_0112.png.import new file mode 100644 index 0000000..e88bc8f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0112.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8jb2ba6akupb" +path="res://.godot/imported/tile_0112.png-92c4325907b78768fb3c664dc9327108.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0112.png" +dest_files=["res://.godot/imported/tile_0112.png-92c4325907b78768fb3c664dc9327108.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0113.png b/assets/kenney/tiny_town/Tiles/tile_0113.png new file mode 100644 index 0000000..17b44f9 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0113.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0113.png.import b/assets/kenney/tiny_town/Tiles/tile_0113.png.import new file mode 100644 index 0000000..8b935c1 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0113.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bw4n5eljvt32q" +path="res://.godot/imported/tile_0113.png-1db4b27e94d663f9377f2ce90fe492e8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0113.png" +dest_files=["res://.godot/imported/tile_0113.png-1db4b27e94d663f9377f2ce90fe492e8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0114.png b/assets/kenney/tiny_town/Tiles/tile_0114.png new file mode 100644 index 0000000..5138912 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0114.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0114.png.import b/assets/kenney/tiny_town/Tiles/tile_0114.png.import new file mode 100644 index 0000000..bebd257 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0114.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyb1b61iaryr8" +path="res://.godot/imported/tile_0114.png-48d793d05e220fde0c5e138cff54b66d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0114.png" +dest_files=["res://.godot/imported/tile_0114.png-48d793d05e220fde0c5e138cff54b66d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0115.png b/assets/kenney/tiny_town/Tiles/tile_0115.png new file mode 100644 index 0000000..0325c94 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0115.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0115.png.import b/assets/kenney/tiny_town/Tiles/tile_0115.png.import new file mode 100644 index 0000000..653f330 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0115.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bou8jt0v83pc3" +path="res://.godot/imported/tile_0115.png-85b80005249f23e919562f9fec4f2849.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0115.png" +dest_files=["res://.godot/imported/tile_0115.png-85b80005249f23e919562f9fec4f2849.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0116.png b/assets/kenney/tiny_town/Tiles/tile_0116.png new file mode 100644 index 0000000..9253174 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0116.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0116.png.import b/assets/kenney/tiny_town/Tiles/tile_0116.png.import new file mode 100644 index 0000000..108ba07 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0116.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dqehcmais3qh4" +path="res://.godot/imported/tile_0116.png-b7ca327829de24b9c8c7b51ecfcd63ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0116.png" +dest_files=["res://.godot/imported/tile_0116.png-b7ca327829de24b9c8c7b51ecfcd63ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0117.png b/assets/kenney/tiny_town/Tiles/tile_0117.png new file mode 100644 index 0000000..6191d7b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0117.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0117.png.import b/assets/kenney/tiny_town/Tiles/tile_0117.png.import new file mode 100644 index 0000000..9306209 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0117.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwiteyplm36kd" +path="res://.godot/imported/tile_0117.png-c11308f675cca97bd21f3037f393e1ff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0117.png" +dest_files=["res://.godot/imported/tile_0117.png-c11308f675cca97bd21f3037f393e1ff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0118.png b/assets/kenney/tiny_town/Tiles/tile_0118.png new file mode 100644 index 0000000..e3e58e6 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0118.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0118.png.import b/assets/kenney/tiny_town/Tiles/tile_0118.png.import new file mode 100644 index 0000000..9911aab --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0118.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0vtbnvjlv4mr" +path="res://.godot/imported/tile_0118.png-d4751d2e3b7604739da22275e9a43c7e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0118.png" +dest_files=["res://.godot/imported/tile_0118.png-d4751d2e3b7604739da22275e9a43c7e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0119.png b/assets/kenney/tiny_town/Tiles/tile_0119.png new file mode 100644 index 0000000..b9da4a3 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0119.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0119.png.import b/assets/kenney/tiny_town/Tiles/tile_0119.png.import new file mode 100644 index 0000000..1ba48ba --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0119.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c43pb6o66gf0k" +path="res://.godot/imported/tile_0119.png-568f16137c07bb9d4a26d9032ac613fd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0119.png" +dest_files=["res://.godot/imported/tile_0119.png-568f16137c07bb9d4a26d9032ac613fd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0120.png b/assets/kenney/tiny_town/Tiles/tile_0120.png new file mode 100644 index 0000000..9991c97 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0120.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0120.png.import b/assets/kenney/tiny_town/Tiles/tile_0120.png.import new file mode 100644 index 0000000..026b0c1 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0120.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpkr56wmramh7" +path="res://.godot/imported/tile_0120.png-8431dcd7ae8f066e2f02d5535df6475a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0120.png" +dest_files=["res://.godot/imported/tile_0120.png-8431dcd7ae8f066e2f02d5535df6475a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0121.png b/assets/kenney/tiny_town/Tiles/tile_0121.png new file mode 100644 index 0000000..9ad5ce6 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0121.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0121.png.import b/assets/kenney/tiny_town/Tiles/tile_0121.png.import new file mode 100644 index 0000000..8c5f09d --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0121.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cj673qxucapti" +path="res://.godot/imported/tile_0121.png-b2e63f06f13db3b456948725738683a8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0121.png" +dest_files=["res://.godot/imported/tile_0121.png-b2e63f06f13db3b456948725738683a8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0122.png b/assets/kenney/tiny_town/Tiles/tile_0122.png new file mode 100644 index 0000000..ed2d0f5 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0122.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0122.png.import b/assets/kenney/tiny_town/Tiles/tile_0122.png.import new file mode 100644 index 0000000..bdf0adf --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0122.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://br6s35k60lqbw" +path="res://.godot/imported/tile_0122.png-ba633c75485c7321e21d03fd5ed79f5f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0122.png" +dest_files=["res://.godot/imported/tile_0122.png-ba633c75485c7321e21d03fd5ed79f5f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0123.png b/assets/kenney/tiny_town/Tiles/tile_0123.png new file mode 100644 index 0000000..3958c4b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0123.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0123.png.import b/assets/kenney/tiny_town/Tiles/tile_0123.png.import new file mode 100644 index 0000000..a854f8b --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0123.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bstnekga8uln6" +path="res://.godot/imported/tile_0123.png-3ad71f5a846c6884f62305164daa6c17.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0123.png" +dest_files=["res://.godot/imported/tile_0123.png-3ad71f5a846c6884f62305164daa6c17.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0124.png b/assets/kenney/tiny_town/Tiles/tile_0124.png new file mode 100644 index 0000000..029f09b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0124.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0124.png.import b/assets/kenney/tiny_town/Tiles/tile_0124.png.import new file mode 100644 index 0000000..9ee9b1f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0124.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4hjvfde35pbx" +path="res://.godot/imported/tile_0124.png-6d270f7cd1bd0a0c3268cf3e1ac0af65.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0124.png" +dest_files=["res://.godot/imported/tile_0124.png-6d270f7cd1bd0a0c3268cf3e1ac0af65.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0125.png b/assets/kenney/tiny_town/Tiles/tile_0125.png new file mode 100644 index 0000000..c13e66b Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0125.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0125.png.import b/assets/kenney/tiny_town/Tiles/tile_0125.png.import new file mode 100644 index 0000000..0af57a7 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0125.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c01liytmbxq5b" +path="res://.godot/imported/tile_0125.png-8e9da2cc25633235758bcf6e19437413.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0125.png" +dest_files=["res://.godot/imported/tile_0125.png-8e9da2cc25633235758bcf6e19437413.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0126.png b/assets/kenney/tiny_town/Tiles/tile_0126.png new file mode 100644 index 0000000..50b6aca Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0126.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0126.png.import b/assets/kenney/tiny_town/Tiles/tile_0126.png.import new file mode 100644 index 0000000..0af71de --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0126.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmj85nmg23ow7" +path="res://.godot/imported/tile_0126.png-e7019dbc5a5d33411d79763d0c2861f2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0126.png" +dest_files=["res://.godot/imported/tile_0126.png-e7019dbc5a5d33411d79763d0c2861f2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0127.png b/assets/kenney/tiny_town/Tiles/tile_0127.png new file mode 100644 index 0000000..1408cfe Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0127.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0127.png.import b/assets/kenney/tiny_town/Tiles/tile_0127.png.import new file mode 100644 index 0000000..60063ef --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0127.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djpe2loxs26u8" +path="res://.godot/imported/tile_0127.png-1486728a51e55c1adb845b49d88874cc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0127.png" +dest_files=["res://.godot/imported/tile_0127.png-1486728a51e55c1adb845b49d88874cc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0128.png b/assets/kenney/tiny_town/Tiles/tile_0128.png new file mode 100644 index 0000000..f0ef7a0 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0128.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0128.png.import b/assets/kenney/tiny_town/Tiles/tile_0128.png.import new file mode 100644 index 0000000..9801bf8 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0128.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dnyl1hmw5ict8" +path="res://.godot/imported/tile_0128.png-5b0e1dd3e326327607a92dd5c751f3e5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0128.png" +dest_files=["res://.godot/imported/tile_0128.png-5b0e1dd3e326327607a92dd5c751f3e5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0129.png b/assets/kenney/tiny_town/Tiles/tile_0129.png new file mode 100644 index 0000000..3c02b6a Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0129.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0129.png.import b/assets/kenney/tiny_town/Tiles/tile_0129.png.import new file mode 100644 index 0000000..33202e2 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0129.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kxfwvmthpax7" +path="res://.godot/imported/tile_0129.png-b782c1374307ce899d393877a6d064b2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0129.png" +dest_files=["res://.godot/imported/tile_0129.png-b782c1374307ce899d393877a6d064b2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0130.png b/assets/kenney/tiny_town/Tiles/tile_0130.png new file mode 100644 index 0000000..4b99555 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0130.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0130.png.import b/assets/kenney/tiny_town/Tiles/tile_0130.png.import new file mode 100644 index 0000000..1423d4f --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0130.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dy6l3dmvxsdwc" +path="res://.godot/imported/tile_0130.png-8519f7531f8e8a7dbdb5969162efb0d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0130.png" +dest_files=["res://.godot/imported/tile_0130.png-8519f7531f8e8a7dbdb5969162efb0d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tiles/tile_0131.png b/assets/kenney/tiny_town/Tiles/tile_0131.png new file mode 100644 index 0000000..b1c9c45 Binary files /dev/null and b/assets/kenney/tiny_town/Tiles/tile_0131.png differ diff --git a/assets/kenney/tiny_town/Tiles/tile_0131.png.import b/assets/kenney/tiny_town/Tiles/tile_0131.png.import new file mode 100644 index 0000000..f9539a8 --- /dev/null +++ b/assets/kenney/tiny_town/Tiles/tile_0131.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cw530xwrvhrxc" +path="res://.godot/imported/tile_0131.png-e8348a3b143ace89d9847b97f60add50.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/kenney/tiny_town/Tiles/tile_0131.png" +dest_files=["res://.godot/imported/tile_0131.png-e8348a3b143ace89d9847b97f60add50.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/kenney/tiny_town/Tilesheet.txt b/assets/kenney/tiny_town/Tilesheet.txt new file mode 100644 index 0000000..3ecbdd2 --- /dev/null +++ b/assets/kenney/tiny_town/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 16px × 16px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 12 tiles +Total tiles (vertical) • 11 tiles +--- +Total tiles in sheet • 132 tiles \ No newline at end of file diff --git a/assets/kenney/tiny_town/Tiny Dungeon (base pack).url b/assets/kenney/tiny_town/Tiny Dungeon (base pack).url new file mode 100644 index 0000000..7fb827d --- /dev/null +++ b/assets/kenney/tiny_town/Tiny Dungeon (base pack).url @@ -0,0 +1,6 @@ +[InternetShortcut] +URL=https://kenney.nl/assets/tiny-dungeon +IDList= +HotKey=0 +[{000214A0-0000-0000-C000-000000000046}] +Prop3=19,11 diff --git a/assets/kenney/tiny_town/Visit Kenney.url b/assets/kenney/tiny_town/Visit Kenney.url new file mode 100644 index 0000000..fbdde43 --- /dev/null +++ b/assets/kenney/tiny_town/Visit Kenney.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=http://www.kenney.nl/ \ No newline at end of file diff --git a/assets/kenney/tiny_town/Visit Patreon.url b/assets/kenney/tiny_town/Visit Patreon.url new file mode 100644 index 0000000..439ef26 --- /dev/null +++ b/assets/kenney/tiny_town/Visit Patreon.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://www.patreon.com/kenney/ \ No newline at end of file diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v1.png b/assets/mockups/the-tree-main-gameplay-visual-mock-v1.png new file mode 100644 index 0000000..3727330 Binary files /dev/null and b/assets/mockups/the-tree-main-gameplay-visual-mock-v1.png differ diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v1.png.import b/assets/mockups/the-tree-main-gameplay-visual-mock-v1.png.import new file mode 100644 index 0000000..1c32c0d --- /dev/null +++ b/assets/mockups/the-tree-main-gameplay-visual-mock-v1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://untl551rrjqb" +path="res://.godot/imported/the-tree-main-gameplay-visual-mock-v1.png-43dd87dc6cb9f73d205ab3b7d4a5b013.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-main-gameplay-visual-mock-v1.png" +dest_files=["res://.godot/imported/the-tree-main-gameplay-visual-mock-v1.png-43dd87dc6cb9f73d205ab3b7d4a5b013.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v1.prompt.md b/assets/mockups/the-tree-main-gameplay-visual-mock-v1.prompt.md new file mode 100644 index 0000000..ec4a533 --- /dev/null +++ b/assets/mockups/the-tree-main-gameplay-visual-mock-v1.prompt.md @@ -0,0 +1,29 @@ +# The Tree メイン画面ビジュアルモック v1 生成プロンプト + +Use case: ui-mockup + +Asset type: high-fidelity 16:9 desktop gameplay screen mockup for a 2D management simulation + +Primary request: Create a shippable-looking main gameplay screen for "The Tree", an isekai RPG fantasy inn management game built inside a colossal living world tree. This is a practical game UI mockup, not standalone concept art. + +Scene/backdrop: strict side-on cutaway view of a gigantic ancient world tree. The trunk contains a multi-floor fantasy inn above ground; enormous roots descend into underground chambers. Show the whole vertical relationship clearly. + +Subject: Above ground, a welcoming fantasy inn with reception desk, dining hall, kitchen, guest rooms, bath, storage and corridors; adult adventurers, elves, beastfolk and staff moving naturally. Below ground, root-carved night businesses with a tavern, guarded private rooms, magical trade room and service tunnels, mysterious but non-explicit. Clearly imply separate elegant guest corridors and hidden practical staff/service routes. + +Style/medium: polished hand-painted 2D fantasy strategy game art with crisp readable silhouettes, slightly stylized proportions, production-quality interface, detailed but not cluttered. Orthographic side view, readable room-by-room management layout. + +Composition/framing: central world-tree cutaway occupies about 70 percent of the screen. Compact resource strip along the top, slim fantasy navigation at left, selected-room information panel at right, build palette along the bottom. Preserve a large unobstructed playable world area. + +Lighting/mood: upper inn uses warm amber lantern light, hearth glow, sunlit green leaves and welcoming hospitality; underground roots use restrained violet, wine-red and blue magical light with secrecy and risk. Strong but tasteful transition between day business and night business. + +Color palette: deep forest green, warm walnut brown, parchment cream, antique brass, amber, muted burgundy, restrained arcane violet. + +Materials/textures: ancient bark, carved dark oak, worn stone, brass fittings, parchment, leather, linen, glowing mana crystals. + +Text (verbatim): "THE TREE", "DAY 3", "06:00", "GOLD 2,480", "FAITH 36", "HEAT 12" + +UI details: carved wood and antique-brass frames, parchment panels, small painted facility icons, fantasy ledger styling, clear pause/x1/x2/x4 controls, selected guest room status, subtle construction grid and room placement highlight. + +Constraints: all depicted characters are clearly adults; no sexual activity or nudity; practical readable game interface; coherent room scale; no trademarks; no watermark. + +Avoid: modern gray developer dashboard, spreadsheet look, sci-fi panels, neon cyberpunk, mobile-game gacha UI, photorealism, isometric camera, excessive ornate borders, tiny illegible filler text, empty placeholder boxes. diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png b/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png new file mode 100644 index 0000000..a15c6e8 Binary files /dev/null and b/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png differ diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png.import b/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png.import new file mode 100644 index 0000000..03df585 --- /dev/null +++ b/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5rjmtpu5tr2b" +path="res://.godot/imported/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png-87f83f685cb340b6316a9b70de95f7fa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png" +dest_files=["res://.godot/imported/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png-87f83f685cb340b6316a9b70de95f7fa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.prompt.md b/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.prompt.md new file mode 100644 index 0000000..ce2d6ac --- /dev/null +++ b/assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.prompt.md @@ -0,0 +1,27 @@ +# The Tree メイン画面ビジュアルモック v2 生成プロンプト + +Use case: style-transfer + +Asset type: high-fidelity 16:9 desktop gameplay UI mockup + +Input images: Image 1 is the edit target and composition reference. + +Primary request: Revise Image 1 to reduce its Western PC strategy-game feeling and give it the visual language of an original contemporary Japanese fantasy management simulation. Change the visual style, character rendering, UI materials, typography and color handling while preserving the same game, same room functions and same practical management-screen structure. + +Preserve exactly: strict side-on world-tree cutaway composition; upper fantasy inn and underground root facilities; overall room arrangement; top status bar; left navigation; right selected-room panel; bottom construction palette; separate guest and staff/service paths; visible build-placement grid; 16:9 framing. + +Style/medium: polished Japanese 2D game illustration with clean anime-influenced linework, softly painted backgrounds, expressive clearly adult character sprites, brighter readable silhouettes, graceful fantasy ornament, warm storybook charm, premium console/PC JRPG-adjacent presentation without imitating any specific existing title. + +UI transformation: replace heavy black carved-wood slabs and dense brass borders with lighter layered panels using pale parchment, cream, warm honey wood, muted jade, soft gold trim and small botanical/world-tree motifs. Use cleaner rounded panel corners, more whitespace, colorful hand-painted icons, restrained translucent overlays and an elegant Japanese game UI hierarchy. Keep it a practical management interface, not a mobile gacha screen. + +Environment transformation: make the upper inn more colorful, welcoming and luminous with honey-colored timber, ivory plaster, greenery, blue sky and gentle magical particles. Keep the underground mysterious and mature but elegant, using plum, indigo and magenta accents rather than grim darkness. Reduce gritty realism and gothic heaviness. + +Character transformation: replace realistic Western tabletop-RPG figures with clearly adult anime-style fantasy inn staff and adventurers, varied silhouettes and costumes, friendly readable poses, no chibi proportions, no school uniforms. + +Text (verbatim): "世界樹の宿", "3日目", "06:00", "所持金 2,480", "信仰 36", "警戒 12", "客室", "施設", "設備", "装飾", "従業員", "地下" + +Constraints: all characters clearly adults; no nudity or sexual activity; preserve screen usability and room scale; no logos; no watermark; no imitation of a named game or artist. + +Avoid: Western CRPG look, Dungeons-and-Dragons rulebook aesthetic, heavy gothic frames, near-black UI, gritty medieval realism, serif-heavy English interface, photorealism, chibi characters, visual-novel-only layout, mobile gacha clutter, sci-fi or cyberpunk. + +Invariant: change only the presentation style and localization; keep the core layout, world-tree concept, facilities, paths and management interactions recognizable from Image 1. diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v3-prompt.md b/assets/mockups/the-tree-main-gameplay-visual-mock-v3-prompt.md new file mode 100644 index 0000000..aad17b9 --- /dev/null +++ b/assets/mockups/the-tree-main-gameplay-visual-mock-v3-prompt.md @@ -0,0 +1,11 @@ +# V3 image-generation prompt + +Use case: `ui-mockup` + +Create a polished 16:9 main gameplay screen for a Japanese fantasy inn-management simulation. Preserve the central giant world-tree inn cutaway, the bright ground/dark underground contrast, and the warm Japanese anime-fantasy atmosphere of the V2 reference. + +Evolve the screen into a practical management interface focused on watching autonomous guests and staff, finding congestion and queues, and directly building or rearranging facilities. Use an original Japanese indie fantasy-management visual language: chunky warm wood, leather and gold frames; cream panels; bold outlined Japanese typography; colorful painted resource icons; rounded tactile buttons. Do not imitate an existing game's exact assets or geometry. + +Keep the world-tree inn dominant. Add a dense top HUD for day, time, period, visitors, lodging, funds, food, wood, mana, reputation and alert, with pause/play/x1/x2/x4 near the clock. Add a left rail for 従業員, 施設, 商品, 探索, 投資 and 帳簿. Add a bottom construction palette with illustrated cards, prices, rotation and demolition. Show a translucent green facility preview. Add a right inspector for 食堂 with status, capacity, users, waiting guests, staff, cleanliness, daily revenue and details. Add subtle green/yellow/red routes and queue bubbles, including one red stair bottleneck. Include a compact objective and alert log without covering the inn. + +Use warm honey wood, caramel leather, parchment cream and muted gold; emerald for healthy states, amber for warnings, restrained crimson for danger, and violet/indigo underground. Keep adult-oriented areas non-explicit and suggested only through elegant nighttime lounge lighting. No nudity, sexual acts, fetish imagery, copied logos, characters, icons, trademarks, Western gothic realism, mobile-ad clutter or watermark. diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png b/assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png new file mode 100644 index 0000000..b3e0230 Binary files /dev/null and b/assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png differ diff --git a/assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png.import b/assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png.import new file mode 100644 index 0000000..85c59b1 --- /dev/null +++ b/assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr832ermjtqyf" +path="res://.godot/imported/the-tree-main-gameplay-visual-mock-v3-tower-style.png-d12a3d9e8a1e3e3766be917a2e2d0f17.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png" +dest_files=["res://.godot/imported/the-tree-main-gameplay-visual-mock-v3-tower-style.png-d12a3d9e8a1e3e3766be917a2e2d0f17.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v3-assets-front.png b/assets/mockups/the-tree-runtime-ui-v3-assets-front.png new file mode 100644 index 0000000..d7b0e35 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v3-assets-front.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v3-assets-front.png.import b/assets/mockups/the-tree-runtime-ui-v3-assets-front.png.import new file mode 100644 index 0000000..fb00f54 --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v3-assets-front.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://gyct8efambkq" +path="res://.godot/imported/the-tree-runtime-ui-v3-assets-front.png-5d43da8f8b97a3a6f59845df79c4e5f1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v3-assets-front.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v3-assets-front.png-5d43da8f8b97a3a6f59845df79c4e5f1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v3-first-pass.png b/assets/mockups/the-tree-runtime-ui-v3-first-pass.png new file mode 100644 index 0000000..e8a6315 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v3-first-pass.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v3-first-pass.png.import b/assets/mockups/the-tree-runtime-ui-v3-first-pass.png.import new file mode 100644 index 0000000..9b3f27f --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v3-first-pass.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rjck7xrbqt0p" +path="res://.godot/imported/the-tree-runtime-ui-v3-first-pass.png-7c0b25da81932660f045cbf6e98436f4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v3-first-pass.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v3-first-pass.png-7c0b25da81932660f045cbf6e98436f4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v4-icons.png b/assets/mockups/the-tree-runtime-ui-v4-icons.png new file mode 100644 index 0000000..be3f504 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v4-icons.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v4-icons.png.import b/assets/mockups/the-tree-runtime-ui-v4-icons.png.import new file mode 100644 index 0000000..04e0ef4 --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v4-icons.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dneat4dy6kxg4" +path="res://.godot/imported/the-tree-runtime-ui-v4-icons.png-352b81b77ac6c9c1f1921756fdfdc094.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v4-icons.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v4-icons.png-352b81b77ac6c9c1f1921756fdfdc094.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v5-map-icons.png b/assets/mockups/the-tree-runtime-ui-v5-map-icons.png new file mode 100644 index 0000000..60d2a9a Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v5-map-icons.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v5-map-icons.png.import b/assets/mockups/the-tree-runtime-ui-v5-map-icons.png.import new file mode 100644 index 0000000..e9dd706 --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v5-map-icons.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhcgtaj6oulfm" +path="res://.godot/imported/the-tree-runtime-ui-v5-map-icons.png-ce1235ef9d5d65fa51a6d8fc8dc06ce2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v5-map-icons.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v5-map-icons.png-ce1235ef9d5d65fa51a6d8fc8dc06ce2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v6-actors-4k.png b/assets/mockups/the-tree-runtime-ui-v6-actors-4k.png new file mode 100644 index 0000000..637b697 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v6-actors-4k.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v6-actors-4k.png.import b/assets/mockups/the-tree-runtime-ui-v6-actors-4k.png.import new file mode 100644 index 0000000..51650cd --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v6-actors-4k.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://buhwdq2ev2afj" +path="res://.godot/imported/the-tree-runtime-ui-v6-actors-4k.png-759f2c25de397477eba48b52f4e9fff5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v6-actors-4k.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v6-actors-4k.png-759f2c25de397477eba48b52f4e9fff5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v6-actors.png b/assets/mockups/the-tree-runtime-ui-v6-actors.png new file mode 100644 index 0000000..b59ed30 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v6-actors.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v6-actors.png.import b/assets/mockups/the-tree-runtime-ui-v6-actors.png.import new file mode 100644 index 0000000..814f7cd --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v6-actors.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jklpgw0ije7f" +path="res://.godot/imported/the-tree-runtime-ui-v6-actors.png-c8ffa5273c68da7353894a0c1149a292.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v6-actors.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v6-actors.png-c8ffa5273c68da7353894a0c1149a292.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v7-skins-4k.png b/assets/mockups/the-tree-runtime-ui-v7-skins-4k.png new file mode 100644 index 0000000..32caf07 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v7-skins-4k.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v7-skins-4k.png.import b/assets/mockups/the-tree-runtime-ui-v7-skins-4k.png.import new file mode 100644 index 0000000..fbe371a --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v7-skins-4k.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhb3e7s5sh41k" +path="res://.godot/imported/the-tree-runtime-ui-v7-skins-4k.png-e17c79d84b211808ea3849bccfcf1957.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v7-skins-4k.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v7-skins-4k.png-e17c79d84b211808ea3849bccfcf1957.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/mockups/the-tree-runtime-ui-v7-skins.png b/assets/mockups/the-tree-runtime-ui-v7-skins.png new file mode 100644 index 0000000..70daed1 Binary files /dev/null and b/assets/mockups/the-tree-runtime-ui-v7-skins.png differ diff --git a/assets/mockups/the-tree-runtime-ui-v7-skins.png.import b/assets/mockups/the-tree-runtime-ui-v7-skins.png.import new file mode 100644 index 0000000..a887ae4 --- /dev/null +++ b/assets/mockups/the-tree-runtime-ui-v7-skins.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4d6gg5ofj6ge" +path="res://.godot/imported/the-tree-runtime-ui-v7-skins.png-d61f91c91e625dc7f985b289032057e2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/mockups/the-tree-runtime-ui-v7-skins.png" +dest_files=["res://.godot/imported/the-tree-runtime-ui-v7-skins.png-d61f91c91e625dc7f985b289032057e2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/themes/japanese_fantasy/README.md b/assets/themes/japanese_fantasy/README.md new file mode 100644 index 0000000..debcad7 --- /dev/null +++ b/assets/themes/japanese_fantasy/README.md @@ -0,0 +1,56 @@ +# Japanese Fantasy visual theme + +`visual-theme-v1.tres` is the only resource the main scene references. The simulation does not reference image filenames or theme colors directly. + +## Replacing the art + +1. Add replacement images beside the existing files or in another theme directory. +2. Duplicate `visual-theme-v1.tres` and change its texture references and palette values. +3. Assign the new `VisualThemeProfile` to the `visual_theme` export on the main scene. + +Images may have different pixel dimensions because the world preview scales them into its drawing area. A 16:9 landscape image with a straight-on cutaway and a low-detail center produces the clearest construction overlay. + +The interface icons are replaceable atlases: + +- `facility-icons-v2.png`: the sixteen facility icons in the fixed 4 x 4 catalog order. +- `system-icons-v2.png`: navigation, layer, resource, and operation icons in a fixed 4 x 4 order. +- `actor-markers-v2.png`: eight guest classes followed by eight staff role groups in a fixed 4 x 4 order. +- `ui-skin-v1.png`: panels, button states, frames, badges, construction, route, and layer-decoration skins in a fixed 4 x 4 order. + +To preserve existing assignments, replace an atlas with another square 4 x 4 image using the same row-major order. To reorder or replace individual icons, edit only the dictionaries and `AtlasTexture` regions in `visual-theme-v1.tres`. Gameplay code continues to use stable semantic IDs and never depends on the image filenames. + +## High-resolution behavior + +The UI is authored at a 1440 x 810 base size and uses Godot's `canvas_items` stretch mode, `expand` aspect handling, fractional scaling, and HiDPI support. The generated backgrounds and atlases retain enough source detail for 720p, 1080p, 1440p, and 4K output. Mipmaps are enabled on every theme texture so scaled-down assets remain stable and readable. Exact 4K output is covered by the dashboard capture and layout tests. + +The complete external-production brief, including dimensions, alpha rules, exact atlas order, prompt recipes, and acceptance checks, is in `docs/ART_ASSET_PRODUCTION_SPECIFICATION.md`. + +If a background texture is absent, the world preview falls back to procedural colors. Visual themes are intentionally excluded from save snapshots and simulation digests. + +## Generated asset prompts + +The built-in image-generation tool was used. + +### `world-front-v1.png` + +Create a text-free Japanese fantasy world-tree inn background for a rectangular construction grid. Use a straight-on 16:9 cutaway with horizontal timber floors and mostly empty modular room bays. Keep the center low-detail for facilities, characters, routes and selection overlays. Use honey wood, cream plaster, leafy green, muted gold and blue sky. No UI, text, characters, logos, trademarked elements or watermark. + +### `world-back-v1.png` + +Create a text-free underground world-tree roots background for a rectangular construction grid. Use a straight-on 16:9 cutaway with carved-root floors and mostly empty modular chambers. Use living roots, service corridors, brass pipes, warm lanterns and restrained violet crystals. Keep the center readable for gameplay overlays. No UI, text, characters, explicit content, logos, trademarked elements or watermark. + +### `facility-icons-v2.png` + +Create exactly sixteen Japanese fantasy inn facility icons as a regular 4 x 4 transparent atlas, in catalog order: reception, guest room, cafeteria, warehouse; kitchen, bath, staff room, security; tavern, massage, guest lift, service lift; staff door, guest corridor, service corridor, tasteful private lounge. Use warm hand-painted RPG styling, clear small silhouettes, equal padding, no text or frames. The first generation was then background-extracted to a genuine alpha channel. + +### `system-icons-v2.png` + +Create exactly sixteen Japanese fantasy management icons as a regular 4 x 4 transparent atlas: staff, construction, products, exploration; investment, ledger, front layer, back layer; gold, mana, materials, faith; warning, rotate, excavate, save. Use warm hand-painted RPG styling, clear small silhouettes, equal padding, no text or frames. The first generation was then background-extracted to a genuine alpha channel. + +### `actor-markers-v2.png` + +Create exactly sixteen unmistakably adult Japanese fantasy RPG character busts as a regular 4 x 4 transparent atlas. Guest rows: warrior, mage, rogue, cleric; noble, merchant, outsider/beastfolk/demonfolk, diplomat. Staff rows: receptionist/clerk, cook, cleaner/maintenance, porter; attendant/masseur, guard, bartender, medic. Match the warm hand-painted, cel-shaded facility icon style; use distinct silhouettes and colors that remain readable at 16 to 24 pixels and retain clean detail at 4K. No text, frames, grid, sexualization, nudity, logos, trademarked elements, or watermark. The first generation was then background-extracted to a genuine alpha channel. + +### `ui-skin-v1.png` + +Create exactly sixteen front-facing Japanese fantasy UI skins as a regular 4 x 4 atlas: outer panel, wood panel, leather panel, parchment panel; normal, hover, selected, and disabled buttons; selection frame, warning frame, queue badge, construction texture; guest route, staff route, FRONT divider, BACK divider. Keep panel corners and edges inside the outer 15 percent and the center stretch-safe for nine-patch scaling. Use honey walnut, leather, antique brass, parchment, moss green, muted cyan, and restrained violet. No text, numerals, characters, facilities, logos, trademarked elements, or watermark. diff --git a/assets/themes/japanese_fantasy/actor-markers-v2.png b/assets/themes/japanese_fantasy/actor-markers-v2.png new file mode 100644 index 0000000..73412b7 Binary files /dev/null and b/assets/themes/japanese_fantasy/actor-markers-v2.png differ diff --git a/assets/themes/japanese_fantasy/actor-markers-v2.png.import b/assets/themes/japanese_fantasy/actor-markers-v2.png.import new file mode 100644 index 0000000..d391080 --- /dev/null +++ b/assets/themes/japanese_fantasy/actor-markers-v2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://f0bgq30gv2v8" +path="res://.godot/imported/actor-markers-v2.png-5be13c3ab1f2e30e081a77e87cc6291c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/themes/japanese_fantasy/actor-markers-v2.png" +dest_files=["res://.godot/imported/actor-markers-v2.png-5be13c3ab1f2e30e081a77e87cc6291c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/themes/japanese_fantasy/facility-icons-v2.png b/assets/themes/japanese_fantasy/facility-icons-v2.png new file mode 100644 index 0000000..76a7490 Binary files /dev/null and b/assets/themes/japanese_fantasy/facility-icons-v2.png differ diff --git a/assets/themes/japanese_fantasy/facility-icons-v2.png.import b/assets/themes/japanese_fantasy/facility-icons-v2.png.import new file mode 100644 index 0000000..ffb90c8 --- /dev/null +++ b/assets/themes/japanese_fantasy/facility-icons-v2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4nxh6bj7kaeu" +path="res://.godot/imported/facility-icons-v2.png-db1517a41bee175e6996bfac3adad43c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/themes/japanese_fantasy/facility-icons-v2.png" +dest_files=["res://.godot/imported/facility-icons-v2.png-db1517a41bee175e6996bfac3adad43c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/themes/japanese_fantasy/system-icons-v2.png b/assets/themes/japanese_fantasy/system-icons-v2.png new file mode 100644 index 0000000..5b42d27 Binary files /dev/null and b/assets/themes/japanese_fantasy/system-icons-v2.png differ diff --git a/assets/themes/japanese_fantasy/system-icons-v2.png.import b/assets/themes/japanese_fantasy/system-icons-v2.png.import new file mode 100644 index 0000000..994c957 --- /dev/null +++ b/assets/themes/japanese_fantasy/system-icons-v2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5k1cf75cdcgv" +path="res://.godot/imported/system-icons-v2.png-90d47be85a8c4e32fbe6e84e60a33ed9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/themes/japanese_fantasy/system-icons-v2.png" +dest_files=["res://.godot/imported/system-icons-v2.png-90d47be85a8c4e32fbe6e84e60a33ed9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/themes/japanese_fantasy/ui-skin-v1.png b/assets/themes/japanese_fantasy/ui-skin-v1.png new file mode 100644 index 0000000..97b3ce3 Binary files /dev/null and b/assets/themes/japanese_fantasy/ui-skin-v1.png differ diff --git a/assets/themes/japanese_fantasy/ui-skin-v1.png.import b/assets/themes/japanese_fantasy/ui-skin-v1.png.import new file mode 100644 index 0000000..ddee7ed --- /dev/null +++ b/assets/themes/japanese_fantasy/ui-skin-v1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfb40plskjvcy" +path="res://.godot/imported/ui-skin-v1.png-c4d4dc517c9c93a6b56879fc9cb6c320.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/themes/japanese_fantasy/ui-skin-v1.png" +dest_files=["res://.godot/imported/ui-skin-v1.png-c4d4dc517c9c93a6b56879fc9cb6c320.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/themes/japanese_fantasy/visual-theme-v1.tres b/assets/themes/japanese_fantasy/visual-theme-v1.tres new file mode 100644 index 0000000..7ea5b53 --- /dev/null +++ b/assets/themes/japanese_fantasy/visual-theme-v1.tres @@ -0,0 +1,314 @@ +[gd_resource type="Resource" script_class="VisualThemeProfile" load_steps=74 format=3] + +[ext_resource type="Script" path="res://scripts/ui/visual_theme_profile.gd" id="1_theme"] +[ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/world-front-v1.png" id="2_front"] +[ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/world-back-v1.png" id="3_back"] +[ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/facility-icons-v2.png" id="4_facilities"] +[ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/system-icons-v2.png" id="5_system"] +[ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/actor-markers-v2.png" id="6_actors"] +[ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/ui-skin-v1.png" id="7_skin"] + +[ext_resource type="Texture2D" path="res://assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png" id="8_pixel_dungeon"] +[ext_resource type="Texture2D" path="res://assets/kenney/tiny_town/Tilemap/tilemap_packed.png" id="9_pixel_town"] + +[sub_resource type="AtlasTexture" id="U00"] +atlas = ExtResource("7_skin") +region = Rect2(0, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U01"] +atlas = ExtResource("7_skin") +region = Rect2(313.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U02"] +atlas = ExtResource("7_skin") +region = Rect2(627, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U03"] +atlas = ExtResource("7_skin") +region = Rect2(940.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U10"] +atlas = ExtResource("7_skin") +region = Rect2(0, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U11"] +atlas = ExtResource("7_skin") +region = Rect2(313.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U12"] +atlas = ExtResource("7_skin") +region = Rect2(627, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U13"] +atlas = ExtResource("7_skin") +region = Rect2(940.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U20"] +atlas = ExtResource("7_skin") +region = Rect2(0, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U21"] +atlas = ExtResource("7_skin") +region = Rect2(313.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U22"] +atlas = ExtResource("7_skin") +region = Rect2(627, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U23"] +atlas = ExtResource("7_skin") +region = Rect2(940.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U30"] +atlas = ExtResource("7_skin") +region = Rect2(0, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U31"] +atlas = ExtResource("7_skin") +region = Rect2(313.5, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U32"] +atlas = ExtResource("7_skin") +region = Rect2(627, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="U33"] +atlas = ExtResource("7_skin") +region = Rect2(940.5, 940.5, 313.5, 313.5) + +[sub_resource type="AtlasTexture" id="A00"] +atlas = ExtResource("6_actors") +region = Rect2(0, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A01"] +atlas = ExtResource("6_actors") +region = Rect2(313.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A02"] +atlas = ExtResource("6_actors") +region = Rect2(627, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A03"] +atlas = ExtResource("6_actors") +region = Rect2(940.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A10"] +atlas = ExtResource("6_actors") +region = Rect2(0, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A11"] +atlas = ExtResource("6_actors") +region = Rect2(313.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A12"] +atlas = ExtResource("6_actors") +region = Rect2(627, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A13"] +atlas = ExtResource("6_actors") +region = Rect2(940.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A20"] +atlas = ExtResource("6_actors") +region = Rect2(0, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A21"] +atlas = ExtResource("6_actors") +region = Rect2(313.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A22"] +atlas = ExtResource("6_actors") +region = Rect2(627, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A23"] +atlas = ExtResource("6_actors") +region = Rect2(940.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A30"] +atlas = ExtResource("6_actors") +region = Rect2(0, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A31"] +atlas = ExtResource("6_actors") +region = Rect2(313.5, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A32"] +atlas = ExtResource("6_actors") +region = Rect2(627, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="A33"] +atlas = ExtResource("6_actors") +region = Rect2(940.5, 940.5, 313.5, 313.5) + +[sub_resource type="AtlasTexture" id="F00"] +atlas = ExtResource("4_facilities") +region = Rect2(0, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F01"] +atlas = ExtResource("4_facilities") +region = Rect2(313.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F02"] +atlas = ExtResource("4_facilities") +region = Rect2(627, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F03"] +atlas = ExtResource("4_facilities") +region = Rect2(940.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F10"] +atlas = ExtResource("4_facilities") +region = Rect2(0, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F11"] +atlas = ExtResource("4_facilities") +region = Rect2(313.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F12"] +atlas = ExtResource("4_facilities") +region = Rect2(627, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F13"] +atlas = ExtResource("4_facilities") +region = Rect2(940.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F20"] +atlas = ExtResource("4_facilities") +region = Rect2(0, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F21"] +atlas = ExtResource("4_facilities") +region = Rect2(313.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F22"] +atlas = ExtResource("4_facilities") +region = Rect2(627, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F23"] +atlas = ExtResource("4_facilities") +region = Rect2(940.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F30"] +atlas = ExtResource("4_facilities") +region = Rect2(0, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F31"] +atlas = ExtResource("4_facilities") +region = Rect2(313.5, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F32"] +atlas = ExtResource("4_facilities") +region = Rect2(627, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F33"] +atlas = ExtResource("4_facilities") +region = Rect2(940.5, 940.5, 313.5, 313.5) + +[sub_resource type="AtlasTexture" id="S00"] +atlas = ExtResource("5_system") +region = Rect2(0, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S01"] +atlas = ExtResource("5_system") +region = Rect2(313.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S02"] +atlas = ExtResource("5_system") +region = Rect2(627, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S03"] +atlas = ExtResource("5_system") +region = Rect2(940.5, 0, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S10"] +atlas = ExtResource("5_system") +region = Rect2(0, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S11"] +atlas = ExtResource("5_system") +region = Rect2(313.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S12"] +atlas = ExtResource("5_system") +region = Rect2(627, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S13"] +atlas = ExtResource("5_system") +region = Rect2(940.5, 313.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S20"] +atlas = ExtResource("5_system") +region = Rect2(0, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S21"] +atlas = ExtResource("5_system") +region = Rect2(313.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S22"] +atlas = ExtResource("5_system") +region = Rect2(627, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S23"] +atlas = ExtResource("5_system") +region = Rect2(940.5, 627, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S30"] +atlas = ExtResource("5_system") +region = Rect2(0, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S31"] +atlas = ExtResource("5_system") +region = Rect2(313.5, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S32"] +atlas = ExtResource("5_system") +region = Rect2(627, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="S33"] +atlas = ExtResource("5_system") +region = Rect2(940.5, 940.5, 313.5, 313.5) + +[resource] +script = ExtResource("1_theme") +pixel_dungeon_tiles = ExtResource("8_pixel_dungeon") +pixel_town_tiles = ExtResource("9_pixel_town") +front_world_background = ExtResource("2_front") +back_world_background = ExtResource("3_back") +world_art_opacity = 0.94 +grid_overlay_opacity = 0.22 +facility_icons = { +"reception": SubResource("F00"), +"standard_room": SubResource("F01"), +"cafeteria": SubResource("F02"), +"warehouse": SubResource("F03"), +"kitchen": SubResource("F10"), +"public_bath": SubResource("F11"), +"staff_waiting_room": SubResource("F12"), +"security_station": SubResource("F13"), +"tavern": SubResource("F20"), +"massage_room": SubResource("F21"), +"guest_elevator": SubResource("F22"), +"service_elevator": SubResource("F23"), +"staff_door": SubResource("F30"), +"guest_corridor": SubResource("F31"), +"service_corridor": SubResource("F32"), +"adult_private_room": SubResource("F33") +} +navigation_icons = { +"staff": SubResource("S00"), +"facility": SubResource("S01"), +"product": SubResource("S02"), +"explore": SubResource("S03"), +"investment": SubResource("S10"), +"ledger": SubResource("S11") +} +resource_icons = { +"gold": SubResource("S20"), +"mana": SubResource("S21"), +"materials": SubResource("S22"), +"faith": SubResource("S23") +} +control_icons = { +"front_layer": SubResource("S12"), +"back_layer": SubResource("S13"), +"alert": SubResource("S30"), +"rotate": SubResource("S31"), +"excavate": SubResource("S32"), +"save": SubResource("S33") +} +guest_actor_markers = { +"default": SubResource("A00"), +"warrior": SubResource("A00"), +"mage": SubResource("A01"), +"rogue": SubResource("A02"), +"cleric": SubResource("A03"), +"noble": SubResource("A10"), +"merchant": SubResource("A11"), +"outsider": SubResource("A12"), +"diplomat": SubResource("A13") +} +staff_actor_markers = { +"default": SubResource("A20"), +"receptionist": SubResource("A20"), +"clerk": SubResource("A20"), +"cook": SubResource("A21"), +"cleaner": SubResource("A22"), +"maintenance": SubResource("A22"), +"porter": SubResource("A23"), +"attendant": SubResource("A30"), +"masseur": SubResource("A30"), +"guard": SubResource("A31"), +"bartender": SubResource("A32"), +"medic": SubResource("A33") +} +ui_skin_textures = { +"panel_outer": SubResource("U00"), +"panel_wood": SubResource("U01"), +"panel_leather": SubResource("U02"), +"panel_parchment": SubResource("U03"), +"button_normal": SubResource("U10"), +"button_hover": SubResource("U11"), +"button_pressed": SubResource("U12"), +"button_disabled": SubResource("U13"), +"selection_frame": SubResource("U20"), +"warning_frame": SubResource("U21"), +"queue_badge": SubResource("U22"), +"construction_hatch": SubResource("U23"), +"guest_route": SubResource("U30"), +"staff_route": SubResource("U31"), +"front_divider": SubResource("U32"), +"back_divider": SubResource("U33") +} +window_background = Color(0.141176, 0.0901961, 0.0588235, 1) +dark_panel = Color(0.419608, 0.243137, 0.113725, 1) +dark_panel_alt = Color(0.317647, 0.188235, 0.0941176, 1) +light_panel = Color(0.956863, 0.890196, 0.741176, 1) +border_gold = Color(0.835294, 0.647059, 0.321569, 1) +button_normal = Color(0.458824, 0.270588, 0.121569, 1) +button_hover = Color(0.552941, 0.337255, 0.156863, 1) +button_pressed = Color(0.286275, 0.396078, 0.156863, 1) +button_disabled = Color(0.341176, 0.262745, 0.203922, 1) +text_light = Color(1, 0.941176, 0.784314, 1) +text_dark = Color(0.333333, 0.215686, 0.117647, 1) +success = Color(0.309804, 0.541176, 0.270588, 1) +warning = Color(0.827451, 0.603922, 0.176471, 1) +danger = Color(0.784314, 0.27451, 0.196078, 1) diff --git a/assets/themes/japanese_fantasy/world-back-v1.png b/assets/themes/japanese_fantasy/world-back-v1.png new file mode 100644 index 0000000..34b3564 Binary files /dev/null and b/assets/themes/japanese_fantasy/world-back-v1.png differ diff --git a/assets/themes/japanese_fantasy/world-back-v1.png.import b/assets/themes/japanese_fantasy/world-back-v1.png.import new file mode 100644 index 0000000..654e108 --- /dev/null +++ b/assets/themes/japanese_fantasy/world-back-v1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bs5u2x022bm8n" +path="res://.godot/imported/world-back-v1.png-d6862f4cec66e6ef96e8cf290e29c026.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/themes/japanese_fantasy/world-back-v1.png" +dest_files=["res://.godot/imported/world-back-v1.png-d6862f4cec66e6ef96e8cf290e29c026.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/themes/japanese_fantasy/world-front-v1.png b/assets/themes/japanese_fantasy/world-front-v1.png new file mode 100644 index 0000000..c07fc00 Binary files /dev/null and b/assets/themes/japanese_fantasy/world-front-v1.png differ diff --git a/assets/themes/japanese_fantasy/world-front-v1.png.import b/assets/themes/japanese_fantasy/world-front-v1.png.import new file mode 100644 index 0000000..39f0626 --- /dev/null +++ b/assets/themes/japanese_fantasy/world-front-v1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxq3fblvp46qg" +path="res://.godot/imported/world-front-v1.png-de2d424e149754233d4d2558f51305dd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/themes/japanese_fantasy/world-front-v1.png" +dest_files=["res://.godot/imported/world-front-v1.png-de2d424e149754233d4d2558f51305dd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd new file mode 100644 index 0000000..5a4e98f --- /dev/null +++ b/autoload/event_bus.gd @@ -0,0 +1,13 @@ +extends Node + +signal layer_changed(layer: int) +signal facility_selected(facility_id: StringName) +signal notification_requested(message: String, severity: int) +signal simulation_event_recorded(event_name: StringName, payload: Dictionary) + +enum Severity { + INFO, + ECONOMY, + WARNING, + CRITICAL, +} diff --git a/autoload/event_bus.gd.uid b/autoload/event_bus.gd.uid new file mode 100644 index 0000000..72f5c6d --- /dev/null +++ b/autoload/event_bus.gd.uid @@ -0,0 +1 @@ +uid://cehcieyj5a8aa diff --git a/autoload/game_clock.gd b/autoload/game_clock.gd new file mode 100644 index 0000000..6f22c74 --- /dev/null +++ b/autoload/game_clock.gd @@ -0,0 +1,40 @@ +extends SimulationKernel + +signal phase_changed(phase: Phase) + +enum Phase { + MORNING, + DAY, + EVENING, + NIGHT, +} + +var _phase := Phase.MORNING + +func _ready() -> void: + _phase = get_phase() + super._ready() + +func reset(start_day := 1, start_hour := 6, start_minute := 0) -> void: + super.reset(start_day, start_hour, start_minute) + _phase = get_phase() + +func advance_one_minute() -> void: + super.advance_one_minute() + var next_phase := get_phase() + if next_phase != _phase: + _phase = next_phase + phase_changed.emit(_phase) + +func get_phase() -> Phase: + var hour := get_hour() + if hour >= 6 and hour < 10: + return Phase.MORNING + if hour >= 10 and hour < 16: + return Phase.DAY + if hour >= 16 and hour < 20: + return Phase.EVENING + return Phase.NIGHT + +func formatted_time() -> String: + return "Day %d %02d:%02d" % [day, get_hour(), get_minute()] diff --git a/autoload/game_clock.gd.uid b/autoload/game_clock.gd.uid new file mode 100644 index 0000000..6f465e0 --- /dev/null +++ b/autoload/game_clock.gd.uid @@ -0,0 +1 @@ +uid://cw7yxcjf1k5hy diff --git a/autoload/game_state.gd b/autoload/game_state.gd new file mode 100644 index 0000000..ab6083d --- /dev/null +++ b/autoload/game_state.gd @@ -0,0 +1,112 @@ +extends Node + +signal resource_changed(resource_name: StringName, value: int) +signal state_reset +signal revision_changed(revision: int) +signal world_tree_rank_changed(rank: int) + +const DEFAULT_RESOURCES := { + &"gold": 5000, + &"mana": 100, + &"materials": 50, + &"faith": 0, +} + +var resources: Dictionary = DEFAULT_RESOURCES.duplicate(true) +var world_tree_rank := 1 +var active_layer := 0 +var revision := 0 + +func reset() -> void: + resources = DEFAULT_RESOURCES.duplicate(true) + world_tree_rank = 1 + active_layer = 0 + revision = 0 + state_reset.emit() + for resource_name: StringName in resources: + resource_changed.emit(resource_name, resources[resource_name]) + +func get_resource(resource_name: StringName) -> int: + return int(resources.get(resource_name, 0)) + +func set_resource(resource_name: StringName, value: int) -> void: + var next_value := maxi(value, 0) + if get_resource(resource_name) == next_value: + return + resources[resource_name] = next_value + _touch_revision() + resource_changed.emit(resource_name, resources[resource_name]) + +func add_resource(resource_name: StringName, amount: int) -> int: + set_resource(resource_name, get_resource(resource_name) + amount) + return get_resource(resource_name) + +func can_afford(cost: Dictionary) -> bool: + for resource_name: Variant in cost: + if get_resource(StringName(resource_name)) < int(cost[resource_name]): + return false + return true + +func spend(cost: Dictionary) -> bool: + if not can_afford(cost): + return false + var deltas := {} + for resource_name: Variant in cost: + deltas[StringName(resource_name)] = -int(cost[resource_name]) + return apply_resource_deltas(deltas) + +func apply_resource_deltas(deltas: Dictionary) -> bool: + var next_values := {} + for resource_name: Variant in deltas: + var normalized_name := StringName(resource_name) + var next_value := get_resource(normalized_name) + int(deltas[resource_name]) + if next_value < 0: + return false + next_values[normalized_name] = next_value + if next_values.is_empty(): + return true + for resource_name: Variant in next_values: + resources[resource_name] = next_values[resource_name] + _touch_revision() + for resource_name: Variant in next_values: + resource_changed.emit(resource_name, int(next_values[resource_name])) + return true + +func set_world_tree_rank(value: int) -> bool: + if value < world_tree_rank or value < 1 or value > 7: + return false + if value == world_tree_rank: + return true + world_tree_rank = value + _touch_revision() + world_tree_rank_changed.emit(world_tree_rank) + return true + +func create_snapshot() -> Dictionary: + return { + &"resources": resources.duplicate(true), + &"world_tree_rank": world_tree_rank, + &"active_layer": active_layer, + &"revision": revision, + } + +func restore_snapshot(snapshot: Dictionary) -> bool: + if not snapshot.has(&"resources") or not snapshot[&"resources"] is Dictionary: + return false + var restored_resources: Dictionary = snapshot[&"resources"] + for resource_name: Variant in restored_resources: + if int(restored_resources[resource_name]) < 0: + return false + resources = restored_resources.duplicate(true) + world_tree_rank = maxi(int(snapshot.get(&"world_tree_rank", 1)), 1) + active_layer = clampi(int(snapshot.get(&"active_layer", 0)), 0, 1) + revision = maxi(int(snapshot.get(&"revision", 0)), 0) + state_reset.emit() + for resource_name: Variant in resources: + resource_changed.emit(StringName(resource_name), int(resources[resource_name])) + revision_changed.emit(revision) + return true + +func _touch_revision() -> void: + revision += 1 + revision_changed.emit(revision) diff --git a/autoload/game_state.gd.uid b/autoload/game_state.gd.uid new file mode 100644 index 0000000..ee7a443 --- /dev/null +++ b/autoload/game_state.gd.uid @@ -0,0 +1 @@ +uid://150dda175byk diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd new file mode 100644 index 0000000..f22dea1 --- /dev/null +++ b/autoload/simulation_runtime.gd @@ -0,0 +1,10971 @@ +class_name SimulationRuntimeService +extends Node + +signal command_completed(result: CommandResult) +signal snapshot_published(snapshot: Dictionary, digest: String) +signal definition_validation_failed(report: Dictionary) +signal save_operation_completed(result: Dictionary) + +const DEFAULT_WORLD_SEED := 20260827 +const STARTER_MANA_OUTPUT := 100 +const STARTER_MANA_SOURCE := &"world_tree.mana_source" +const STARTER_MANA_BUS := &"world_tree.mana_bus" +const PLAYER_CASH_ACCOUNT := &"PLAYER_CASH" +const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE" +const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE" +const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE" +const WAGE_EXPENSE_ACCOUNT := &"WAGE_EXPENSE" +const RUNTIME_SNAPSHOT_VERSION := 6 +const LEGACY_RUNTIME_SNAPSHOT_VERSION := 1 +const DEFENSE_RUNTIME_SNAPSHOT_VERSION := 2 +const PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION := 3 +const CONTENT_RUNTIME_SNAPSHOT_VERSION := 4 +const OPERATIONS_RUNTIME_SNAPSHOT_VERSION := 5 +const MVP_CONTENT_MANIFEST_VERSION := 1 +const OPERATIONS_RUNTIME_WRAPPER_VERSION := 1 +const OPERATIONS_START_MINUTE := 360 +const OPERATIONS_COORDINATOR_ORIGIN_MINUTE := 359 +const MAX_PERSONAL_ACTION_POINTS := 4 +const OPERATIONS_TEST_PREPARE_PHASE: StringName = &"PREPARE" +const OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE: StringName = &"AFTER_FIRST_MUTATION" +const DEFAULT_OPERATIONS_DEMAND_PROFILES := [ + {&"facility_tag": &"hygiene", &"guest_class": &"cleric", &"time_band": &"MORNING"}, + {&"facility_tag": &"service", &"guest_class": &"diplomat", &"time_band": &"MORNING"}, + {&"facility_tag": &"lodging", &"guest_class": &"mage", &"time_band": &"DAY"}, + {&"facility_tag": &"service", &"guest_class": &"merchant", &"time_band": &"DAY"}, + {&"facility_tag": &"lodging", &"guest_class": &"noble", &"time_band": &"EVENING"}, + {&"facility_tag": &"dining", &"guest_class": &"warrior", &"time_band": &"EVENING"}, + {&"facility_tag": &"leisure", &"guest_class": &"outsider", &"time_band": &"NIGHT"}, + {&"facility_tag": &"underground", &"guest_class": &"rogue", &"time_band": &"NIGHT"}, +] +const MOVEMENT_CREDIT_SCALE := 1_000 +const MIN_MOVEMENT_CREDIT_PER_MINUTE := 250 +const DEFAULT_AUTOSAVE_PATH := "user://saves/autosave" +const SAVE_LIFECYCLE_VERSION := 1 +const DEFAULT_SAVE_POLICY := { + &"enabled": false, + &"daily_checkpoint_enabled": true, + &"autosave_path": DEFAULT_AUTOSAVE_PATH, + &"autosave_generations": SaveService.DEFAULT_AUTOSAVE_GENERATIONS, + &"auto_migrate": true, +} +const RNG_STREAM_IDS: Array[StringName] = [ + &"world", + &"guest_spawn", + &"guest_decision", + &"staff", + &"relationship", + &"authority", + &"defense", + &"event", + &"loot", +] +const DEFENSE_COMMAND_TYPES: Array[StringName] = [ + &"create_defense_raid", + &"register_defense_combatant", + &"register_defense_asset", + &"start_defense_raid", + &"set_defense_retreat_policy", + &"queue_defense_skill", + &"resolve_defense_round", + &"begin_next_defense_wave", + &"retreat_defenders", + &"cancel_defense_raid", +] + +var definition_registry := DefinitionRegistry.new() +var command_queue := CommandQueue.new() +var id_generator := StableIdGenerator.new() +var domain_events := DomainEventStream.new() +var world_service := WorldService.new() +var path_service := PathService.new() +var mana_network_service := ManaNetworkService.new() +var economy_service: EconomyService +var construction_resource_state: LedgerBackedResourceState +var operating_resource_state: LedgerBackedResourceState +var inventory_service := InventoryService.new() +var logistics_dispatcher := LogisticsDispatcher.new() +var logistics_requests: Dictionary = {} +var facility_service := FacilityService.new() +var guest_service := GuestService.new() +var staff_service := StaffService.new() +var mvp_content_catalog := MvpContentCatalog.new() +var mvp_content_report: Dictionary = {} +var progression_service := ProgressionService.new() +var operations_coordinator: OperationsCoordinator +var relationship_service := RelationshipService.new() +var event_service := EventService.new() +var authority_service := AuthorityService.new() +var defense_service := DefenseService.new() +var construction_service: ConstructionService +var excavation_service: ExcavationService +var spatial_subslot_service: SpatialSubslotService +var route_subslot_path_adapter: RouteSubslotPathAdapter +var facility_infrastructure_projector: FacilityInfrastructureProjector +var latest_snapshot: Dictionary = {} +var latest_digest := "" +var latest_save_operation_result: Dictionary = {} +var latest_restore_safety_report: Dictionary = {} +var world_seed := DEFAULT_WORLD_SEED +var save_migration_registry := SaveMigrationRegistry.new() + +var _clock: SimulationKernel +var _state: Variant +var _rng_streams: Dictionary = {} +var _executors: Dictionary = {} +var _next_command_id := 1 +var _staffing_projection_enabled := false +var _movement_credits: Dictionary = {} +var _hired_staff_candidate_ids: Dictionary = {} +var _save_policy: Dictionary = DEFAULT_SAVE_POLICY.duplicate(true) +var _last_daily_checkpoint_day := -1 +var _pending_checkpoints: Array[Dictionary] = [] +var _next_checkpoint_request_id := 1 +var _initialized := false +var _active_command_minute := -1 +var _operations_enabled := false +var _operations_tick_blocked := false +var _operations_runtime_failure_latched := false +var _operations_runtime_failure_report: Dictionary = {} +var _operations_deferred_runtime_minute := -1 +var _operations_resume_publish_pending := false +var _personal_action_points := 0 +var _operations_test_boundary_hooks: Dictionary = {} +var _operations_transaction_active := false +var _operations_queued_domain_events: Array[SimulationDomainEvent] = [] +var _operations_queued_guest_spawn_notifications: Array[StringName] = [] +var _operations_flushing_guest_spawn_notifications: Dictionary = {} +var _operations_queued_economy_notifications: Array[StringName] = [] +var _operations_flushing_economy_notifications: Dictionary = {} +var _infrastructure_failure_latched := false +var _infrastructure_failure_report: Dictionary = {} +var _construction_completion_transaction_active := false +var _construction_committing_notifications := false +var _construction_queued_domain_events: Array[SimulationDomainEvent] = [] + +func _ready() -> void: + # The autoload is also present in headless test SceneTrees, so merely entering + # the tree must never opt into disk writes. The playable main scene enables + # the production policy explicitly. + if not _initialized: + initialize(GameClock, GameState, DEFAULT_WORLD_SEED) + +func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_SEED) -> Dictionary: + if _initialized and _clock != null: + _clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.operations") + _clock.unregister_tick_handler(_clock.TickPhase.APPLY_COMMANDS, &"simulation_runtime.commands") + _clock.unregister_tick_handler(_clock.TickPhase.PUBLISH_SNAPSHOT, &"simulation_runtime.snapshot") + _clock.unregister_tick_handler(_clock.TickPhase.CONSTRUCTION, &"simulation_runtime.construction") + _clock.unregister_tick_handler(_clock.TickPhase.MANA, &"simulation_runtime.mana") + _clock.unregister_tick_handler(_clock.TickPhase.LOGISTICS, &"simulation_runtime.logistics") + _clock.unregister_tick_handler(_clock.TickPhase.FACILITY_COMPLETION, &"simulation_runtime.facilities") + _clock.unregister_tick_handler(_clock.TickPhase.VITALS_AND_ENVIRONMENT, &"simulation_runtime.guest_vitals") + _clock.unregister_tick_handler(_clock.TickPhase.FACILITY_QUEUES, &"simulation_runtime.guest_queues") + _clock.unregister_tick_handler(_clock.TickPhase.INCIDENTS_AND_EVENTS, &"simulation_runtime.authority_incidents") + _clock.unregister_tick_handler(_clock.TickPhase.INCIDENTS_AND_EVENTS, &"simulation_runtime.defense") + _clock.unregister_tick_handler(_clock.TickPhase.INCIDENTS_AND_EVENTS, &"simulation_runtime.relationship_events") + _clock.unregister_tick_handler(_clock.TickPhase.AI_DECISIONS, &"simulation_runtime.staff_ai") + _clock.unregister_tick_handler(_clock.TickPhase.PUBLISH_SNAPSHOT, &"simulation_runtime.zz_save_checkpoints") + _detach_defense_service(defense_service) + _clock = clock + _state = state + world_seed = seed + mvp_content_catalog = MvpContentCatalog.new() + mvp_content_report = mvp_content_catalog.rebuild() + progression_service = ProgressionService.new() + var initial_progression_ok := progression_service.configure(1, { + &"asset_gold": maxi(int(_state.get_resource(&"gold")), 0), + &"faith_lifetime": maxi(int(_state.get_resource(&"faith")), 0), + }) + _hired_staff_candidate_ids.clear() + command_queue = CommandQueue.new() + id_generator = StableIdGenerator.new() + domain_events = DomainEventStream.new() + world_service = WorldService.new() + path_service = PathService.new() + _movement_credits.clear() + _connect_path_performance_signal() + mana_network_service = ManaNetworkService.new() + _initialize_world(maxi(int(_state.world_tree_rank), 1)) + _initialize_mana_network() + _initialize_economy() + _initialize_s2_operations() + _staffing_projection_enabled = false + _initialize_s3_actors() + _initialize_s4_relationship_events() + _initialize_s5_authority() + _initialize_s5_defense() + construction_service = ConstructionService.new(world_service, construction_resource_state) + construction_service.current_minute = _clock.absolute_minute + _connect_construction_signals() + var initial_infrastructure_ok := _initialize_infrastructure_services() + _next_command_id = 1 + _last_daily_checkpoint_day = -1 + _pending_checkpoints.clear() + _next_checkpoint_request_id = 1 + _active_command_minute = -1 + operations_coordinator = null + _operations_enabled = false + _operations_tick_blocked = false + _operations_runtime_failure_latched = false + _operations_runtime_failure_report.clear() + _operations_deferred_runtime_minute = -1 + _operations_resume_publish_pending = false + _personal_action_points = 0 + _operations_test_boundary_hooks.clear() + _operations_transaction_active = false + _operations_queued_domain_events.clear() + _operations_queued_guest_spawn_notifications.clear() + _operations_flushing_guest_spawn_notifications.clear() + _operations_queued_economy_notifications.clear() + _operations_flushing_economy_notifications.clear() + _infrastructure_failure_latched = false + _infrastructure_failure_report.clear() + _construction_completion_transaction_active = false + _construction_committing_notifications = false + _construction_queued_domain_events.clear() + latest_save_operation_result.clear() + latest_restore_safety_report.clear() + _initialize_rng_streams() + _executors.clear() + register_command_executor(&"change_resources", Callable(self, "_execute_change_resources")) + register_command_executor(&"spend_resources", Callable(self, "_execute_spend_resources")) + register_command_executor(&"build_facility", Callable(self, "_execute_build_facility")) + register_command_executor(&"cancel_construction", Callable(self, "_execute_cancel_construction")) + register_command_executor(&"demolish_facility", Callable(self, "_execute_demolish_facility")) + register_command_executor(&"start_excavation", Callable(self, "_execute_start_excavation")) + register_command_executor(&"pause_excavation", Callable(self, "_execute_pause_excavation")) + register_command_executor(&"resume_excavation", Callable(self, "_execute_resume_excavation")) + register_command_executor(&"set_world_tree_rank", Callable(self, "_execute_set_world_tree_rank")) + register_command_executor(&"update_progression_metrics", Callable(self, "_execute_update_progression_metrics")) + register_command_executor(&"complete_progression_quest", Callable(self, "_execute_complete_progression_quest")) + register_command_executor(&"acknowledge_operations_pause", Callable(self, "_execute_acknowledge_operations_pause")) + register_command_executor(&"add_inventory_stack", Callable(self, "_execute_add_inventory_stack")) + register_command_executor(&"create_logistics_request", Callable(self, "_execute_create_logistics_request")) + register_command_executor(&"dispatch_logistics", Callable(self, "_execute_dispatch_logistics")) + register_command_executor(&"advance_logistics_request", Callable(self, "_execute_advance_logistics_request")) + register_command_executor(&"register_guest_wallet", Callable(self, "_execute_register_guest_wallet")) + register_command_executor(&"create_service_request", Callable(self, "_execute_create_service_request")) + register_command_executor(&"set_facility_conditions", Callable(self, "_execute_set_facility_conditions")) + register_command_executor(&"set_facility_policy", Callable(self, "_execute_set_facility_policy")) + register_command_executor(&"leave_facility_queue", Callable(self, "_execute_leave_facility_queue")) + register_command_executor(&"spawn_guest", Callable(self, "_execute_spawn_guest")) + register_command_executor(&"transition_guest", Callable(self, "_execute_transition_guest")) + register_command_executor(&"decide_guest", Callable(self, "_execute_decide_guest")) + register_command_executor(&"register_staff", Callable(self, "_execute_register_staff")) + register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate")) + register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift")) + register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override")) + register_command_executor(&"create_staff_task", Callable(self, "_execute_create_staff_task")) + register_command_executor(&"begin_staff_task", Callable(self, "_execute_begin_staff_task")) + register_command_executor(&"progress_staff_task", Callable(self, "_execute_progress_staff_task")) + register_command_executor(&"create_relationship", Callable(self, "_execute_create_relationship")) + register_command_executor(&"change_relationship", Callable(self, "_execute_change_relationship")) + register_command_executor(&"attack_relationship_shield", Callable(self, "_execute_attack_relationship_shield")) + register_command_executor(&"request_route_lock", Callable(self, "_execute_request_route_lock")) + register_command_executor(&"confirm_route_lock", Callable(self, "_execute_confirm_route_lock")) + register_command_executor(&"register_event_definition", Callable(self, "_execute_register_event_definition")) + register_command_executor(&"enqueue_event", Callable(self, "_execute_enqueue_event")) + register_command_executor(&"reserve_event_choice_cost", Callable(self, "_execute_reserve_event_choice_cost")) + register_command_executor(&"submit_event_choice", Callable(self, "_execute_submit_event_choice")) + register_command_executor(&"report_authority_incident", Callable(self, "_execute_report_authority_incident")) + register_command_executor(&"register_authority_evidence", Callable(self, "_execute_register_authority_evidence")) + register_command_executor(&"discover_authority_evidence", Callable(self, "_execute_discover_authority_evidence")) + register_command_executor(&"evaluate_authority_inspection", Callable(self, "_execute_evaluate_authority_inspection")) + register_command_executor(&"schedule_authority_inspection", Callable(self, "_execute_schedule_authority_inspection")) + register_command_executor(&"start_authority_inspection", Callable(self, "_execute_start_authority_inspection")) + register_command_executor(&"arrive_authority_inspection", Callable(self, "_execute_arrive_authority_inspection")) + register_command_executor(&"plan_authority_inspection", Callable(self, "_execute_plan_authority_inspection")) + register_command_executor(&"advance_authority_inspection", Callable(self, "_execute_advance_authority_inspection")) + register_command_executor(&"inspect_authority_waypoint", Callable(self, "_execute_inspect_authority_waypoint")) + register_command_executor(&"resolve_authority_inspection", Callable(self, "_execute_resolve_authority_inspection")) + register_command_executor(&"depart_authority_inspection", Callable(self, "_execute_depart_authority_inspection")) + register_command_executor(&"create_defense_raid", Callable(self, "_execute_create_defense_raid")) + register_command_executor(&"register_defense_combatant", Callable(self, "_execute_register_defense_combatant")) + register_command_executor(&"register_defense_asset", Callable(self, "_execute_register_defense_asset")) + register_command_executor(&"start_defense_raid", Callable(self, "_execute_start_defense_raid")) + register_command_executor(&"set_defense_retreat_policy", Callable(self, "_execute_set_defense_retreat_policy")) + register_command_executor(&"queue_defense_skill", Callable(self, "_execute_queue_defense_skill")) + register_command_executor(&"resolve_defense_round", Callable(self, "_execute_resolve_defense_round")) + register_command_executor(&"begin_next_defense_wave", Callable(self, "_execute_begin_next_defense_wave")) + register_command_executor(&"retreat_defenders", Callable(self, "_execute_retreat_defenders")) + register_command_executor(&"cancel_defense_raid", Callable(self, "_execute_cancel_defense_raid")) + _clock.register_tick_handler( + _clock.TickPhase.APPLY_COMMANDS, + &"simulation_runtime.commands", + Callable(self, "_process_tick_commands") + ) + _clock.register_tick_handler( + _clock.TickPhase.PUBLISH_SNAPSHOT, + &"simulation_runtime.snapshot", + Callable(self, "_publish_snapshot") + ) + _clock.register_tick_handler( + _clock.TickPhase.MANA, + &"simulation_runtime.mana", + Callable(self, "_advance_mana") + ) + _clock.register_tick_handler( + _clock.TickPhase.CONSTRUCTION, + &"simulation_runtime.construction", + Callable(self, "_advance_construction") + ) + _clock.register_tick_handler( + _clock.TickPhase.LOGISTICS, + &"simulation_runtime.logistics", + Callable(self, "_advance_inventory_and_logistics") + ) + _clock.register_tick_handler( + _clock.TickPhase.FACILITY_COMPLETION, + &"simulation_runtime.facilities", + Callable(self, "_advance_facilities") + ) + _clock.register_tick_handler( + _clock.TickPhase.VITALS_AND_ENVIRONMENT, + &"simulation_runtime.guest_vitals", + Callable(self, "_advance_guest_vitals") + ) + _clock.register_tick_handler( + _clock.TickPhase.FACILITY_QUEUES, + &"simulation_runtime.guest_queues", + Callable(self, "_advance_guest_queues") + ) + _clock.register_tick_handler( + _clock.TickPhase.INCIDENTS_AND_EVENTS, + &"simulation_runtime.authority_incidents", + Callable(self, "_advance_authority_incidents") + ) + _clock.register_tick_handler( + _clock.TickPhase.INCIDENTS_AND_EVENTS, + &"simulation_runtime.defense", + Callable(self, "_advance_defense") + ) + _clock.register_tick_handler( + _clock.TickPhase.INCIDENTS_AND_EVENTS, + &"simulation_runtime.relationship_events", + Callable(self, "_advance_relationship_events") + ) + _clock.register_tick_handler( + _clock.TickPhase.AI_DECISIONS, + &"simulation_runtime.staff_ai", + Callable(self, "_advance_staff_ai") + ) + # The `zz` ID deliberately orders persistence after the published simulation + # snapshot at the final phase of a fully completed minute. + _clock.register_tick_handler( + _clock.TickPhase.PUBLISH_SNAPSHOT, + &"simulation_runtime.zz_save_checkpoints", + Callable(self, "_process_save_checkpoints") + ) + _initialized = true + var report := definition_registry.rebuild() + var catalog_registration_ok := _catalog_definitions_are_registered(guest_service) + report[&"content_catalog"] = mvp_content_report.duplicate(true) + report[&"progression_initialized"] = initial_progression_ok + report[&"catalog_guest_definitions_registered"] = catalog_registration_ok + report[&"infrastructure_initialized"] = initial_infrastructure_ok + report[&"valid"] = bool(report[&"valid"]) \ + and bool(mvp_content_report.get(&"valid", false)) \ + and catalog_registration_ok \ + and initial_progression_ok \ + and initial_infrastructure_ok \ + and int(_state.world_tree_rank) == ProgressionService.MIN_RANK + if not report[&"valid"]: + _clock.set_paused(true) + definition_validation_failed.emit(report) + _publish_snapshot(_clock.absolute_minute) + return report + +func register_command_executor(command_type: StringName, executor: Callable) -> bool: + if command_type.is_empty() or not executor.is_valid() or _executors.has(command_type): + return false + _executors[command_type] = executor + return true + +func unregister_command_executor(command_type: StringName) -> void: + _executors.erase(command_type) + +func submit_command( + command_type: StringName, + payload: Dictionary = {}, + actor_id: StringName = &"player", + target_id: StringName = &"", + priority := 0, + execute_at := -1, + idempotency_key: StringName = &"" +) -> CommandResult: + if not _initialized: + return CommandResult.failed(CommandResult.Code.EXECUTOR_FAILED, 0, "Simulation runtime is not initialized.") + var scheduled_tick := _clock.absolute_minute if execute_at < 0 else execute_at + var command := SimulationCommand.new( + _next_command_id, + _clock.absolute_minute, + scheduled_tick, + priority, + command_type, + actor_id, + target_id, + payload, + idempotency_key + ) + var enqueue_result := command_queue.enqueue(command) + if not enqueue_result.is_success(): + return enqueue_result + _next_command_id += 1 + if _clock.paused and scheduled_tick <= _clock.absolute_minute: + var resume_snapshot_published := _process_commands(_clock.absolute_minute) + if not resume_snapshot_published: + _publish_snapshot(_clock.absolute_minute) + var completed := command_queue.get_processed_result(command.command_id) + if completed != null: + return completed + return enqueue_result + +func get_rng_stream(stream_id: StringName) -> DeterministicRng: + return _rng_streams.get(stream_id) as DeterministicRng + +func create_snapshot() -> Dictionary: + var rng_data := {} + var stream_ids: Array = _rng_streams.keys() + stream_ids.sort() + for stream_id: Variant in stream_ids: + var stream: DeterministicRng = _rng_streams[stream_id] + rng_data[String(stream_id)] = stream.snapshot() + return SimulationSnapshot.capture(_clock, _state, { + &"runtime": { + &"runtime_schema_version": RUNTIME_SNAPSHOT_VERSION, + &"world_seed": world_seed, + &"next_command_id": _next_command_id, + &"staffing_projection_enabled": _staffing_projection_enabled, + &"save_lifecycle": { + &"version": SAVE_LIFECYCLE_VERSION, + &"last_daily_checkpoint_day": _last_daily_checkpoint_day, + }, + &"command_queue": command_queue.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + &"domain_events": domain_events.create_snapshot(), + &"rng_streams": rng_data, + &"world": world_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"movement_credits": _serialize_movement_credits(), + &"mana": mana_network_service.create_snapshot(), + &"economy": economy_service.create_snapshot(), + &"inventory": inventory_service.create_snapshot(), + &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), + &"logistics_requests": _serialize_logistics_requests(), + &"facilities": facility_service.create_snapshot(), + &"guests": guest_service.create_snapshot(), + &"staff": staff_service.create_snapshot(), + &"catalog_manifest": _create_mvp_content_manifest(mvp_content_catalog), + &"progression": progression_service.create_snapshot(), + &"hired_staff_candidate_ids": _serialize_hired_staff_candidate_ids(), + &"operations": _create_operations_runtime_snapshot(), + &"relationships": relationship_service.create_snapshot(), + &"event_definitions": _serialize_event_definitions(), + &"events": event_service.create_snapshot(), + &"authority": authority_service.create_snapshot(), + &"defense": defense_service.create_snapshot(), + &"construction": construction_service.create_snapshot(), + &"excavation": excavation_service.create_snapshot(), + &"spatial_subslots": spatial_subslot_service.create_snapshot(), + &"route_projection": route_subslot_path_adapter.create_snapshot(), + &"facility_infrastructure": facility_infrastructure_projector.create_snapshot(), + }, + }) + +func restore_snapshot(snapshot: Dictionary) -> bool: + return _restore_snapshot_internal(snapshot, false) + + +func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bool: + var active_filter_profile := event_service.get_content_filter_profile() + var staff_repair_report := { + &"automatic_repairs_applied": false, + &"reopened_task_ids": [], + &"released_staff_ids": [], + &"release_failed_task_ids": [], + } + var event_lock_repair_report := { + &"automatic_repairs_applied": false, + &"released_actor_ids": [], + &"released_foreground_instance_id": &"", + &"reason": &"NONE", + } + var validation := SimulationSnapshot.validate(snapshot) + if not validation[&"ok"]: + return false + var systems: Variant = snapshot.get(&"systems", {}) + if not systems is Dictionary or not systems.has(&"runtime") or not systems[&"runtime"] is Dictionary: + return false + var runtime_data: Dictionary = systems[&"runtime"] + var restored_minute := int(snapshot[&"clock"].get(&"absolute_minute", -1)) + if restored_minute < 0: + return false + var runtime_schema_version_value: Variant = runtime_data.get( + &"runtime_schema_version", + runtime_data.get("runtime_schema_version", LEGACY_RUNTIME_SNAPSHOT_VERSION) + ) + if typeof(runtime_schema_version_value) != TYPE_INT: + return false + var runtime_schema_version := int(runtime_schema_version_value) + if runtime_schema_version not in [ + LEGACY_RUNTIME_SNAPSHOT_VERSION, + DEFENSE_RUNTIME_SNAPSHOT_VERSION, + PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION, + CONTENT_RUNTIME_SNAPSHOT_VERSION, + OPERATIONS_RUNTIME_SNAPSHOT_VERSION, + RUNTIME_SNAPSHOT_VERSION, + ]: + return false + if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION \ + and not _legacy_operations_history_is_absent(runtime_data): + return false + if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION \ + and not _legacy_infrastructure_history_is_absent(runtime_data): + return false + if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION: + if not _runtime_v5_outer_snapshot_shape_is_valid(snapshot): + return false + var runtime_current_keys: Array[StringName] = [ + &"runtime_schema_version", &"world_seed", &"next_command_id", + &"staffing_projection_enabled", &"save_lifecycle", &"command_queue", + &"id_generator", &"domain_events", &"rng_streams", &"world", &"path", + &"movement_credits", &"mana", &"economy", &"inventory", + &"logistics_dispatcher", &"logistics_requests", &"facilities", &"guests", + &"staff", &"catalog_manifest", &"progression", &"hired_staff_candidate_ids", + &"operations", &"relationships", &"event_definitions", &"events", + &"authority", &"defense", &"construction", + ] + if runtime_schema_version == RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append_array([ + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", + ]) + if not _dictionary_has_exact_canonical_keys(runtime_data, runtime_current_keys) \ + or typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \ + or typeof(runtime_data.get(&"world_seed", null)) != TYPE_INT \ + or typeof(runtime_data.get(&"next_command_id", null)) != TYPE_INT \ + or typeof(runtime_data.get(&"staffing_projection_enabled", null)) != TYPE_BOOL: + return false + var has_defense_snapshot := runtime_data.has(&"defense") or runtime_data.has("defense") + if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION and not has_defense_snapshot: + return false + if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION \ + and not runtime_data.get(&"movement_credits", null) is Array: + return false + if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION \ + and (not runtime_data.get(&"catalog_manifest", null) is Dictionary \ + or not runtime_data.get(&"progression", null) is Dictionary \ + or not runtime_data.get(&"hired_staff_candidate_ids", null) is Array): + return false + if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \ + and not runtime_data.get(&"operations", null) is Dictionary: + return false + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + for infrastructure_key: StringName in [ + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", + ]: + if not runtime_data.get(infrastructure_key, null) is Dictionary: + return false + var restored_catalog := MvpContentCatalog.new() + var restored_catalog_report := restored_catalog.rebuild() + if not bool(restored_catalog_report.get(&"valid", false)): + return false + if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION \ + and not _strict_variants_equal( + _create_mvp_content_manifest(restored_catalog), + runtime_data[&"catalog_manifest"] + ): + return false + var restored_last_daily_checkpoint_day := -1 + var save_lifecycle_value: Variant = runtime_data.get(&"save_lifecycle", {}) + if not save_lifecycle_value is Dictionary: + return false + var save_lifecycle: Dictionary = save_lifecycle_value + if not save_lifecycle.is_empty(): + if (runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \ + and (not _dictionary_has_exact_canonical_keys( + save_lifecycle, [&"version", &"last_daily_checkpoint_day"] + ) \ + or typeof(save_lifecycle.get(&"version", null)) != TYPE_INT \ + or typeof(save_lifecycle.get(&"last_daily_checkpoint_day", null)) != TYPE_INT)) \ + or int(save_lifecycle.get(&"version", -1)) != SAVE_LIFECYCLE_VERSION: + return false + restored_last_daily_checkpoint_day = int( + save_lifecycle.get(&"last_daily_checkpoint_day", -1) + ) + if restored_last_daily_checkpoint_day < -1: + return false + if not runtime_data.get(&"command_queue", null) is Dictionary \ + or not runtime_data.get(&"id_generator", null) is Dictionary \ + or not runtime_data.get(&"domain_events", null) is Dictionary \ + or not runtime_data.get(&"rng_streams", null) is Dictionary \ + or not runtime_data.get(&"world", null) is Dictionary \ + or not runtime_data.get(&"path", null) is Dictionary \ + or not runtime_data.get(&"mana", null) is Dictionary \ + or not runtime_data.get(&"economy", null) is Dictionary \ + or not runtime_data.get(&"inventory", null) is Dictionary \ + or not runtime_data.get(&"logistics_dispatcher", null) is Dictionary \ + or not runtime_data.get(&"logistics_requests", null) is Array \ + or not runtime_data.get(&"facilities", null) is Dictionary \ + or not runtime_data.get(&"guests", null) is Dictionary \ + or not runtime_data.get(&"staff", null) is Dictionary \ + or not runtime_data.get(&"relationships", null) is Dictionary \ + or not runtime_data.get(&"event_definitions", null) is Array \ + or not runtime_data.get(&"events", null) is Dictionary \ + or not runtime_data.get(&"authority", null) is Dictionary \ + or (has_defense_snapshot and not runtime_data.get(&"defense", null) is Dictionary) \ + or not runtime_data.get(&"construction", null) is Dictionary: + return false + var restored_queue := CommandQueue.new() + if not restored_queue.restore_snapshot(runtime_data[&"command_queue"]): + return false + var restored_id_generator := StableIdGenerator.new() + if not restored_id_generator.restore_snapshot(runtime_data[&"id_generator"]): + return false + var restored_domain_events := DomainEventStream.new() + if not restored_domain_events.restore_snapshot(runtime_data[&"domain_events"]): + return false + if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \ + and not _runtime_command_history_crosslinks_are_valid( + runtime_data[&"command_queue"], + int(runtime_data[&"next_command_id"]), + restored_domain_events + ): + return false + var restored_world := WorldService.new() + if not restored_world.restore_snapshot(runtime_data[&"world"]): + return false + var restored_path := PathService.new() + var restored_spatial := SpatialSubslotService.new() + var restored_route_adapter := RouteSubslotPathAdapter.new() + var restored_infrastructure_projector := FacilityInfrastructureProjector.new() + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + # Configure against an empty Spatial source before any managed namespace is + # present. Then restore the source, rebuild the base+ROUTE topology, and + # finally restore Path traffic/actors and the two metadata owners. + if not restored_path.rebuild_graph(restored_world.get_walkable_cells()) \ + or not restored_route_adapter.configure( + restored_spatial, restored_path, restored_world.get_walkable_cells() + ) \ + or not restored_infrastructure_projector.configure( + restored_route_adapter, restored_spatial, restored_path + ) \ + or not restored_spatial.restore_snapshot(runtime_data[&"spatial_subslots"]): + return false + var prepared_projection := restored_route_adapter.synchronize_projection( + &"runtime.v6.restore.prepare", + restored_world.get_walkable_cells() + ) + if not prepared_projection.is_success() \ + or not _path_snapshot_cells_match_world( + runtime_data[&"path"], + restored_route_adapter.get_projected_walkable_cells() + ) \ + or not restored_path.restore_snapshot(runtime_data[&"path"]) \ + or not restored_route_adapter.restore_snapshot(runtime_data[&"route_projection"]) \ + or not restored_infrastructure_projector.restore_snapshot( + runtime_data[&"facility_infrastructure"] + ): + return false + else: + if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION \ + and not _path_snapshot_cells_match_world( + runtime_data[&"path"], + restored_world.get_walkable_cells() + ): + return false + if not restored_path.restore_snapshot(runtime_data[&"path"]) \ + or not restored_path.rebuild_graph(restored_world.get_walkable_cells()) \ + or not restored_route_adapter.configure( + restored_spatial, restored_path, restored_world.get_walkable_cells() + ) \ + or not restored_infrastructure_projector.configure( + restored_route_adapter, restored_spatial, restored_path + ): + return false + var restored_mana := ManaNetworkService.new() + if not restored_mana.restore_snapshot(runtime_data[&"mana"]): + return false + var restored_economy := EconomyService.new() + if not restored_economy.restore_snapshot(runtime_data[&"economy"]): + return false + if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION: + if not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT) \ + and not restored_economy.register_account(WAGE_EXPENSE_ACCOUNT): + return false + elif not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT): + return false + var saved_resources: Variant = snapshot[&"game_state"].get(&"resources", null) + if not saved_resources is Dictionary \ + or int(saved_resources.get(&"gold", saved_resources.get("gold", -1))) \ + != restored_economy.get_balance(PLAYER_CASH_ACCOUNT): + return false + var restored_inventory := InventoryService.new() + if not restored_inventory.restore_snapshot(runtime_data[&"inventory"]): + return false + var restored_dispatcher := LogisticsDispatcher.new() + if not restored_dispatcher.restore_snapshot(runtime_data[&"logistics_dispatcher"]): + return false + var restored_logistics: Variant = _deserialize_logistics_requests(runtime_data[&"logistics_requests"]) + if restored_logistics == null: + return false + if restored_dispatcher.restored_snapshot_version == LogisticsDispatcher.LEGACY_SNAPSHOT_VERSION: + if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION \ + or not restored_dispatcher.migrate_legacy_assignment_ownership( + _derive_legacy_logistics_ownership( + restored_dispatcher, + restored_logistics, + restored_minute + ) + ): + return false + if not _validate_logistics_dispatcher_crosslinks( + restored_dispatcher, + restored_logistics, + restored_minute + ): + return false + var restored_facilities := FacilityService.new() + if not restored_facilities.restore_snapshot(runtime_data[&"facilities"]): + return false + var restored_guests := GuestService.new() + if not restored_guests.restore_snapshot(runtime_data[&"guests"]): + return false + if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION: + if not _saved_catalog_guest_definitions_match( + runtime_data[&"guests"], + restored_catalog + ): + return false + elif not _register_or_validate_legacy_catalog_definitions( + restored_guests, + restored_catalog + ): + return false + var restored_staff := StaffService.new() + if recovery_mode: + restored_staff.configure_callbacks( + Callable(), + func( + reservation_ids: Array[StringName], + task: StaffTask, + absolute_minute: int + ) -> Dictionary: + return _release_recovery_task_items( + restored_inventory, + reservation_ids, + task, + absolute_minute + ), + Callable() + ) + var restored_staff_ok := ( + restored_staff.restore_snapshot_with_orphan_recovery(runtime_data[&"staff"]) + if recovery_mode + else restored_staff.restore_snapshot(runtime_data[&"staff"]) + ) + if not restored_staff_ok: + return false + if recovery_mode: + staff_repair_report = restored_staff.last_recovery_repair_report.duplicate(true) + if not staff_repair_report[&"release_failed_task_ids"].is_empty(): + return false + var saved_rank_value: Variant = snapshot[&"game_state"].get(&"world_tree_rank", null) + if not saved_rank_value is int: + return false + var saved_rank := int(saved_rank_value) + var restored_progression := ProgressionService.new() + if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION: + if not restored_progression.restore_snapshot(runtime_data[&"progression"]): + return false + else: + # Pre-v4 saves never recorded promotion evidence. Rank 1 can be migrated + # from authoritative live facts; a later rank would require guessing which + # requirements and quests were actually satisfied, so it is rejected. + if saved_rank != ProgressionService.MIN_RANK: + return false + var legacy_guest_lifetime := restored_guests.guest_count() \ + + int((runtime_data[&"guests"] as Dictionary)[&"persistent_records"].size()) + if not restored_progression.configure(ProgressionService.MIN_RANK, { + &"active_above_ground_facilities": _count_active_facilities(restored_facilities, false), + &"active_underground_facilities": _count_active_facilities(restored_facilities, true), + &"asset_gold": restored_economy.get_balance(PLAYER_CASH_ACCOUNT), + &"faith_lifetime": maxi(int(saved_resources.get(&"faith", saved_resources.get("faith", 0))), 0), + &"guest_lifetime": legacy_guest_lifetime, + }): + return false + if saved_rank != restored_progression.current_rank \ + or not _strict_variants_equal( + restored_progression.get_rank_bounds(saved_rank), + restored_world.get_bounds() + ) \ + or not _progression_guest_lifetime_matches( + restored_progression, restored_guests + ): + return false + var restored_hired_staff_candidates: Variant + if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION: + restored_hired_staff_candidates = _deserialize_hired_staff_candidate_ids( + runtime_data[&"hired_staff_candidate_ids"], + restored_catalog + ) + if restored_hired_staff_candidates == null: + return false + else: + restored_hired_staff_candidates = _derive_legacy_hired_staff_candidate_ids( + restored_staff, + runtime_data[&"staff"], + restored_catalog + ) + if not _validate_catalog_staff_crosslinks( + restored_staff, + restored_hired_staff_candidates, + restored_catalog + ): + return false + var restored_operations_state := _parse_operations_runtime_snapshot( + runtime_data.get(&"operations", {}), + runtime_schema_version, + restored_minute, + restored_guests, + restored_staff, + restored_economy, + int(runtime_data.get(&"world_seed", DEFAULT_WORLD_SEED)), + snapshot[&"clock"].get(&"paused", false) + ) + if not bool(restored_operations_state.get(&"ok", false)): + return false + var restored_movement_credits: Variant + if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION: + restored_movement_credits = _deserialize_movement_credits( + runtime_data[&"movement_credits"], + restored_minute + ) + if restored_movement_credits == null: + return false + else: + restored_movement_credits = _rebuild_legacy_path_actor_projection( + restored_path, + restored_guests, + restored_staff, + restored_minute + ) + if restored_movement_credits == null: + return false + if not _validate_runtime_path_crosslinks( + restored_path, + restored_guests, + restored_staff, + restored_movement_credits, + restored_minute + ): + return false + var restored_relationships := RelationshipService.new() + if not restored_relationships.restore_snapshot(runtime_data[&"relationships"]): + return false + var restored_events := EventService.new() + for raw_definition: Variant in runtime_data[&"event_definitions"]: + var definition := EventDefinition.from_variant(raw_definition) + if definition == null or not restored_events.register_definition(definition).is_success(): + return false + if not bool(restored_events.validate_definitions().get(&"ok", false)): + return false + var restored_events_ok := ( + restored_events.restore_snapshot_with_lock_recovery(runtime_data[&"events"]) + if recovery_mode + else restored_events.restore_snapshot(runtime_data[&"events"]) + ) + if not restored_events_ok: + return false + if recovery_mode: + event_lock_repair_report = restored_events.last_recovery_repair_report.duplicate(true) + var saved_filter_profile := restored_events.get_content_filter_profile() + var effective_filter_profile := _merge_content_filter_profiles( + active_filter_profile, + saved_filter_profile + ) + restored_events.configure_content_filter( + effective_filter_profile[&"filter_mode"], + effective_filter_profile[&"maximum_content_rating"], + effective_filter_profile[&"disabled_content_tags"] + ) + var restored_authority := AuthorityService.new() + if not restored_authority.restore_snapshot(runtime_data[&"authority"]): + return false + var restored_defense := DefenseService.new() + if has_defense_snapshot: + if not restored_defense.restore_snapshot(runtime_data[&"defense"]): + return false + else: + # Runtime snapshots predating the Defense aggregate migrate to an empty + # aggregate at the saved clock. No external entity or consequence is + # synthesized by this compatibility path. + if not restored_defense.advance_time(restored_minute).is_success(): + return false + var restored_staffing_projection := bool( + runtime_data.get(&"staffing_projection_enabled", false) + ) + var restored_runtime_domain_minute := restored_minute + if bool(restored_operations_state.get(&"enabled", false)): + var restored_operations_coordinator: OperationsCoordinator = ( + restored_operations_state.get(&"coordinator", null) + ) + if int(restored_operations_state.get(&"deferred_runtime_minute", -1)) \ + == restored_minute \ + or (restored_operations_coordinator != null \ + and restored_operations_coordinator.is_terminal()): + restored_runtime_domain_minute = restored_minute - 1 + if not _validate_runtime_crosslinks( + restored_facilities, + restored_guests, + restored_economy, + restored_inventory, + restored_staff, + restored_runtime_domain_minute, + restored_staffing_projection + ): + return false + if restored_authority.current_minute < restored_runtime_domain_minute \ + or restored_authority.current_minute > restored_minute \ + or restored_defense.current_minute < restored_runtime_domain_minute \ + or restored_defense.current_minute > restored_minute: + return false + if not _validate_authority_economy_crosslinks( + restored_authority, + restored_economy, + restored_authority.current_minute + ): + return false + if not _validate_defense_crosslinks( + restored_defense, + restored_staff, + restored_facilities, + restored_defense.current_minute + ): + return false + var restored_construction_resource := _create_resource_adapter( + restored_economy, + CONSTRUCTION_EXPENSE_ACCOUNT, + &"CONSTRUCTION", + &"CONSTRUCTION_REFUND", + &"construction_service" + ) + var restored_construction := ConstructionService.new(restored_world, restored_construction_resource) + if not restored_construction.restore_snapshot(runtime_data[&"construction"]): + return false + var restored_excavation := ExcavationService.new( + restored_world, restored_construction_resource + ) + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if not restored_excavation.restore_snapshot(runtime_data[&"excavation"]): + return false + else: + restored_excavation.current_minute = restored_construction.current_minute + if not _migrate_legacy_infrastructure_proxies( + restored_construction, + restored_facilities, + restored_infrastructure_projector + ) or not restored_progression.update_metrics({ + &"active_above_ground_facilities": _count_active_facilities( + restored_facilities, false + ), + &"active_underground_facilities": _count_active_facilities( + restored_facilities, true + ), + &"asset_gold": restored_economy.get_balance(PLAYER_CASH_ACCOUNT), + }) or not _validate_runtime_crosslinks( + restored_facilities, + restored_guests, + restored_economy, + restored_inventory, + restored_staff, + restored_runtime_domain_minute, + restored_staffing_projection + ): + return false + if not _validate_construction_facility_crosslinks( + restored_world, + restored_construction, + restored_facilities, + restored_infrastructure_projector + ) or not _validate_infrastructure_crosslinks( + restored_world, + restored_path, + restored_construction, + restored_construction_resource, + restored_excavation, + restored_spatial, + restored_route_adapter, + restored_infrastructure_projector + ): + return false + var restored_world_seed := int(runtime_data.get(&"world_seed", DEFAULT_WORLD_SEED)) + var restored_streams := {} + var saved_streams: Dictionary = runtime_data[&"rng_streams"] + for stream_key: Variant in saved_streams: + if not saved_streams[stream_key] is Dictionary: + return false + var normalized_stream_id := StringName(stream_key) + if normalized_stream_id.is_empty() or restored_streams.has(normalized_stream_id): + return false + var stream := DeterministicRng.from_root_seed(0, normalized_stream_id) as DeterministicRng + if stream == null or not stream.restore(saved_streams[stream_key]) \ + or stream.get_stream_id() != normalized_stream_id \ + or stream.get_root_seed() != restored_world_seed: + return false + restored_streams[normalized_stream_id] = stream + if not restored_streams.has(&"defense"): + if has_defense_snapshot: + return false + restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense") + if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION: + for required_stream_id: StringName in RNG_STREAM_IDS: + if not restored_streams.has(required_stream_id): + return false + var restored_next_id := int(runtime_data.get(&"next_command_id", 0)) + if restored_next_id <= 0: + return false + if not SimulationSnapshot.restore(snapshot, _clock, _state): + return false + world_seed = restored_world_seed + _next_command_id = restored_next_id + _staffing_projection_enabled = restored_staffing_projection + command_queue = restored_queue + id_generator = restored_id_generator + domain_events = restored_domain_events + _rng_streams = restored_streams + world_service = restored_world + path_service = restored_path + _movement_credits = (restored_movement_credits as Dictionary).duplicate(true) + _connect_path_performance_signal() + mana_network_service = restored_mana + economy_service = restored_economy + inventory_service = restored_inventory + logistics_dispatcher = restored_dispatcher + logistics_requests = restored_logistics + facility_service = restored_facilities + guest_service = restored_guests + staff_service = restored_staff + mvp_content_catalog = restored_catalog + mvp_content_report = restored_catalog_report.duplicate(true) + progression_service = restored_progression + _hired_staff_candidate_ids = (restored_hired_staff_candidates as Dictionary).duplicate(true) + operations_coordinator = restored_operations_state.get(&"coordinator", null) + _operations_enabled = bool(restored_operations_state[&"enabled"]) + _personal_action_points = int(restored_operations_state[&"personal_action_points"]) + _operations_runtime_failure_latched = bool(restored_operations_state[&"runtime_failure_latched"]) + _operations_runtime_failure_report = ( + restored_operations_state[&"runtime_failure_report"] as Dictionary + ).duplicate(true) + _operations_tick_blocked = _operations_should_block_tick() + _operations_deferred_runtime_minute = int( + restored_operations_state[&"deferred_runtime_minute"] + ) + _operations_resume_publish_pending = false + _operations_test_boundary_hooks.clear() + _operations_transaction_active = false + _operations_queued_domain_events.clear() + _operations_queued_guest_spawn_notifications.clear() + _operations_flushing_guest_spawn_notifications.clear() + _operations_queued_economy_notifications.clear() + _operations_flushing_economy_notifications.clear() + relationship_service = restored_relationships + event_service = restored_events + authority_service = restored_authority + _detach_defense_service(defense_service) + defense_service = restored_defense + construction_resource_state = restored_construction_resource + operating_resource_state = _create_resource_adapter( + economy_service, + OPERATING_EXPENSE_ACCOUNT, + &"OPERATING_EXPENSE", + &"OPERATING_REFUND", + &"simulation_runtime" + ) + construction_service = restored_construction + excavation_service = restored_excavation + spatial_subslot_service = restored_spatial + route_subslot_path_adapter = restored_route_adapter + facility_infrastructure_projector = restored_infrastructure_projector + _infrastructure_failure_latched = false + _infrastructure_failure_report.clear() + _construction_completion_transaction_active = false + _construction_committing_notifications = false + _construction_queued_domain_events.clear() + _last_daily_checkpoint_day = restored_last_daily_checkpoint_day + _pending_checkpoints.clear() + _next_checkpoint_request_id = 1 + _connect_economy_signals() + _configure_facility_boundaries() + _connect_facility_signals() + _configure_staff_boundaries() + _connect_guest_signals() + _connect_staff_signals() + _configure_relationship_event_boundaries() + _connect_relationship_signals() + _connect_event_signals() + _configure_authority_boundaries() + _connect_authority_signals() + _configure_defense_boundaries() + _connect_defense_signals() + _connect_construction_signals() + var excavation_callback := Callable(self, "_on_excavation_completed") + if not excavation_service.excavation_completed.is_connected(excavation_callback): + excavation_service.excavation_completed.connect(excavation_callback) + if _operations_enabled: + _configure_operations_callbacks() + _register_operations_tick_handler() + else: + _clock.unregister_tick_handler( + _clock.TickPhase.TIME_BOUNDARIES, + &"simulation_runtime.operations" + ) + latest_restore_safety_report = { + &"report_version": 1, + &"recovery_mode": bool(recovery_mode), + &"repair_policy": &"WHITELISTED_AUTHORITATIVE_ONLY", + &"automatic_repairs_applied": ( + bool(staff_repair_report[&"automatic_repairs_applied"]) + or bool(event_lock_repair_report[&"automatic_repairs_applied"]) + ), + &"repairs": { + &"orphan_tasks": staff_repair_report, + &"orphan_event_locks": event_lock_repair_report, + }, + &"content_filter": _content_filter_restore_report( + active_filter_profile, + saved_filter_profile, + effective_filter_profile + ), + &"forbidden_repairs_applied": false, + &"forbidden_repair_categories": [ + &"synthesize_gold", + &"delete_facilities", + &"rewrite_ledger", + &"guess_route_locks", + ], + } + _publish_snapshot(_clock.absolute_minute) + return true + +func configure_save_policy(overrides: Dictionary) -> Dictionary: + var configured := _save_policy.duplicate(true) + if overrides.has(&"enabled"): + configured[&"enabled"] = bool(overrides[&"enabled"]) + if overrides.has(&"daily_checkpoint_enabled"): + configured[&"daily_checkpoint_enabled"] = bool(overrides[&"daily_checkpoint_enabled"]) + if overrides.has(&"auto_migrate"): + configured[&"auto_migrate"] = bool(overrides[&"auto_migrate"]) + if overrides.has(&"autosave_path"): + var autosave_path := str(overrides[&"autosave_path"]) + if not _save_policy_path_is_safe(autosave_path): + return { + &"ok": false, + &"code": &"INVALID_AUTOSAVE_PATH", + &"message": autosave_path, + } + configured[&"autosave_path"] = autosave_path + if overrides.has(&"autosave_generations"): + var generations := int(overrides[&"autosave_generations"]) + if generations < 1 or generations > SaveService.MAX_AUTOSAVE_GENERATIONS: + return { + &"ok": false, + &"code": &"INVALID_AUTOSAVE_GENERATIONS", + &"message": str(generations), + } + configured[&"autosave_generations"] = generations + _save_policy = configured + return { + &"ok": true, + &"code": &"OK", + &"policy": get_save_policy(), + } + + +func enable_production_save_policy() -> Dictionary: + return configure_save_policy({ + &"enabled": true, + &"daily_checkpoint_enabled": true, + &"autosave_path": DEFAULT_AUTOSAVE_PATH, + }) + + +func get_save_policy() -> Dictionary: + return _save_policy.duplicate(true) + + +func get_last_daily_checkpoint_day() -> int: + return _last_daily_checkpoint_day + + +func get_pending_checkpoint_count() -> int: + return _pending_checkpoints.size() + + +func get_latest_restore_safety_report() -> Dictionary: + return latest_restore_safety_report.duplicate(true) + + +func enable_production_operations() -> Dictionary: + if not _initialized or _clock == null or _state == null: + return _operations_api_failure(&"RUNTIME_NOT_INITIALIZED", "Runtime is not initialized.") + if _operations_enabled: + return _operations_api_failure(&"ALREADY_ENABLED", "Production operations are already enabled.") + if _clock.absolute_minute != OPERATIONS_START_MINUTE \ + or _clock.day != 1 or _clock.minute_of_day != OPERATIONS_START_MINUTE: + return _operations_api_failure( + &"INVALID_START_TIME", + "Production operations must start at Day 1 06:00." + ) + if not bool(mvp_content_report.get(&"valid", false)): + return _operations_api_failure(&"CONTENT_INVALID", "MVP content is unavailable.") + if _staff_payroll_arrears_total(staff_service) != 0: + return _operations_api_failure( + &"INITIAL_ARREARS_UNSUPPORTED", + "Production operations cannot infer pre-existing payroll arrears." + ) + if not (staff_service.create_snapshot().get(&"payroll_settlements", []) as Array).is_empty(): + return _operations_api_failure( + &"INITIAL_PAYROLL_HISTORY_UNSUPPORTED", + "Production operations require a fresh Day 1 payroll history." + ) + if _guest_aggregate_has_reserved_operations_id(guest_service): + return _operations_api_failure( + &"RESERVED_GUEST_ID_PRESENT", + "Production operations require the guest-auto namespace to be unused." + ) + var coordinator := OperationsCoordinator.new({ + &"start_minute": OPERATIONS_COORDINATOR_ORIGIN_MINUTE, + &"world_seed": world_seed, + }) + coordinator.configure_callbacks( + Callable(self, "_operations_daily_boundary"), + Callable(self, "_operations_arrival_boundary"), + Callable(self, "_operations_morning_boundary") + ) + var demand_configuration := _configure_default_operations_demand(coordinator) + if not demand_configuration.is_success(): + return _operations_api_failure( + &"DEMAND_CONFIGURATION_FAILED", + demand_configuration.message, + demand_configuration.details + ) + var population := coordinator.set_population_count(guest_service.guest_count()) + if not population.is_success(): + return _operations_api_failure( + &"INITIAL_POPULATION_INVALID", + population.message, + population.details + ) + var previous_coordinator := operations_coordinator + var previous_ap := _personal_action_points + var enable_external_before := _capture_operations_external_state() + if enable_external_before.is_empty(): + return _operations_api_failure( + &"INITIAL_CAPTURE_FAILED", + "Production Operations could not capture the initial Runtime state." + ) + operations_coordinator = coordinator + _personal_action_points = 0 + var advanced := coordinator.advance_to(OPERATIONS_START_MINUTE, true) + if not advanced.is_success() \ + or coordinator.current_minute != _clock.absolute_minute \ + or coordinator.get_population_count() != guest_service.guest_count() \ + or coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service) \ + or not _operations_notifications_are_committable() \ + or not _commit_operations_domain_events() \ + or not _commit_operations_notifications(): + coordinator.discard_deferred_notifications() + _restore_operations_external_state(enable_external_before) + _discard_operations_domain_events() + operations_coordinator = previous_coordinator + _personal_action_points = previous_ap + return _operations_api_failure( + &"INITIAL_BOUNDARY_FAILED", + advanced.message if not advanced.is_success() else "Initial Operations cross-links failed.", + advanced.details + ) + _operations_enabled = true + _operations_tick_blocked = false + _operations_runtime_failure_latched = false + _operations_runtime_failure_report.clear() + _register_operations_tick_handler() + _pause_for_waiting_foreground_event() + _publish_snapshot(_clock.absolute_minute) + return { + &"ok": true, + &"code": &"OK", + &"enabled": true, + &"current_minute": coordinator.current_minute, + &"personal_action_points": _personal_action_points, + } + + +func acknowledge_operations_pause() -> Dictionary: + if not _operations_enabled or operations_coordinator == null: + return _operations_api_failure(&"OPERATIONS_DISABLED", "Production operations are disabled.") + if operations_coordinator.is_terminal(): + return _operations_api_failure(&"TERMINAL_STATE", "Bankruptcy cannot be acknowledged away.") + if operations_coordinator.is_external_quarantined(): + return _operations_api_failure( + &"EXTERNAL_ROLLBACK_FAILED", + "A verified snapshot restore is required." + ) + if _operations_deferred_runtime_minute < 0: + return _operations_api_failure( + &"NO_DEFERRED_RUNTIME_MINUTE", + "Operations have no deferred Runtime phase suffix." + ) + if _operations_deferred_runtime_minute != _clock.absolute_minute: + return _operations_api_failure( + &"DEFERRED_MINUTE_MISMATCH", + "The deferred Operations minute no longer matches GameClock." + ) + var had_runtime_failure := _operations_runtime_failure_latched + var acknowledged := operations_coordinator.acknowledge_pause() + if not acknowledged.is_success(): + return _operations_api_failure(&"ACKNOWLEDGE_FAILED", acknowledged.message, acknowledged.details) + var deferred_minute := _operations_deferred_runtime_minute + var deferred := operations_coordinator.advance_to(deferred_minute, true) + if not deferred.is_success(): + var partial_commit_ok := _commit_operations_domain_events() \ + and _commit_operations_notifications() + if not partial_commit_ok: + operations_coordinator.discard_deferred_notifications() + _discard_operations_domain_events() + _operations_runtime_failure_latched = true + _operations_runtime_failure_report = deferred.to_dictionary() + _operations_tick_blocked = true + _clock.set_paused(true) + return _operations_api_failure(&"RETRY_FAILED", deferred.message, deferred.details) + if operations_coordinator.current_minute != deferred_minute \ + or operations_coordinator.get_population_count() != guest_service.guest_count() \ + or operations_coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service) \ + or not _operations_notifications_are_committable() \ + or not _commit_operations_domain_events() \ + or not _commit_operations_notifications(): + operations_coordinator.discard_deferred_notifications() + _discard_operations_domain_events() + _operations_runtime_failure_latched = true + _operations_runtime_failure_report = { + &"code": &"OPERATIONS_CROSSLINK_MISMATCH", + &"minute": deferred_minute, + } + _operations_tick_blocked = true + _clock.set_paused(true) + return _operations_api_failure( + &"RETRY_CROSSLINK_FAILED", + "Operations retry did not restore Runtime cross-links." + ) + _operations_runtime_failure_latched = false + _operations_runtime_failure_report.clear() + if operations_coordinator.is_terminal(): + _operations_deferred_runtime_minute = -1 + _operations_tick_blocked = true + _clock.set_paused(true) + _publish_snapshot(deferred_minute) + return { + &"ok": true, + &"code": &"TERMINAL_STATE", + &"had_runtime_failure": had_runtime_failure, + &"suffix_replayed": false, + } + if operations_coordinator.pause_requested: + _operations_tick_blocked = true + _clock.set_paused(true) + return _operations_api_failure( + &"PAUSE_REMAINS", + "Operations still require acknowledgement before Runtime phases can resume." + ) + _operations_deferred_runtime_minute = -1 + _operations_tick_blocked = false + _clock.set_paused(false) + _run_operations_deferred_suffix(deferred_minute) + _pause_for_waiting_foreground_event() + if operations_coordinator.get_population_count() != guest_service.guest_count() \ + or operations_coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service): + _operations_runtime_failure_latched = true + _operations_runtime_failure_report = { + &"code": &"DEFERRED_SUFFIX_CROSSLINK_MISMATCH", + &"minute": deferred_minute, + } + _operations_tick_blocked = true + _clock.set_paused(true) + return _operations_api_failure( + &"SUFFIX_CROSSLINK_FAILED", + "Deferred Runtime phases ended with invalid Operations cross-links." + ) + return { + &"ok": true, + &"code": &"OK", + &"had_runtime_failure": had_runtime_failure, + &"coordinator": acknowledged.to_dictionary(), + &"suffix_replayed": true, + &"deferred_runtime_minute": deferred_minute, + } + + +func _run_operations_deferred_suffix(absolute_minute: int) -> void: + _advance_mana(absolute_minute) + _advance_construction(absolute_minute) + _advance_inventory_and_logistics(absolute_minute) + _advance_facilities(absolute_minute) + _advance_guest_vitals(absolute_minute) + _advance_authority_incidents(absolute_minute) + _advance_defense(absolute_minute) + _advance_relationship_events(absolute_minute) + _advance_guest_queues(absolute_minute) + _advance_staff_ai(absolute_minute) + if _active_command_minute >= 0: + _operations_resume_publish_pending = true + else: + _publish_snapshot(absolute_minute) + _process_save_checkpoints(absolute_minute) + + +func get_operations_report() -> Dictionary: + return { + &"enabled": _operations_enabled, + &"tick_blocked": _operations_tick_blocked, + &"runtime_failure_latched": _operations_runtime_failure_latched, + &"runtime_failure_report": _operations_runtime_failure_report.duplicate(true), + &"deferred_runtime_minute": _operations_deferred_runtime_minute, + &"personal_action_points": _personal_action_points, + &"coordinator": ( + operations_coordinator.create_snapshot() + if operations_coordinator != null else {} + ), + } + + +func set_operations_test_boundary_hook(operation: StringName, callback: Callable) -> bool: + # Test-only fault injection is invoked at PREPARE and, for arrivals, once at + # AFTER_FIRST_MUTATION. The latter proves staged service notifications are + # discarded when a real partial apply is rolled back. + if operation not in [&"DAILY_CLOSE", &"ARRIVAL_PLAN", &"MORNING_BOUNDARY"]: + return false + if callback.is_valid(): + _operations_test_boundary_hooks[operation] = callback + else: + _operations_test_boundary_hooks.erase(operation) + return true + + +func _run_operations_test_boundary_hook( + operation: StringName, + payload: Dictionary, + phase: StringName, + token: Variant +) -> Dictionary: + var callback: Callable = _operations_test_boundary_hooks.get(operation, Callable()) + if not callback.is_valid(): + return {&"ok": true} + var returned: Variant = callback.call(payload.duplicate(true), phase, token) + if returned is bool: + return {&"ok": bool(returned), &"reason": &"TEST_HOOK_REJECTED"} + if returned is Dictionary: + var result: Dictionary = returned + if typeof(result.get(&"ok", null)) == TYPE_BOOL: + return result.duplicate(true) + return {&"ok": false, &"reason": &"TEST_HOOK_INVALID_RESULT"} + + +func _register_operations_tick_handler() -> void: + _clock.unregister_tick_handler( + _clock.TickPhase.TIME_BOUNDARIES, + &"simulation_runtime.operations" + ) + _clock.register_tick_handler( + _clock.TickPhase.TIME_BOUNDARIES, + &"simulation_runtime.operations", + Callable(self, "_advance_operations_time") + ) + + +func _configure_operations_callbacks() -> void: + if operations_coordinator == null: + return + operations_coordinator.configure_callbacks( + Callable(self, "_operations_daily_boundary"), + Callable(self, "_operations_arrival_boundary"), + Callable(self, "_operations_morning_boundary") + ) + + +func _configure_default_operations_demand(coordinator: OperationsCoordinator) -> CommandResult: + if coordinator == null or DEFAULT_OPERATIONS_DEMAND_PROFILES.size() != 8: + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + 0, + "The canonical Operations demand profile set is unavailable." + ) + var allowed_classes: Dictionary = {} + for class_id: StringName in mvp_content_catalog.get_guest_class_ids(): + allowed_classes[class_id] = true + var seen_classes: Dictionary = {} + for raw_profile: Variant in DEFAULT_OPERATIONS_DEMAND_PROFILES: + if not raw_profile is Dictionary: + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, 0, + "A canonical Operations demand profile is malformed." + ) + var profile: Dictionary = raw_profile + var guest_class := StringName(profile[&"guest_class"]) + if not allowed_classes.has(guest_class) or seen_classes.has(guest_class): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, 0, + "The canonical Operations demand classes are invalid." + ) + seen_classes[guest_class] = true + var configured := coordinator.set_demand_input( + StringName(profile[&"facility_tag"]), + guest_class, + StringName(profile[&"time_band"]), + { + &"base": 100, + &"reputation": 0, + &"unmet_need": 0, + &"event": 0, + &"recent_price_penalty": 0, + &"wait_penalty": 0, + } + ) + if not configured.is_success(): + return configured + if seen_classes.size() != allowed_classes.size(): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, 0, + "Every MVP Guest class must have exactly one demand profile." + ) + return CommandResult.succeeded(0, {&"profile_count": seen_classes.size()}) + + +func _advance_operations_time(absolute_minute: int) -> void: + if not _operations_enabled or operations_coordinator == null: + return + if _operations_deferred_runtime_minute >= 0: + _operations_tick_blocked = true + _clock.set_paused(true) + return + _operations_tick_blocked = false + var advanced := operations_coordinator.advance_to(absolute_minute, true) + if not advanced.is_success(): + # Earlier boundaries in the same minute can already be validly committed + # (for example demand before a failed arrival). Publish only those staged + # effects and notifications; the failing callback rolled itself back. + if not _commit_operations_domain_events() \ + or not _commit_operations_notifications(): + operations_coordinator.discard_deferred_notifications() + _discard_operations_domain_events() + _operations_runtime_failure_latched = true + _operations_runtime_failure_report = advanced.to_dictionary() + _operations_deferred_runtime_minute = absolute_minute + _operations_tick_blocked = true + _clock.set_paused(true) + return + if operations_coordinator.current_minute != absolute_minute \ + or operations_coordinator.get_population_count() != guest_service.guest_count() \ + or operations_coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service) \ + or not _operations_notifications_are_committable() \ + or not _commit_operations_domain_events() \ + or not _commit_operations_notifications(): + operations_coordinator.discard_deferred_notifications() + _discard_operations_domain_events() + _operations_runtime_failure_latched = true + _operations_runtime_failure_report = { + &"code": &"OPERATIONS_CROSSLINK_MISMATCH", + &"minute": absolute_minute, + } + _operations_deferred_runtime_minute = absolute_minute + _operations_tick_blocked = true + _clock.set_paused(true) + return + _operations_runtime_failure_latched = false + _operations_runtime_failure_report.clear() + if operations_coordinator.is_terminal(): + _operations_deferred_runtime_minute = -1 + _operations_tick_blocked = true + _clock.set_paused(true) + return + if operations_coordinator.pause_requested: + _operations_deferred_runtime_minute = absolute_minute + _operations_tick_blocked = true + _clock.set_paused(true) + return + _operations_deferred_runtime_minute = -1 + _pause_for_waiting_foreground_event() + + +func _operations_should_block_tick() -> bool: + if not _operations_enabled or operations_coordinator == null: + return false + return _operations_deferred_runtime_minute >= 0 \ + or _operations_runtime_failure_latched \ + or operations_coordinator.pause_requested \ + or operations_coordinator.is_terminal() \ + or operations_coordinator.is_external_quarantined() + + +func _pause_for_waiting_foreground_event() -> void: + if event_service == null or _clock == null: + return + var foreground := event_service.get_foreground_instance() + if foreground != null and foreground.state == EventInstance.State.WAITING_CHOICE: + _clock.set_paused(true) + + +func _capture_operations_external_state() -> Dictionary: + # This is deliberately a composed transaction token, not a Runtime snapshot. + # It contains every aggregate that an Operations callback can reach, while + # excluding clock/coordinator state owned by the caller of the callback. + if not _begin_operations_external_transaction(): + return {} + return _build_operations_external_state() + + +func _build_operations_external_state() -> Dictionary: + var committed_minute := _runtime_aggregate_committed_minute() + if committed_minute < 0: + _discard_operations_domain_events() + return {} + var rng_snapshots: Dictionary = {} + for stream_id: StringName in _sorted_name_keys(_rng_streams): + var stream := _rng_streams.get(stream_id) as DeterministicRng + if stream == null: + _discard_operations_domain_events() + return {} + rng_snapshots[stream_id] = stream.snapshot() + return { + # TIME_BOUNDARIES runs before the Runtime suffix. During a normal + # Operations callback the Clock is already at X while minute-driven + # aggregates are still committed through X - 1. Capture that committed + # participant minute so rollback validates the state it actually saved. + &"captured_minute": committed_minute, + &"captured_clock_minute": _clock.absolute_minute, + &"captured_authority_minute": authority_service.current_minute, + &"captured_defense_minute": defense_service.current_minute, + &"game_state": _state.create_snapshot(), + &"command_queue": command_queue.create_snapshot(), + &"next_command_id": _next_command_id, + &"economy": economy_service.create_snapshot(), + &"inventory": inventory_service.create_snapshot(), + &"facilities": facility_service.create_snapshot(), + &"guests": guest_service.create_snapshot(), + &"staff": staff_service.create_snapshot(), + &"relationships": relationship_service.create_snapshot(), + &"events": event_service.create_snapshot(), + &"authority": authority_service.create_snapshot(), + &"world": world_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"excavation": excavation_service.create_snapshot(), + &"spatial_subslots": spatial_subslot_service.create_snapshot(), + &"route_projection": route_subslot_path_adapter.create_snapshot(), + &"facility_infrastructure": facility_infrastructure_projector.create_snapshot(), + &"movement_credits": _movement_credits.duplicate(true), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + &"rng_streams": rng_snapshots, + &"staffing_projection_enabled": _staffing_projection_enabled, + &"progression": progression_service.create_snapshot(), + &"hired_staff_candidate_ids": _hired_staff_candidate_ids.duplicate(true), + &"personal_action_points": _personal_action_points, + # The remaining aggregates are reachable through Event actions or signals. + &"mana": mana_network_service.create_snapshot(), + &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), + &"logistics_requests": _serialize_logistics_requests(), + &"defense": defense_service.create_snapshot(), + &"construction": construction_service.create_snapshot(), + } + + +func _operations_external_committed_minute() -> int: + return _runtime_aggregate_committed_minute() + + +func _runtime_aggregate_committed_minute() -> int: + if facility_service == null or guest_service == null or inventory_service == null \ + or staff_service == null: + return -1 + var minute := facility_service.current_minute + if minute < 0 or guest_service.current_minute != minute \ + or inventory_service.current_minute != minute \ + or staff_service.current_minute != minute: + return -1 + return minute + + +func _restore_operations_external_state(token: Variant) -> bool: + if not token is Dictionary: + _discard_operations_domain_events() + return false + var saved: Dictionary = token + var required_dictionary_keys: Array[StringName] = [ + &"game_state", &"command_queue", &"economy", &"inventory", &"facilities", &"guests", + &"staff", &"relationships", &"events", &"authority", &"world", &"path", + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", + &"movement_credits", &"domain_events", &"id_generator", &"rng_streams", + &"progression", &"hired_staff_candidate_ids", &"mana", + &"logistics_dispatcher", &"defense", &"construction", + ] + for key: StringName in required_dictionary_keys: + if not saved.get(key, null) is Dictionary: + _discard_operations_domain_events() + return false + if not saved.get(&"logistics_requests", null) is Array \ + or typeof(saved.get(&"staffing_projection_enabled", null)) != TYPE_BOOL \ + or typeof(saved.get(&"personal_action_points", null)) != TYPE_INT \ + or typeof(saved.get(&"captured_minute", null)) != TYPE_INT \ + or int(saved[&"captured_minute"]) < 0 \ + or typeof(saved.get(&"captured_clock_minute", null)) != TYPE_INT \ + or int(saved[&"captured_clock_minute"]) < int(saved[&"captured_minute"]) \ + or typeof(saved.get(&"captured_authority_minute", null)) != TYPE_INT \ + or int(saved[&"captured_authority_minute"]) < 0 \ + or typeof(saved.get(&"captured_defense_minute", null)) != TYPE_INT \ + or int(saved[&"captured_defense_minute"]) < 0 \ + or typeof(saved.get(&"next_command_id", null)) != TYPE_INT \ + or int(saved[&"next_command_id"]) <= 0: + _discard_operations_domain_events() + return false + # Validation/PREPARE failure did not mutate a participant. Avoid restore APIs + # here because GameState.restore_snapshot intentionally emits reset signals. + if _operations_external_state_matches(saved): + _discard_operations_domain_events() + return true + + # Ask every participant to restore even when an earlier participant rejects + # its snapshot. This avoids knowingly leaving a half-restored external world. + var state_ok: bool = _state.restore_snapshot(saved[&"game_state"]) + var command_queue_ok := command_queue.restore_snapshot(saved[&"command_queue"]) + var economy_ok := economy_service.restore_snapshot(saved[&"economy"]) + var inventory_ok := inventory_service.restore_snapshot(saved[&"inventory"]) + var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"]) + var guests_ok := guest_service.restore_snapshot(saved[&"guests"]) + var staff_ok := staff_service.restore_snapshot(saved[&"staff"]) + var relationships_ok := relationship_service.restore_snapshot(saved[&"relationships"]) + var events_ok := event_service.restore_snapshot(saved[&"events"]) + var authority_ok := authority_service.restore_snapshot(saved[&"authority"]) + var world_ok := world_service.restore_snapshot(saved[&"world"]) + var spatial_ok := spatial_subslot_service.restore_snapshot(saved[&"spatial_subslots"]) + var route_snapshot: Dictionary = saved[&"route_projection"] + var projected_walkable: Variant = route_snapshot.get(&"projected_walkable_cells", null) + var topology_ok := projected_walkable is Array \ + and path_service.rebuild_graph(projected_walkable) + var path_ok := topology_ok and path_service.restore_snapshot(saved[&"path"]) + var route_ok := path_ok and route_subslot_path_adapter.restore_snapshot(route_snapshot) + var infrastructure_ok := route_ok and facility_infrastructure_projector.restore_snapshot( + saved[&"facility_infrastructure"] + ) + var domain_events_ok := domain_events.restore_snapshot(saved[&"domain_events"]) + var ids_ok := id_generator.restore_snapshot(saved[&"id_generator"]) + var progression_ok := progression_service.restore_snapshot(saved[&"progression"]) + var mana_ok := mana_network_service.restore_snapshot(saved[&"mana"]) + var dispatcher_ok := logistics_dispatcher.restore_snapshot(saved[&"logistics_dispatcher"]) + var defense_ok := defense_service.restore_snapshot(saved[&"defense"]) + var construction_ok := construction_service.restore_snapshot(saved[&"construction"]) + var excavation_ok := excavation_service.restore_snapshot(saved[&"excavation"]) + var restored_requests: Variant = _deserialize_logistics_requests(saved[&"logistics_requests"]) + var requests_ok := restored_requests is Dictionary + if requests_ok: + logistics_requests = restored_requests + _movement_credits = (saved[&"movement_credits"] as Dictionary).duplicate(true) + _staffing_projection_enabled = bool(saved[&"staffing_projection_enabled"]) + _hired_staff_candidate_ids = ( + saved[&"hired_staff_candidate_ids"] as Dictionary + ).duplicate(true) + _personal_action_points = int(saved[&"personal_action_points"]) + _next_command_id = int(saved[&"next_command_id"]) + + var rng_ok := true + var saved_rngs: Dictionary = saved[&"rng_streams"] + if saved_rngs.size() != _rng_streams.size(): + rng_ok = false + for raw_stream_id: Variant in saved_rngs: + var stream_id := StringName(raw_stream_id) + var stream := _rng_streams.get(stream_id) as DeterministicRng + if stream == null or not saved_rngs[raw_stream_id] is Dictionary \ + or not stream.restore(saved_rngs[raw_stream_id]): + rng_ok = false + + _discard_operations_domain_events() + var participants_ok := state_ok and command_queue_ok and economy_ok and inventory_ok \ + and facilities_ok and guests_ok and staff_ok and relationships_ok \ + and events_ok and authority_ok and world_ok and spatial_ok and path_ok \ + and route_ok and infrastructure_ok and excavation_ok \ + and domain_events_ok and ids_ok \ + and progression_ok and mana_ok and dispatcher_ok and defense_ok \ + and construction_ok and requests_ok and rng_ok + if not participants_ok: + return false + var validation_minute := int(saved[&"captured_minute"]) + var clock_validation_minute := int(saved[&"captured_clock_minute"]) + return _operations_external_state_matches(saved) \ + and bool(economy_service.validate_ledger().get(&"ok", false)) \ + and _validate_logistics_dispatcher_crosslinks( + logistics_dispatcher, logistics_requests, clock_validation_minute + ) \ + and _validate_runtime_crosslinks( + facility_service, guest_service, economy_service, inventory_service, + staff_service, validation_minute, _staffing_projection_enabled + ) \ + and _validate_runtime_path_crosslinks( + path_service, guest_service, staff_service, _movement_credits, + clock_validation_minute + ) \ + and _validate_authority_economy_crosslinks( + authority_service, economy_service, + int(saved[&"captured_authority_minute"]) + ) \ + and _validate_defense_crosslinks( + defense_service, staff_service, facility_service, + int(saved[&"captured_defense_minute"]) + ) \ + and _validate_construction_facility_crosslinks( + world_service, construction_service, facility_service, + facility_infrastructure_projector + ) \ + and _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ) + + +func _operations_external_state_matches(saved: Dictionary) -> bool: + if not _strict_variants_equal(_state.create_snapshot(), saved[&"game_state"]) \ + or not _strict_variants_equal(command_queue.create_snapshot(), saved[&"command_queue"]) \ + or _next_command_id != int(saved[&"next_command_id"]) \ + or not _strict_variants_equal(economy_service.create_snapshot(), saved[&"economy"]) \ + or not _strict_variants_equal(inventory_service.create_snapshot(), saved[&"inventory"]) \ + or not _strict_variants_equal(facility_service.create_snapshot(), saved[&"facilities"]) \ + or not _strict_variants_equal(guest_service.create_snapshot(), saved[&"guests"]) \ + or not _strict_variants_equal(staff_service.create_snapshot(), saved[&"staff"]) \ + or not _strict_variants_equal(relationship_service.create_snapshot(), saved[&"relationships"]) \ + or not _strict_variants_equal(event_service.create_snapshot(), saved[&"events"]) \ + or not _strict_variants_equal(authority_service.create_snapshot(), saved[&"authority"]) \ + or not _strict_variants_equal(world_service.create_snapshot(), saved[&"world"]) \ + or not _strict_variants_equal(path_service.create_snapshot(), saved[&"path"]) \ + or not _strict_variants_equal(excavation_service.create_snapshot(), saved[&"excavation"]) \ + or not _strict_variants_equal(spatial_subslot_service.create_snapshot(), saved[&"spatial_subslots"]) \ + or not _strict_variants_equal(route_subslot_path_adapter.create_snapshot(), saved[&"route_projection"]) \ + or not _strict_variants_equal(facility_infrastructure_projector.create_snapshot(), saved[&"facility_infrastructure"]) \ + or not _strict_variants_equal(_movement_credits, saved[&"movement_credits"]) \ + or not _strict_variants_equal(domain_events.create_snapshot(), saved[&"domain_events"]) \ + or not _strict_variants_equal(id_generator.create_snapshot(), saved[&"id_generator"]) \ + or not _strict_variants_equal(progression_service.create_snapshot(), saved[&"progression"]) \ + or not _strict_variants_equal(mana_network_service.create_snapshot(), saved[&"mana"]) \ + or not _strict_variants_equal(logistics_dispatcher.create_snapshot(), saved[&"logistics_dispatcher"]) \ + or not _strict_variants_equal(_serialize_logistics_requests(), saved[&"logistics_requests"]) \ + or not _strict_variants_equal(defense_service.create_snapshot(), saved[&"defense"]) \ + or not _strict_variants_equal(construction_service.create_snapshot(), saved[&"construction"]) \ + or _staffing_projection_enabled != bool(saved[&"staffing_projection_enabled"]) \ + or not _strict_variants_equal(_hired_staff_candidate_ids, saved[&"hired_staff_candidate_ids"]) \ + or _personal_action_points != int(saved[&"personal_action_points"]): + return false + var saved_rngs: Dictionary = saved[&"rng_streams"] + if saved_rngs.size() != _rng_streams.size(): + return false + for raw_stream_id: Variant in saved_rngs: + var stream := _rng_streams.get(StringName(raw_stream_id)) as DeterministicRng + if stream == null or not _strict_variants_equal( + stream.snapshot(), saved_rngs[raw_stream_id] + ): + return false + return true + + +func _begin_operations_external_transaction() -> bool: + if _operations_transaction_active: + # One Coordinator minute can contain daily, demand and arrival boundaries. + # Keep their Runtime side effects in one notification/event transaction + # until the Coordinator has committed the complete minute. + return true + _operations_transaction_active = true + _operations_queued_domain_events.clear() + _operations_queued_guest_spawn_notifications.clear() + _operations_queued_economy_notifications.clear() + return true + + +func _commit_operations_domain_events() -> bool: + if not _operations_transaction_active: + return true + var seen: Dictionary = {} + for event: SimulationDomainEvent in _operations_queued_domain_events: + if event == null or not event.is_valid() or seen.has(event.event_id) \ + or domain_events.has_published(event.event_id): + return false + seen[event.event_id] = true + var committed := domain_events.publish_batch(_operations_queued_domain_events) + if not committed: + return false + _operations_queued_domain_events.clear() + _operations_transaction_active = false + return true + + +func _operations_notifications_are_committable() -> bool: + if operations_coordinator == null or guest_service == null: + return false + var seen_guest_ids: Dictionary = {} + for guest_id: StringName in _operations_queued_guest_spawn_notifications: + if guest_id.is_empty() or seen_guest_ids.has(guest_id) \ + or not guest_service.has_guest(guest_id) \ + or not guest_service.has_pending_guest_spawned_notification(guest_id): + return false + seen_guest_ids[guest_id] = true + var seen_transaction_ids: Dictionary = {} + for transaction_id: StringName in _operations_queued_economy_notifications: + if transaction_id.is_empty() or seen_transaction_ids.has(transaction_id) \ + or not economy_service.has_pending_transaction_notification(transaction_id): + return false + seen_transaction_ids[transaction_id] = true + return true + + +func _stage_operations_economy_notification(result: CommandResult) -> bool: + if result == null or not result.is_success(): + return false + var transaction_id := StringName(result.details.get(&"transaction_id", &"")) + if transaction_id.is_empty() \ + or not economy_service.has_pending_transaction_notification(transaction_id) \ + or _operations_queued_economy_notifications.has(transaction_id): + return false + _operations_queued_economy_notifications.append(transaction_id) + # Preserve Runtime's authoritative GameState projection now; the matching raw + # Economy signal is delivered to external observers only after commit. + _on_ledger_transaction_posted( + transaction_id, economy_service.get_transaction_entries(transaction_id) + ) + return true + + +func _discard_operations_domain_events() -> void: + for guest_id: StringName in _operations_queued_guest_spawn_notifications: + guest_service.discard_guest_spawned_notification(guest_id) + for transaction_id: StringName in _operations_queued_economy_notifications: + economy_service.discard_transaction_notification(transaction_id) + _operations_queued_guest_spawn_notifications.clear() + _operations_flushing_guest_spawn_notifications.clear() + _operations_queued_economy_notifications.clear() + _operations_flushing_economy_notifications.clear() + _operations_queued_domain_events.clear() + _operations_transaction_active = false + + +func _commit_operations_notifications() -> bool: + if operations_coordinator == null: + return false + guest_service.set_external_mutation_blocked(true) + var coordinator_notifications_ok := operations_coordinator.commit_deferred_notifications() + var guest_notifications_ok := true + if coordinator_notifications_ok: + for guest_id: StringName in _operations_queued_guest_spawn_notifications: + _operations_flushing_guest_spawn_notifications[guest_id] = true + if not guest_service.commit_guest_spawned_notification(guest_id): + guest_notifications_ok = false + _operations_flushing_guest_spawn_notifications.erase(guest_id) + else: + guest_notifications_ok = false + var economy_notifications_ok := coordinator_notifications_ok and guest_notifications_ok + if economy_notifications_ok: + for transaction_id: StringName in _operations_queued_economy_notifications: + _operations_flushing_economy_notifications[transaction_id] = true + if not economy_service.commit_transaction_notification(transaction_id): + economy_notifications_ok = false + _operations_flushing_economy_notifications.erase(transaction_id) + guest_service.set_external_mutation_blocked(false) + _operations_queued_guest_spawn_notifications.clear() + _operations_flushing_guest_spawn_notifications.clear() + _operations_queued_economy_notifications.clear() + _operations_flushing_economy_notifications.clear() + return coordinator_notifications_ok and guest_notifications_ok and economy_notifications_ok + + +func _publish_domain_event(event: SimulationDomainEvent) -> bool: + if _construction_completion_transaction_active: + if event == null or not event.is_valid() or domain_events.has_published(event.event_id): + return false + for queued: SimulationDomainEvent in _construction_queued_domain_events: + if queued.event_id == event.event_id: + return false + _construction_queued_domain_events.append(event.copy()) + return true + if not _operations_transaction_active: + return domain_events.publish(event) + if event == null or not event.is_valid() or domain_events.has_published(event.event_id): + return false + for queued: SimulationDomainEvent in _operations_queued_domain_events: + if queued.event_id == event.event_id: + return false + _operations_queued_domain_events.append(event.copy()) + return true + + +func _operations_daily_boundary( + payload: Dictionary, + phase: StringName, + token: Variant +) -> Dictionary: + if phase == OperationsCoordinator.PHASE_CAPTURE: + var captured := _capture_operations_external_state() + return {&"ok": not captured.is_empty(), &"token": captured} + if phase == OperationsCoordinator.PHASE_VALIDATE: + return { + &"ok": token is Dictionary and not token.is_empty() \ + and _operations_daily_payload_is_valid(payload), + &"token": token, + } + if phase == OperationsCoordinator.PHASE_APPLY: + return _apply_operations_daily_close(payload, token) + if phase == OperationsCoordinator.PHASE_ROLLBACK: + return {&"ok": _restore_operations_external_state(token)} + return {&"ok": false} + + +func _operations_daily_payload_is_valid(payload: Dictionary) -> bool: + for key: StringName in [ + &"day", &"boundary_minute", &"period_start_minute", + &"period_end_minute_exclusive", + ]: + if typeof(payload.get(key, null)) != TYPE_INT: + return false + var day := int(payload[&"day"]) + var boundary_minute := int(payload[&"boundary_minute"]) + return day > 0 \ + and boundary_minute == _clock.absolute_minute \ + and boundary_minute == day * SimulationKernel.MINUTES_PER_DAY \ + + OperationsCoordinator.MORNING_MINUTE_OF_DAY \ + and int(payload[&"period_start_minute"]) == boundary_minute - SimulationKernel.MINUTES_PER_DAY \ + and int(payload[&"period_end_minute_exclusive"]) == boundary_minute + + +func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dictionary: + if not token is Dictionary or token.is_empty() \ + or not _operations_daily_payload_is_valid(payload): + return {&"ok": false, &"reason": &"DAILY_CLOSE_PRECONDITION_FAILED"} + var prepare_hook := _run_operations_test_boundary_hook( + &"DAILY_CLOSE", payload, OPERATIONS_TEST_PREPARE_PHASE, token + ) + if not bool(prepare_hook.get(&"ok", false)): + return prepare_hook + var day := int(payload[&"day"]) + var preview := staff_service.preview_daily_payroll(day) + if not bool(preview.get(&"ok", false)) \ + or not preview.get(&"rows", null) is Array: + return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_FAILED"} + var available_gold := economy_service.get_balance(PLAYER_CASH_ACCOUNT) + var remaining_gold := available_gold + var payments: Dictionary = {} + var expected_paid_total := 0 + var previous_staff_id := "" + for raw_row: Variant in preview[&"rows"]: + if not raw_row is Dictionary: + return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_INVALID"} + var row: Dictionary = raw_row + var staff_id_value: Variant = row.get(&"staff_id", null) + var total_due_value: Variant = row.get(&"total_due", null) + if typeof(staff_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(staff_id_value).is_empty() \ + or typeof(total_due_value) != TYPE_INT or int(total_due_value) < 0: + return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_INVALID"} + var staff_text := String(staff_id_value) + if not previous_staff_id.is_empty() and staff_text <= previous_staff_id: + return {&"ok": false, &"reason": &"PAYROLL_ORDER_INVALID"} + previous_staff_id = staff_text + var payment := mini(int(total_due_value), remaining_gold) + payments[StringName(staff_id_value)] = payment + remaining_gold -= payment + expected_paid_total += payment + + # Category classification and facility wage allocation are read-only and can + # reject unknown input. Finish them before Staff or Economy receives a write. + var financials := _build_operations_daily_financials( + int(payload[&"period_start_minute"]), + int(payload[&"period_end_minute_exclusive"]), + int(preview.get(&"new_wages_total", -1)), + preview[&"rows"] + ) + if not bool(financials.get(&"ok", false)): + return financials + var settlement_id := StringName("payroll-day-%08d" % day) + var settlement := staff_service.apply_daily_payroll_settlement( + settlement_id, day, payments + ) + if not settlement.is_success() \ + or int(settlement.details.get(&"paid_total", -1)) != expected_paid_total: + return { + &"ok": false, + &"reason": &"PAYROLL_SETTLEMENT_FAILED", + &"result": settlement.to_dictionary(), + } + if expected_paid_total > 0: + var wage_payment := economy_service.transfer( + PLAYER_CASH_ACCOUNT, + WAGE_EXPENSE_ACCOUNT, + expected_paid_total, + int(payload[&"boundary_minute"]), + &"WAGE", + settlement_id, + StringName("operations.wage.day.%08d" % day), + &"operations.payroll.settlement", + &"", + false + ) + if not wage_payment.is_success(): + return { + &"ok": false, + &"reason": &"WAGE_LEDGER_POST_FAILED", + &"result": wage_payment.to_dictionary(), + } + if not _stage_operations_economy_notification(wage_payment): + return { + &"ok": false, + &"reason": &"WAGE_NOTIFICATION_STAGE_FAILED", + &"result": wage_payment.to_dictionary(), + } + var arrears_total := int(settlement.details.get(&"unpaid_after_total", -1)) + if arrears_total < 0 \ + or arrears_total != _staff_payroll_arrears_total(staff_service) \ + or economy_service.get_balance(PLAYER_CASH_ACCOUNT) != available_gold - expected_paid_total \ + or int(_state.get_resource(&"gold")) != economy_service.get_balance(PLAYER_CASH_ACCOUNT) \ + or not bool(economy_service.validate_ledger().get(&"ok", false)) \ + or not bool(staff_service.validate_invariants().get(&"ok", false)): + return {&"ok": false, &"reason": &"DAILY_CLOSE_POSTCONDITION_FAILED"} + var settlement_report := settlement.details.duplicate(true) + settlement_report[&"arrears_total"] = arrears_total + return { + &"ok": true, + &"category_totals": financials[&"category_totals"], + &"reconciliation_difference": financials[&"reconciliation_difference"], + &"facility_profit_loss": financials[&"facility_profit_loss"], + &"anomalies": financials[&"anomalies"], + &"settlement": settlement_report, + } + + +func _build_operations_daily_financials( + period_start: int, + period_end_exclusive: int, + new_wages_total: int, + payroll_rows: Array +) -> Dictionary: + if period_start < 0 or period_end_exclusive <= period_start or new_wages_total < 0: + return {&"ok": false, &"reason": &"DAILY_FINANCIAL_PERIOD_INVALID"} + var category_totals := { + &"revenue": 0, + &"refunds": 0, + &"cost_of_goods": 0, + &"wages": new_wages_total, + &"maintenance": 0, + &"utilities": 0, + &"taxes_and_interest": 0, + &"fines": 0, + &"construction_cash_flow": 0, + } + var facility_rows: Dictionary = {} + var excluded_categories: Array[StringName] = [ + &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", + &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", + &"SERVICE_CASH_SWEEP", &"WAGE", + ] + var category_mapping := { + &"SERVICE_SALE": &"revenue", + &"REFUND": &"refunds", + &"CONSTRUCTION": &"construction_cash_flow", + &"CONSTRUCTION_REFUND": &"construction_cash_flow", + &"AUTHORITY_FINE": &"fines", + &"OPERATING_EXPENSE": &"cost_of_goods", + &"STAFF_SIGNING_COST": &"cost_of_goods", + &"EVENT_ACTION": &"cost_of_goods", + &"EVENT_COST_COMMITTED": &"cost_of_goods", + &"DEFENSE_SKILL_COST": &"cost_of_goods", + } + var operating_refund_total := 0 + for entry: LedgerEntry in economy_service.get_entries(): + if entry.absolute_minute < period_start \ + or entry.absolute_minute >= period_end_exclusive: + continue + if entry.category in excluded_categories: + continue + if entry.category == &"OPERATING_REFUND": + operating_refund_total += entry.amount + continue + if not category_mapping.has(entry.category): + return { + &"ok": false, + &"reason": &"UNKNOWN_LEDGER_CATEGORY", + &"category": entry.category, + &"transaction_id": entry.transaction_id, + } + var category: StringName = category_mapping[entry.category] + category_totals[category] = int(category_totals[category]) + entry.amount + if not entry.source_entity_id.is_empty() \ + and facility_service.has_instance(entry.source_entity_id) \ + and category != &"construction_cash_flow": + if not facility_rows.has(entry.source_entity_id): + facility_rows[entry.source_entity_id] = { + &"revenue": 0, &"expenses": 0, &"net_profit": 0, + } + var facility_row: Dictionary = facility_rows[entry.source_entity_id] + if category == &"revenue": + facility_row[&"revenue"] = int(facility_row[&"revenue"]) + entry.amount + else: + facility_row[&"expenses"] = int(facility_row[&"expenses"]) + entry.amount + var anomalies: Array[Dictionary] = [] + var wage_allocation := _allocate_operations_wages_to_facilities( + period_start, + period_end_exclusive, + new_wages_total, + payroll_rows, + facility_rows + ) + if not bool(wage_allocation.get(&"ok", false)): + return wage_allocation + anomalies.append_array(wage_allocation[&"anomalies"]) + var operating_refund_offset := mini( + operating_refund_total, + int(category_totals[&"cost_of_goods"]) + ) + category_totals[&"cost_of_goods"] = int(category_totals[&"cost_of_goods"]) \ + - operating_refund_offset + var excess_operating_refund := operating_refund_total - operating_refund_offset + if excess_operating_refund > 0: + category_totals[&"revenue"] = int(category_totals[&"revenue"]) \ + + excess_operating_refund + anomalies.append({ + &"code": &"OPERATING_REFUND_EXCEEDS_PERIOD_COST", + &"refund_total": operating_refund_total, + &"cost_offset": operating_refund_offset, + &"excess_revenue": excess_operating_refund, + }) + for facility_id: StringName in _sorted_name_keys(facility_rows): + var row: Dictionary = facility_rows[facility_id] + row[&"net_profit"] = int(row[&"revenue"]) - int(row[&"expenses"]) + var allocation_rows: Array[Dictionary] = [] + for facility_id: StringName in _sorted_name_keys( + wage_allocation[&"allocated_by_facility"] + ): + allocation_rows.append({ + &"facility_id": String(facility_id), + &"amount": int(wage_allocation[&"allocated_by_facility"][facility_id]), + }) + var unallocated_rows: Array[Dictionary] = [] + for staff_id: StringName in _sorted_name_keys( + wage_allocation[&"unallocated_by_staff"] + ): + unallocated_rows.append({ + &"staff_id": String(staff_id), + &"amount": int(wage_allocation[&"unallocated_by_staff"][staff_id]), + }) + var profit_loss_facility_ids: Array[String] = [] + for facility_id: StringName in _sorted_name_keys(facility_rows): + profit_loss_facility_ids.append(String(facility_id)) + anomalies.append({ + &"code": &"WAGE_ALLOCATION_EVIDENCE", + &"version": 1, + &"new_wages_total": new_wages_total, + &"allocated_total": int(wage_allocation[&"allocated_total"]), + &"unallocated_total": int(wage_allocation[&"unallocated_total"]), + &"allocations": allocation_rows, + &"unallocated_by_staff": unallocated_rows, + &"facility_ids": profit_loss_facility_ids, + }) + var daily_summary := economy_service.get_daily_summary(period_start / 1_440 + 1) + if daily_summary.is_empty(): + return {&"ok": false, &"reason": &"LEDGER_PERIOD_SUMMARY_MISSING"} + var difference := int(daily_summary.get(&"total_debit", 0)) \ + - int(daily_summary.get(&"total_credit", 0)) + return { + &"ok": true, + &"category_totals": category_totals, + &"reconciliation_difference": difference, + &"facility_profit_loss": facility_rows, + &"anomalies": anomalies, + } + + +func _allocate_operations_wages_to_facilities( + period_start: int, + period_end_exclusive: int, + new_wages_total: int, + payroll_rows: Array, + facility_rows: Dictionary +) -> Dictionary: + var new_wages_by_staff: Dictionary = {} + var observed_new_wages := 0 + var previous_staff_text := "" + for raw_row: Variant in payroll_rows: + if not raw_row is Dictionary: + return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_INPUT_INVALID"} + var row: Dictionary = raw_row + var staff_id_value: Variant = row.get(&"staff_id", null) + var new_wages_value: Variant = row.get(&"new_wages", null) + if typeof(staff_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(staff_id_value).is_empty() \ + or typeof(new_wages_value) != TYPE_INT or int(new_wages_value) < 0: + return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_INPUT_INVALID"} + var staff_text := String(staff_id_value) + if not previous_staff_text.is_empty() and staff_text <= previous_staff_text: + return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_ORDER_INVALID"} + previous_staff_text = staff_text + new_wages_by_staff[StringName(staff_id_value)] = int(new_wages_value) + observed_new_wages += int(new_wages_value) + if observed_new_wages != new_wages_total: + return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_TOTAL_MISMATCH"} + + var weights_by_staff: Dictionary = {} + for shift: ShiftInstance in staff_service.get_shifts(): + if shift == null or shift.cancelled or shift.assigned_facility_id.is_empty() \ + or not new_wages_by_staff.has(shift.staff_id) \ + or int(new_wages_by_staff[shift.staff_id]) <= 0 \ + or not facility_service.has_instance(shift.assigned_facility_id): + continue + var overlap := maxi( + mini(shift.end_at, period_end_exclusive) - maxi(shift.start_at, period_start), + 0 + ) + if overlap <= 0: + continue + # Recorded duty is the strongest attribution fact. A future/unadvanced Shift + # still uses its scheduled overlap so daily wages never disappear merely + # because the Runtime was restored immediately before the close. + var recorded_minutes := shift.worked_minutes + shift.overtime_minutes + var weight := mini(recorded_minutes, overlap) if recorded_minutes > 0 else overlap + if weight <= 0: + continue + if not weights_by_staff.has(shift.staff_id): + weights_by_staff[shift.staff_id] = {} + var facility_weights: Dictionary = weights_by_staff[shift.staff_id] + facility_weights[shift.assigned_facility_id] = int( + facility_weights.get(shift.assigned_facility_id, 0) + ) + weight + + var anomalies: Array[Dictionary] = [] + var allocated_total := 0 + var allocated_by_facility: Dictionary = {} + var unallocated_by_staff: Dictionary = {} + for staff_id: StringName in _sorted_name_keys(new_wages_by_staff): + var wage := int(new_wages_by_staff[staff_id]) + if wage <= 0: + continue + var weights: Dictionary = weights_by_staff.get(staff_id, {}) + if weights.is_empty(): + unallocated_by_staff[staff_id] = wage + anomalies.append({ + &"code": &"UNALLOCATED_WAGES", + &"staff_id": staff_id, + &"amount": wage, + }) + continue + var total_weight := 0 + for facility_id: StringName in _sorted_name_keys(weights): + total_weight += int(weights[facility_id]) + if total_weight <= 0: + return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_WEIGHT_INVALID"} + var allocations: Dictionary = {} + var floor_total := 0 + var facility_ids := _sorted_name_keys(weights) + for facility_id: StringName in facility_ids: + var amount := wage * int(weights[facility_id]) / total_weight + allocations[facility_id] = amount + floor_total += amount + var remainder := wage - floor_total + for index in range(remainder): + var facility_id: StringName = facility_ids[index] + allocations[facility_id] = int(allocations[facility_id]) + 1 + for facility_id: StringName in facility_ids: + var amount := int(allocations[facility_id]) + if amount <= 0: + continue + if not facility_rows.has(facility_id): + facility_rows[facility_id] = { + &"revenue": 0, &"expenses": 0, &"net_profit": 0, + } + var facility_row: Dictionary = facility_rows[facility_id] + facility_row[&"expenses"] = int(facility_row[&"expenses"]) + amount + allocated_by_facility[facility_id] = int( + allocated_by_facility.get(facility_id, 0) + ) + amount + allocated_total += amount + if allocated_total > new_wages_total: + return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_EXCEEDS_TOTAL"} + return { + &"ok": true, + &"allocated_total": allocated_total, + &"unallocated_total": new_wages_total - allocated_total, + &"allocated_by_facility": allocated_by_facility, + &"unallocated_by_staff": unallocated_by_staff, + &"anomalies": anomalies, + } + + +func _operations_arrival_boundary( + payload: Dictionary, + phase: StringName, + token: Variant +) -> Dictionary: + if phase == OperationsCoordinator.PHASE_CAPTURE: + var captured := _capture_operations_external_state() + var population_cap := ( + int(operations_coordinator.get_config().get(&"population_cap", 0)) + if operations_coordinator != null else 0 + ) + return { + &"ok": not captured.is_empty(), + &"token": captured, + &"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0), + } + if phase == OperationsCoordinator.PHASE_VALIDATE: + return { + &"ok": token is Dictionary and not token.is_empty() \ + and _operations_arrival_payload_is_valid(payload), + &"token": token, + } + if phase == OperationsCoordinator.PHASE_APPLY: + return _apply_operations_arrivals(payload, token) + if phase == OperationsCoordinator.PHASE_ROLLBACK: + return {&"ok": _restore_operations_external_state(token)} + return {&"ok": false} + + +func _operations_arrival_payload_is_valid(payload: Dictionary) -> bool: + var plans_value: Variant = payload.get(&"plans", null) + var total_value: Variant = payload.get(&"total_count", null) + var minute_value: Variant = payload.get(&"minute", null) + if not plans_value is Array or typeof(total_value) != TYPE_INT \ + or int(total_value) < 0 or typeof(minute_value) != TYPE_INT \ + or int(minute_value) != _clock.absolute_minute: + return false + var allowed_classes: Dictionary = {} + for class_id: StringName in mvp_content_catalog.get_guest_class_ids(): + allowed_classes[class_id] = true + var species_ids := mvp_content_catalog.get_species_ids() + if allowed_classes.size() != 8 or species_ids.size() != 4: + return false + var seen_guest_ids: Dictionary = {} + var seen_plan_ids: Dictionary = {} + var counted := 0 + for raw_plan: Variant in plans_value: + if not raw_plan is Dictionary: + return false + var plan: Dictionary = raw_plan + var plan_id_value: Variant = plan.get(&"plan_id", null) + var guest_class_value: Variant = plan.get(&"guest_class", null) + var facility_tag_value: Variant = plan.get(&"facility_tag", null) + var time_band_value: Variant = plan.get(&"time_band", null) + var plan_minute_value: Variant = plan.get(&"minute", null) + var count_value: Variant = plan.get(&"count", null) + var guest_ids_value: Variant = plan.get(&"guest_ids", null) + if typeof(plan_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _operations_sequence_id_is_valid(String(plan_id_value), "arrival-plan-") \ + or seen_plan_ids.has(StringName(plan_id_value)) \ + or typeof(guest_class_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not allowed_classes.has(StringName(guest_class_value)) \ + or typeof(facility_tag_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(facility_tag_value).is_empty() \ + or typeof(time_band_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(time_band_value) not in OperationsCoordinator.TIME_BANDS \ + or typeof(plan_minute_value) != TYPE_INT \ + or int(plan_minute_value) != int(minute_value) \ + or typeof(count_value) != TYPE_INT or int(count_value) <= 0 \ + or not guest_ids_value is Array \ + or guest_ids_value.size() != int(count_value): + return false + seen_plan_ids[StringName(plan_id_value)] = true + for raw_guest_id: Variant in guest_ids_value: + if typeof(raw_guest_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var guest_id := StringName(raw_guest_id) + var actor_id := _guest_path_actor_id(guest_id) + if not _operations_sequence_id_is_valid(String(guest_id), "guest-auto-") \ + or seen_guest_ids.has(guest_id) or guest_service.has_guest(guest_id) \ + or not guest_service.get_persistent_record(guest_id).is_empty() \ + or economy_service.has_account(_guest_wallet_account(guest_id)) \ + or path_service.has_actor_projection(actor_id) \ + or _movement_credits.has(actor_id): + return false + seen_guest_ids[guest_id] = true + counted += 1 + if counted != int(total_value): + return false + var population_cap := int(operations_coordinator.get_config().get(&"population_cap", 0)) + return guest_service.guest_count() + counted <= population_cap \ + and _operations_entrance_cell() != null + + +func _operations_sequence_id_is_valid(value: String, prefix: String) -> bool: + if not value.begins_with(prefix) or value.length() != prefix.length() + 8: + return false + var suffix := value.substr(prefix.length(), 8) + if not suffix.is_valid_int(): + return false + var sequence := int(suffix) + return sequence > 0 and value == "%s%08d" % [prefix, sequence] + + +func _operations_entrance_cell() -> Variant: + var bounds := world_service.get_bounds() + for x in range(int(bounds.get(&"min_x", 0)), int(bounds.get(&"max_x", -1)) + 1): + var cell := Vector3i(x, 0, WorldService.LAYER_FRONT) + if not world_service.is_in_bounds(cell) \ + or world_service.get_zone(cell) != WorldService.ZONE_BOUNDARY: + continue + var traffic := path_service.get_cell_traffic(cell) + if traffic.is_empty() or bool(traffic.get(&"temporary_block", false)): + continue + return cell + return null + + +func _apply_operations_arrivals(payload: Dictionary, token: Variant) -> Dictionary: + if not token is Dictionary or token.is_empty() \ + or not _operations_arrival_payload_is_valid(payload): + return {&"ok": false, &"reason": &"ARRIVAL_PRECONDITION_FAILED"} + var prepare_hook := _run_operations_test_boundary_hook( + &"ARRIVAL_PLAN", payload, OPERATIONS_TEST_PREPARE_PHASE, token + ) + if not bool(prepare_hook.get(&"ok", false)): + return prepare_hook + if int(progression_service.metrics.get(&"guest_lifetime", 0)) \ + > ProgressionService.MAX_SAFE_INTEGER - int(payload[&"total_count"]): + return {&"ok": false, &"reason": &"ARRIVAL_LIFETIME_LIMIT"} + var entrance_value: Variant = _operations_entrance_cell() + var spawn_rng := get_rng_stream(&"guest_spawn") + var species_ids := mvp_content_catalog.get_species_ids() + if entrance_value == null or spawn_rng == null or species_ids.size() != 4: + return {&"ok": false, &"reason": &"ARRIVAL_CONTENT_UNAVAILABLE"} + var entrance: Vector3i = entrance_value + var accepted_count := 0 + var arrivals: Array[Dictionary] = [] + for raw_plan: Variant in payload[&"plans"]: + var plan: Dictionary = raw_plan + var guest_class := StringName(plan[&"guest_class"]) + for raw_guest_id: Variant in plan[&"guest_ids"]: + var guest_id := StringName(raw_guest_id) + var species := species_ids[spawn_rng.randi_range(0, species_ids.size() - 1)] + var definition_id := StringName("%s_%s" % [guest_class, species]) + var definition := guest_service.get_definition(definition_id) + if definition == null or not definition.adult_verified \ + or definition.wealth_min < 0 or definition.wealth_max < definition.wealth_min: + return {&"ok": false, &"reason": &"ARRIVAL_DEFINITION_INVALID"} + var wallet_gold := spawn_rng.randi_range( + definition.wealth_min, definition.wealth_max + ) + var command := SimulationCommand.new( + 1, + _clock.absolute_minute, + _clock.absolute_minute, + 0, + &"spawn_guest", + &"operations_coordinator", + guest_id, + { + &"guest_id": guest_id, + &"guest_type_id": definition_id, + &"gold": wallet_gold, + &"options": { + &"current_cell": entrance, + &"needs": { + &"hunger": 0, &"sleep": 0, &"hygiene": 0, + &"medical": 0, &"leisure": 0, &"intimacy": 0, + }, + &"planned_exit_at": _clock.absolute_minute + 720, + }, + }, + StringName("operations.arrival.%s" % guest_id) + ) + var spawned := _execute_spawn_guest(command) + var guest := guest_service.get_guest(guest_id) + if not spawned.is_success() or guest == null \ + or guest.guest_type_id != definition_id \ + or guest.current_cell != entrance \ + or guest.wallet_gold != wallet_gold \ + or economy_service.get_balance(_guest_wallet_account(guest_id)) != wallet_gold: + return { + &"ok": false, + &"reason": &"ARRIVAL_SPAWN_FAILED", + &"guest_id": guest_id, + &"result": spawned.to_dictionary(), + } + var arrival_row := { + &"guest_id": guest_id, + &"plan_id": StringName(plan[&"plan_id"]), + &"guest_class": guest_class, + &"species": species, + &"definition_id": definition_id, + &"adult_verified": true, + &"wallet_gold": wallet_gold, + &"entrance_cell": entrance, + } + if not _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"GUEST_AUTO_ARRIVED", + _clock.absolute_minute, + &"operations_coordinator", + [guest_id, definition_id], + arrival_row, + 0 + )): + return {&"ok": false, &"reason": &"ARRIVAL_EVENT_FAILED"} + arrivals.append(arrival_row) + accepted_count += 1 + if accepted_count == 1: + var after_mutation_hook := _run_operations_test_boundary_hook( + &"ARRIVAL_PLAN", + payload, + OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE, + token + ) + if not bool(after_mutation_hook.get(&"ok", false)): + return after_mutation_hook + var runtime_crosslinks_ok := _validate_runtime_crosslinks( + facility_service, guest_service, economy_service, inventory_service, + staff_service, _operations_external_committed_minute(), + _staffing_projection_enabled + ) + var path_crosslinks_ok := _validate_runtime_path_crosslinks( + path_service, guest_service, staff_service, _movement_credits, + _clock.absolute_minute + ) + if accepted_count != int(payload[&"total_count"]) \ + or not runtime_crosslinks_ok or not path_crosslinks_ok: + return { + &"ok": false, + &"reason": &"ARRIVAL_POSTCONDITION_FAILED", + &"accepted_count": accepted_count, + &"expected_count": int(payload[&"total_count"]), + &"runtime_crosslinks_ok": runtime_crosslinks_ok, + &"path_crosslinks_ok": path_crosslinks_ok, + } + return { + &"ok": true, + &"accepted_count": accepted_count, + } + + +func _operations_morning_boundary( + payload: Dictionary, + phase: StringName, + token: Variant +) -> Dictionary: + if phase == OperationsCoordinator.PHASE_CAPTURE: + var captured := _capture_operations_external_state() + return {&"ok": not captured.is_empty(), &"token": captured} + if phase == OperationsCoordinator.PHASE_VALIDATE: + return { + &"ok": token is Dictionary and not token.is_empty() \ + and _operations_morning_payload_is_valid(payload), + &"token": token, + } + if phase == OperationsCoordinator.PHASE_APPLY: + return _apply_operations_morning(payload, token) + if phase == OperationsCoordinator.PHASE_ROLLBACK: + return {&"ok": _restore_operations_external_state(token)} + return {&"ok": false} + + +func _operations_morning_payload_is_valid(payload: Dictionary) -> bool: + var day_value: Variant = payload.get(&"day", null) + var minute_value: Variant = payload.get(&"boundary_minute", null) + if typeof(day_value) != TYPE_INT or int(day_value) <= 0 \ + or typeof(minute_value) != TYPE_INT: + return false + var minute := int(minute_value) + return minute == _clock.absolute_minute \ + and posmod(minute, SimulationKernel.MINUTES_PER_DAY) == OPERATIONS_START_MINUTE \ + and int(day_value) == minute / SimulationKernel.MINUTES_PER_DAY + 1 + + +func _apply_operations_morning(payload: Dictionary, token: Variant) -> Dictionary: + if not token is Dictionary or token.is_empty() \ + or not _operations_morning_payload_is_valid(payload): + return {&"ok": false, &"reason": &"MORNING_PRECONDITION_FAILED"} + var prepare_hook := _run_operations_test_boundary_hook( + &"MORNING_BOUNDARY", payload, OPERATIONS_TEST_PREPARE_PHASE, token + ) + if not bool(prepare_hook.get(&"ok", false)): + return prepare_hook + var minute := int(payload[&"boundary_minute"]) + var before_ap := _personal_action_points + _personal_action_points = mini( + _personal_action_points + 2, + MAX_PERSONAL_ACTION_POINTS + ) + var authority_advance := authority_service.advance_time(minute) + if not authority_advance.is_success(): + return { + &"ok": false, + &"reason": &"MORNING_AUTHORITY_ADVANCE_FAILED", + &"result": authority_advance.to_dictionary(), + } + var authority_evaluation := authority_service.evaluate_inspection(minute) + if not authority_evaluation.is_success(): + return { + &"ok": false, + &"reason": &"MORNING_AUTHORITY_EVALUATION_FAILED", + &"result": authority_evaluation.to_dictionary(), + } + if not authority_evaluation.is_replay: + _publish_authority_evaluation(authority_evaluation.details) + _enqueue_pending_relationship_candidates() + # start_next_event owns exactly one expiry pass and one actor audit pass. + # Calling those helpers separately here would double-apply morning effects. + var event_start := event_service.start_next_event( + minute, + _build_event_context(minute) + ) + var no_event_ready := ( + not event_start.is_success() + and event_start.message == "No eligible Event candidate is ready." + ) + var foreground_already_active := ( + not event_start.is_success() + and event_start.message == "A foreground Event is already active." + ) + if not event_start.is_success() and not no_event_ready and not foreground_already_active: + return { + &"ok": false, + &"reason": &"MORNING_EVENT_START_FAILED", + &"result": event_start.to_dictionary(), + } + if _personal_action_points < 0 \ + or _personal_action_points > MAX_PERSONAL_ACTION_POINTS \ + or authority_service.current_minute != minute \ + or not bool(authority_service.validate_invariants().get(&"ok", false)) \ + or not bool(event_service.validate_invariants().get(&"ok", false)) \ + or not bool(relationship_service.validate_invariants().get(&"ok", false)): + return {&"ok": false, &"reason": &"MORNING_POSTCONDITION_FAILED"} + return { + &"ok": true, + &"event_payload": { + &"personal_action_points_before": before_ap, + &"personal_action_points_after": _personal_action_points, + &"authority_advance": authority_advance.to_dictionary(), + &"authority_evaluation": authority_evaluation.to_dictionary(), + &"event_start": event_start.to_dictionary(), + &"no_event_ready": no_event_ready, + &"foreground_already_active": foreground_already_active, + }, + } + + +func _create_operations_runtime_snapshot() -> Dictionary: + return { + &"wrapper_version": OPERATIONS_RUNTIME_WRAPPER_VERSION, + &"enabled": _operations_enabled, + &"personal_action_points": _personal_action_points, + &"runtime_failure_latched": _operations_runtime_failure_latched, + &"runtime_failure_report": _operations_runtime_failure_report.duplicate(true), + &"deferred_runtime_minute": _operations_deferred_runtime_minute, + &"coordinator": ( + operations_coordinator.create_snapshot() + if _operations_enabled and operations_coordinator != null else {} + ), + } + + +func _parse_operations_runtime_snapshot( + raw_wrapper: Variant, + runtime_schema_version: int, + restored_minute: int, + restored_guests: GuestService, + restored_staff: StaffService, + restored_economy: EconomyService, + restored_world_seed: int, + restored_clock_paused: Variant +) -> Dictionary: + var disabled := { + &"ok": true, + &"enabled": false, + &"personal_action_points": 0, + &"runtime_failure_latched": false, + &"runtime_failure_report": {}, + &"deferred_runtime_minute": -1, + &"coordinator": null, + } + if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION: + return disabled + if typeof(restored_clock_paused) != TYPE_BOOL \ + or not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT): + return {&"ok": false} + if not raw_wrapper is Dictionary: + return {&"ok": false} + var wrapper: Dictionary = raw_wrapper + var expected_keys: Array[StringName] = [ + &"wrapper_version", &"enabled", &"personal_action_points", + &"runtime_failure_latched", &"runtime_failure_report", + &"deferred_runtime_minute", &"coordinator", + ] + if not _dictionary_has_exact_canonical_keys(wrapper, expected_keys): + return {&"ok": false} + if typeof(wrapper[&"wrapper_version"]) != TYPE_INT \ + or int(wrapper[&"wrapper_version"]) != OPERATIONS_RUNTIME_WRAPPER_VERSION \ + or typeof(wrapper[&"enabled"]) != TYPE_BOOL \ + or typeof(wrapper[&"personal_action_points"]) != TYPE_INT \ + or typeof(wrapper[&"runtime_failure_latched"]) != TYPE_BOOL \ + or not wrapper[&"runtime_failure_report"] is Dictionary \ + or typeof(wrapper[&"deferred_runtime_minute"]) != TYPE_INT \ + or not wrapper[&"coordinator"] is Dictionary: + return {&"ok": false} + var enabled := bool(wrapper[&"enabled"]) + var action_points := int(wrapper[&"personal_action_points"]) + var failure_latched := bool(wrapper[&"runtime_failure_latched"]) + var failure_report: Dictionary = wrapper[&"runtime_failure_report"] + var deferred_runtime_minute := int(wrapper[&"deferred_runtime_minute"]) + var coordinator_snapshot: Dictionary = wrapper[&"coordinator"] + if action_points < 0 or action_points > MAX_PERSONAL_ACTION_POINTS \ + or failure_latched != not failure_report.is_empty() \ + or (failure_latched \ + and not _operations_failure_report_is_valid(failure_report, restored_minute)): + return {&"ok": false} + if not enabled: + if action_points != 0 or failure_latched or deferred_runtime_minute != -1 \ + or not coordinator_snapshot.is_empty(): + return {&"ok": false} + return disabled + var restored_coordinator := OperationsCoordinator.new() + if not restored_coordinator.restore_snapshot(coordinator_snapshot): + return {&"ok": false} + var coordinator_current_matches := restored_coordinator.current_minute == restored_minute + var pending_boundary_minute := int( + coordinator_snapshot.get(&"pending_boundary_minute", -1) + ) + var deferred_boundary_minute := int( + coordinator_snapshot.get(&"deferred_boundary_minute", -1) + ) + var normal_shape := deferred_runtime_minute == -1 \ + and not failure_latched and not restored_coordinator.pause_requested \ + and not restored_coordinator.is_terminal() \ + and coordinator_current_matches + var failure_shape := deferred_runtime_minute == restored_minute \ + and failure_latched and bool(restored_clock_paused) \ + and restored_coordinator.current_minute == restored_minute - 1 \ + and pending_boundary_minute == restored_minute + var pause_shape := deferred_runtime_minute == restored_minute \ + and not failure_latched and bool(restored_clock_paused) \ + and restored_coordinator.pause_requested and coordinator_current_matches \ + and deferred_boundary_minute == restored_minute + var terminal_shape := deferred_runtime_minute == -1 \ + and not failure_latched and restored_coordinator.is_terminal() \ + and coordinator_current_matches and bool(restored_clock_paused) + if not bool(restored_coordinator.validate_invariants().get(&"ok", false)) \ + or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \ + or not (normal_shape or failure_shape or pause_shape or terminal_shape) \ + or restored_coordinator.get_population_count() != restored_guests.guest_count() \ + or restored_coordinator.get_arrears_total() != _staff_payroll_arrears_total(restored_staff) \ + or not _operations_payroll_crosslinks_are_valid( + restored_coordinator, restored_staff, restored_economy + ) \ + or not _operations_coordinator_configuration_is_canonical( + restored_coordinator, restored_world_seed + ) \ + or not _operations_arrival_guest_crosslinks_are_valid( + restored_coordinator, restored_guests + ) \ + or (restored_coordinator.is_external_quarantined() \ + and not (failure_shape or pause_shape)): + return {&"ok": false} + return { + &"ok": true, + &"enabled": true, + &"personal_action_points": action_points, + &"runtime_failure_latched": failure_latched, + &"runtime_failure_report": failure_report.duplicate(true), + &"deferred_runtime_minute": deferred_runtime_minute, + &"coordinator": restored_coordinator, + } + + +func _progression_guest_lifetime_matches( + progression: ProgressionService, + guests: GuestService +) -> bool: + if progression == null or guests == null: + return false + var guest_snapshot := guests.create_snapshot() + var persistent_value: Variant = guest_snapshot.get(&"persistent_records", null) + var metrics_value: Variant = progression.create_snapshot().get(&"metrics", null) + if not persistent_value is Array or not metrics_value is Dictionary: + return false + var lifetime_value: Variant = (metrics_value as Dictionary).get(&"guest_lifetime", null) + return typeof(lifetime_value) == TYPE_INT \ + and int(lifetime_value) == guests.guest_count() + persistent_value.size() + + +func _operations_arrival_guest_crosslinks_are_valid( + coordinator: OperationsCoordinator, + guests: GuestService +) -> bool: + if coordinator == null or guests == null: + return false + var definitions_by_guest: Dictionary = {} + for guest: GuestRuntime in guests.get_guests(): + definitions_by_guest[guest.guest_id] = guest.guest_type_id + var guest_snapshot := guests.create_snapshot() + var persistent_value: Variant = guest_snapshot.get(&"persistent_records", null) + if not persistent_value is Array: + return false + for raw_record: Variant in persistent_value: + if not raw_record is Dictionary: + return false + var guest_id := StringName((raw_record as Dictionary).get(&"guest_id", &"")) + var definition_id := StringName( + (raw_record as Dictionary).get(&"guest_type_id", &"") + ) + if guest_id.is_empty() or definition_id.is_empty() \ + or definitions_by_guest.has(guest_id): + return false + definitions_by_guest[guest_id] = definition_id + var history_ids: Dictionary = {} + var expected_sequence := 1 + for report: Dictionary in coordinator.get_arrival_reports(): + var plans_value: Variant = report.get(&"plans", null) + if not plans_value is Array: + return false + for raw_plan: Variant in plans_value: + if not raw_plan is Dictionary: + return false + var plan: Dictionary = raw_plan + var guest_class := StringName(plan.get(&"guest_class", &"")) + var ids_value: Variant = plan.get(&"guest_ids", null) + if guest_class.is_empty() or not ids_value is Array: + return false + for raw_guest_id: Variant in ids_value: + if typeof(raw_guest_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var guest_id := StringName(raw_guest_id) + if guest_id != StringName("guest-auto-%08d" % expected_sequence) \ + or history_ids.has(guest_id) \ + or not definitions_by_guest.has(guest_id): + return false + var definition_id := String(definitions_by_guest[guest_id]) + if not definition_id.begins_with("%s_" % String(guest_class)): + return false + history_ids[guest_id] = true + expected_sequence += 1 + for raw_guest_id: Variant in definitions_by_guest: + var guest_id := StringName(raw_guest_id) + if String(guest_id).begins_with("guest-auto-") \ + and not history_ids.has(guest_id): + return false + return true + + +func _guest_aggregate_has_reserved_operations_id(guests: GuestService) -> bool: + if guests == null: + return true + for guest: GuestRuntime in guests.get_guests(): + if String(guest.guest_id).begins_with("guest-auto-"): + return true + var records: Variant = guests.create_snapshot().get(&"persistent_records", null) + if not records is Array: + return true + for raw_record: Variant in records: + if not raw_record is Dictionary \ + or String((raw_record as Dictionary).get(&"guest_id", "")).begins_with( + "guest-auto-" + ): + return true + return false + + +func _operations_payroll_crosslinks_are_valid( + coordinator: OperationsCoordinator, + staff: StaffService, + economy: EconomyService +) -> bool: + if coordinator == null or staff == null or economy == null: + return false + var staff_snapshot := staff.create_snapshot() + var settlement_values: Variant = staff_snapshot.get(&"payroll_settlements", null) + if not settlement_values is Array: + return false + var summaries := coordinator.get_day_summaries() + if summaries.size() != settlement_values.size(): + return false + var expected_wage_transactions: Dictionary = {} + var settlements_by_day: Dictionary = {} + for raw_settlement: Variant in settlement_values: + if not raw_settlement is Dictionary: + return false + var serialized: Dictionary = raw_settlement + var day_value: Variant = serialized.get(&"day", null) + var settlement_id_value: Variant = serialized.get(&"settlement_id", null) + if typeof(day_value) != TYPE_INT \ + or typeof(settlement_id_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var day := int(day_value) + var settlement_id := StringName(settlement_id_value) + if day <= 0 or settlement_id != StringName("payroll-day-%08d" % day) \ + or settlements_by_day.has(day): + return false + var staff_record := staff.get_payroll_settlement(settlement_id) + if staff_record.is_empty(): + return false + settlements_by_day[day] = staff_record + var paid_total := int(staff_record.get(&"paid_total", -1)) + if paid_total < 0: + return false + var idempotency_key := StringName("operations.wage.day.%08d" % day) + var transaction_id := economy.get_transaction_id_for_idempotency_key(idempotency_key) + if paid_total == 0: + if not transaction_id.is_empty(): + return false + continue + if transaction_id.is_empty() or expected_wage_transactions.has(transaction_id): + return false + var entries := economy.get_transaction_entries(transaction_id) + if entries.size() != 1: + return false + var entry: LedgerEntry = entries[0] + if entry.absolute_minute != day * SimulationKernel.MINUTES_PER_DAY \ + + OperationsCoordinator.MORNING_MINUTE_OF_DAY \ + or entry.debit_account != WAGE_EXPENSE_ACCOUNT \ + or entry.credit_account != PLAYER_CASH_ACCOUNT \ + or entry.amount != paid_total or entry.category != &"WAGE" \ + or entry.source_entity_id != settlement_id \ + or entry.memo_key != &"operations.payroll.settlement" \ + or not entry.related_transaction_id.is_empty(): + return false + expected_wage_transactions[transaction_id] = true + + for summary: Dictionary in summaries: + var day := int(summary.get(&"day", -1)) + if not settlements_by_day.has(day): + return false + var summary_settlement_value: Variant = summary.get(&"settlement", null) + if not summary_settlement_value is Dictionary: + return false + var summary_settlement: Dictionary = summary_settlement_value.duplicate(true) + var staff_record: Dictionary = settlements_by_day[day] + var expected_categories: Variant = _operations_expected_category_totals( + economy, + (day - 1) * SimulationKernel.MINUTES_PER_DAY \ + + OperationsCoordinator.MORNING_MINUTE_OF_DAY, + day * SimulationKernel.MINUTES_PER_DAY \ + + OperationsCoordinator.MORNING_MINUTE_OF_DAY, + int(staff_record.get(&"new_wages_total", -1)) + ) + if expected_categories == null \ + or not _strict_variants_equal( + _operations_snapshot_comparison_value( + summary.get(&"category_totals", {}) + ), + _operations_snapshot_comparison_value(expected_categories) + ) \ + or not _operations_facility_wage_summary_is_valid( + summary, staff_record, staff, economy + ): + return false + if int(summary_settlement.get(&"arrears_total", -1)) \ + != int(staff_record.get(&"unpaid_after_total", -2)) \ + or summary_settlement.get(&"replayed", null) != false: + return false + summary_settlement.erase(&"arrears_total") + summary_settlement.erase(&"replayed") + # OperationsCoordinator persists history in a transport-safe form, so + # StringName identifiers become String values after a round-trip. Compare + # the redundant Staff/Coordinator evidence in that same canonical form; + # numeric and structural types remain strict. + if not _strict_variants_equal( + _operations_snapshot_comparison_value(summary_settlement), + _operations_snapshot_comparison_value(staff_record) + ): + return false + + for entry: LedgerEntry in economy.get_entries(): + if entry.category == &"WAGE" \ + and not expected_wage_transactions.has(entry.transaction_id): + return false + return true + + +func _operations_expected_category_totals( + economy: EconomyService, + period_start: int, + period_end_exclusive: int, + new_wages_total: int +) -> Variant: + if economy == null or period_start < 0 or period_end_exclusive <= period_start \ + or new_wages_total < 0: + return null + var totals := { + &"revenue": 0, &"refunds": 0, &"cost_of_goods": 0, + &"wages": new_wages_total, &"maintenance": 0, &"utilities": 0, + &"taxes_and_interest": 0, &"fines": 0, + &"construction_cash_flow": 0, + } + var excluded: Array[StringName] = [ + &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", + &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", + &"SERVICE_CASH_SWEEP", &"WAGE", + ] + var mapping := { + &"SERVICE_SALE": &"revenue", &"REFUND": &"refunds", + &"CONSTRUCTION": &"construction_cash_flow", + &"CONSTRUCTION_REFUND": &"construction_cash_flow", + &"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods", + &"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods", + &"EVENT_COST_COMMITTED": &"cost_of_goods", + &"DEFENSE_SKILL_COST": &"cost_of_goods", + } + var operating_refunds := 0 + for entry: LedgerEntry in economy.get_entries(): + if entry.absolute_minute < period_start \ + or entry.absolute_minute >= period_end_exclusive: + continue + if entry.category in excluded: + continue + if entry.category == &"OPERATING_REFUND": + operating_refunds += entry.amount + continue + if not mapping.has(entry.category): + return null + var category: StringName = mapping[entry.category] + totals[category] = int(totals[category]) + entry.amount + var offset := mini(operating_refunds, int(totals[&"cost_of_goods"])) + totals[&"cost_of_goods"] = int(totals[&"cost_of_goods"]) - offset + totals[&"revenue"] = int(totals[&"revenue"]) + operating_refunds - offset + return totals + + +func _operations_facility_wage_summary_is_valid( + summary: Dictionary, + staff_record: Dictionary, + _staff: StaffService, + economy: EconomyService +) -> bool: + var facilities_value: Variant = summary.get(&"facility_profit_loss", null) + var anomalies_value: Variant = summary.get(&"anomalies", null) + if not facilities_value is Dictionary or not anomalies_value is Array: + return false + var evidence: Dictionary = {} + var observed_unallocated: Dictionary = {} + for raw_anomaly: Variant in anomalies_value: + if not raw_anomaly is Dictionary: + return false + var anomaly: Dictionary = raw_anomaly + match StringName(anomaly.get(&"code", &"")): + &"WAGE_ALLOCATION_EVIDENCE": + if not evidence.is_empty() or not _dictionary_has_exact_canonical_keys( + anomaly, + [&"code", &"version", &"new_wages_total", &"allocated_total", + &"unallocated_total", &"allocations", &"unallocated_by_staff", + &"facility_ids"] + ): + return false + evidence = anomaly + &"UNALLOCATED_WAGES": + if not _dictionary_has_exact_canonical_keys( + anomaly, [&"code", &"staff_id", &"amount"] + ): + return false + var staff_id := StringName(anomaly.get(&"staff_id", &"")) + var amount_value: Variant = anomaly.get(&"amount", null) + if staff_id.is_empty() or typeof(amount_value) != TYPE_INT \ + or int(amount_value) <= 0 or observed_unallocated.has(staff_id): + return false + observed_unallocated[staff_id] = int(amount_value) + &"OPERATING_REFUND_EXCEEDS_PERIOD_COST": + if not _dictionary_has_exact_canonical_keys( + anomaly, [&"code", &"refund_total", &"cost_offset", &"excess_revenue"] + ): + return false + _: + return false + if evidence.is_empty() or int(evidence.get(&"version", -1)) != 1 \ + or typeof(evidence.get(&"new_wages_total", null)) != TYPE_INT \ + or int(evidence[&"new_wages_total"]) \ + != int(staff_record.get(&"new_wages_total", -1)) \ + or typeof(evidence.get(&"allocated_total", null)) != TYPE_INT \ + or typeof(evidence.get(&"unallocated_total", null)) != TYPE_INT \ + or not evidence.get(&"allocations", null) is Array \ + or not evidence.get(&"unallocated_by_staff", null) is Array \ + or not evidence.get(&"facility_ids", null) is Array: + return false + var allocations: Dictionary = {} + var previous_facility := "" + var allocated_total := 0 + for raw_row: Variant in evidence[&"allocations"]: + if not raw_row is Dictionary or not _dictionary_has_exact_canonical_keys( + raw_row, [&"facility_id", &"amount"] + ): + return false + var facility_id := StringName((raw_row as Dictionary).get(&"facility_id", &"")) + var amount_value: Variant = (raw_row as Dictionary).get(&"amount", null) + if facility_id.is_empty() or typeof(amount_value) != TYPE_INT \ + or int(amount_value) <= 0 or allocations.has(facility_id) \ + or (not previous_facility.is_empty() \ + and String(facility_id) <= previous_facility): + return false + allocations[facility_id] = int(amount_value) + allocated_total += int(amount_value) + previous_facility = String(facility_id) + var unallocated: Dictionary = {} + var previous_staff := "" + var unallocated_total := 0 + for raw_row: Variant in evidence[&"unallocated_by_staff"]: + if not raw_row is Dictionary or not _dictionary_has_exact_canonical_keys( + raw_row, [&"staff_id", &"amount"] + ): + return false + var staff_id := StringName((raw_row as Dictionary).get(&"staff_id", &"")) + var amount_value: Variant = (raw_row as Dictionary).get(&"amount", null) + if staff_id.is_empty() or typeof(amount_value) != TYPE_INT \ + or int(amount_value) <= 0 or unallocated.has(staff_id) \ + or (not previous_staff.is_empty() and String(staff_id) <= previous_staff): + return false + unallocated[staff_id] = int(amount_value) + unallocated_total += int(amount_value) + previous_staff = String(staff_id) + var expected_facility_ids: Dictionary = {} + var previous_id := "" + for raw_id: Variant in evidence[&"facility_ids"]: + if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var facility_id := StringName(raw_id) + if facility_id.is_empty() or expected_facility_ids.has(facility_id) \ + or (not previous_id.is_empty() and String(facility_id) <= previous_id): + return false + expected_facility_ids[facility_id] = true + previous_id = String(facility_id) + var new_wages_total := int(evidence[&"new_wages_total"]) + if allocated_total != int(evidence[&"allocated_total"]) \ + or unallocated_total != int(evidence[&"unallocated_total"]) \ + or allocated_total + unallocated_total != new_wages_total \ + or not _strict_variants_equal(observed_unallocated, unallocated): + return false + for facility_id: StringName in allocations: + if not expected_facility_ids.has(facility_id): + return false + var facility_rows: Dictionary = facilities_value + if facility_rows.size() != expected_facility_ids.size(): + return false + for raw_id: Variant in facility_rows: + if not expected_facility_ids.has(StringName(raw_id)): + return false + var period_start := int(summary.get(&"period_start_minute", -1)) + var period_end := int(summary.get(&"period_end_minute_exclusive", -1)) + if period_start < 0 or period_end <= period_start: + return false + var base_rows: Dictionary = {} + for facility_id: StringName in expected_facility_ids: + base_rows[facility_id] = {&"revenue": 0, &"expenses": 0} + var excluded: Array[StringName] = [ + &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", + &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", + &"SERVICE_CASH_SWEEP", &"WAGE", &"OPERATING_REFUND", + ] + var mapping := { + &"SERVICE_SALE": &"revenue", &"REFUND": &"expenses", + &"AUTHORITY_FINE": &"expenses", &"OPERATING_EXPENSE": &"expenses", + &"STAFF_SIGNING_COST": &"expenses", &"EVENT_ACTION": &"expenses", + &"EVENT_COST_COMMITTED": &"expenses", &"DEFENSE_SKILL_COST": &"expenses", + } + for entry: LedgerEntry in economy.get_entries(): + if entry.absolute_minute < period_start or entry.absolute_minute >= period_end \ + or entry.category in excluded \ + or entry.category in [&"CONSTRUCTION", &"CONSTRUCTION_REFUND"] \ + or not expected_facility_ids.has(entry.source_entity_id): + continue + if not mapping.has(entry.category): + return false + var base: Dictionary = base_rows[entry.source_entity_id] + var key: StringName = mapping[entry.category] + base[key] = int(base[key]) + entry.amount + for facility_id: StringName in expected_facility_ids: + var row_value: Variant = facility_rows.get(facility_id, null) + if not row_value is Dictionary: + return false + var row: Dictionary = row_value + var base: Dictionary = base_rows[facility_id] + var expected_revenue := int(base[&"revenue"]) + var expected_expenses := int(base[&"expenses"]) \ + + int(allocations.get(facility_id, 0)) + if int(row.get(&"revenue", -1)) != expected_revenue \ + or int(row.get(&"expenses", -1)) != expected_expenses \ + or int(row.get(&"net_profit", 0)) != expected_revenue - expected_expenses: + return false + return true + + +func _operations_facility_wage_summary_from_live_shifts_is_valid( + summary: Dictionary, + staff_record: Dictionary, + staff: StaffService, + economy: EconomyService +) -> bool: + var facilities_value: Variant = summary.get(&"facility_profit_loss", null) + var rows_value: Variant = staff_record.get(&"rows", null) + var anomalies_value: Variant = summary.get(&"anomalies", null) + if not facilities_value is Dictionary or not rows_value is Array \ + or not anomalies_value is Array: + return false + var facility_rows: Dictionary = facilities_value + var period_start := int(summary.get(&"period_start_minute", -1)) + var period_end := int(summary.get(&"period_end_minute_exclusive", -1)) + if period_start < 0 or period_end <= period_start: + return false + var wages_by_staff: Dictionary = {} + for raw_row: Variant in rows_value: + if not raw_row is Dictionary: + return false + var row: Dictionary = raw_row + var staff_id := StringName(row.get(&"staff_id", &"")) + var wage_value: Variant = row.get(&"new_wages", null) + if staff_id.is_empty() or typeof(wage_value) != TYPE_INT \ + or int(wage_value) < 0 or wages_by_staff.has(staff_id): + return false + wages_by_staff[staff_id] = int(wage_value) + var weights_by_staff: Dictionary = {} + for shift: ShiftInstance in staff.get_shifts(): + if shift == null or shift.cancelled or shift.assigned_facility_id.is_empty() \ + or not wages_by_staff.has(shift.staff_id) \ + or int(wages_by_staff[shift.staff_id]) <= 0: + continue + var overlap := maxi( + mini(shift.end_at, period_end) - maxi(shift.start_at, period_start), 0 + ) + if overlap <= 0: + continue + var recorded := shift.worked_minutes + shift.overtime_minutes + var weight := mini(recorded, overlap) if recorded > 0 else overlap + if weight <= 0: + continue + if not weights_by_staff.has(shift.staff_id): + weights_by_staff[shift.staff_id] = {} + var weights: Dictionary = weights_by_staff[shift.staff_id] + weights[shift.assigned_facility_id] = int( + weights.get(shift.assigned_facility_id, 0) + ) + weight + var expected_allocations: Dictionary = {} + var expected_unallocated: Dictionary = {} + for staff_id: StringName in _sorted_name_keys(wages_by_staff): + var wage := int(wages_by_staff[staff_id]) + if wage <= 0: + continue + var weights: Dictionary = weights_by_staff.get(staff_id, {}) + if weights.is_empty(): + expected_unallocated[staff_id] = wage + continue + var facility_ids := _sorted_name_keys(weights) + var total_weight := 0 + for facility_id: StringName in facility_ids: + total_weight += int(weights[facility_id]) + if total_weight <= 0: + return false + var allocated := 0 + for facility_id: StringName in facility_ids: + var amount := wage * int(weights[facility_id]) / total_weight + expected_allocations[facility_id] = int( + expected_allocations.get(facility_id, 0) + ) + amount + allocated += amount + var remainder := wage - allocated + for index in range(remainder): + var facility_id: StringName = facility_ids[index] + expected_allocations[facility_id] = int(expected_allocations[facility_id]) + 1 + + var base_rows: Dictionary = {} + var excluded: Array[StringName] = [ + &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", + &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", + &"SERVICE_CASH_SWEEP", &"WAGE", &"OPERATING_REFUND", + ] + var mapping := { + &"SERVICE_SALE": &"revenue", &"REFUND": &"expense", + &"AUTHORITY_FINE": &"expense", &"OPERATING_EXPENSE": &"expense", + &"STAFF_SIGNING_COST": &"expense", &"EVENT_ACTION": &"expense", + &"EVENT_COST_COMMITTED": &"expense", &"DEFENSE_SKILL_COST": &"expense", + } + for entry: LedgerEntry in economy.get_entries(): + if entry.absolute_minute < period_start or entry.absolute_minute >= period_end \ + or entry.category in excluded \ + or entry.category in [&"CONSTRUCTION", &"CONSTRUCTION_REFUND"] \ + or entry.source_entity_id.is_empty() \ + or not facility_rows.has(entry.source_entity_id): + continue + if not mapping.has(entry.category): + return false + if not base_rows.has(entry.source_entity_id): + base_rows[entry.source_entity_id] = {&"revenue": 0, &"expenses": 0} + var base: Dictionary = base_rows[entry.source_entity_id] + var key: StringName = mapping[entry.category] + if key == &"revenue": + base[&"revenue"] = int(base[&"revenue"]) + entry.amount + else: + base[&"expenses"] = int(base[&"expenses"]) + entry.amount + for raw_facility_id: Variant in facility_rows: + var facility_id := StringName(raw_facility_id) + var row_value: Variant = facility_rows[raw_facility_id] + if facility_id.is_empty() or not row_value is Dictionary: + return false + var row: Dictionary = row_value + var base: Dictionary = base_rows.get( + facility_id, {&"revenue": 0, &"expenses": 0} + ) + if int(row.get(&"revenue", -1)) != int(base[&"revenue"]) \ + or int(row.get(&"expenses", -1)) \ + != int(base[&"expenses"]) + int(expected_allocations.get(facility_id, 0)): + return false + for facility_id: StringName in expected_allocations: + if not facility_rows.has(facility_id): + return false + var observed_unallocated: Dictionary = {} + for raw_anomaly: Variant in anomalies_value: + if not raw_anomaly is Dictionary: + return false + var anomaly: Dictionary = raw_anomaly + if StringName(anomaly.get(&"code", &"")) != &"UNALLOCATED_WAGES": + continue + var staff_id := StringName(anomaly.get(&"staff_id", &"")) + var amount_value: Variant = anomaly.get(&"amount", null) + if staff_id.is_empty() or typeof(amount_value) != TYPE_INT \ + or int(amount_value) <= 0 or observed_unallocated.has(staff_id): + return false + observed_unallocated[staff_id] = int(amount_value) + return _strict_variants_equal(observed_unallocated, expected_unallocated) + + +func _operations_coordinator_configuration_is_canonical( + coordinator: OperationsCoordinator, + expected_world_seed: int +) -> bool: + if coordinator == null: + return false + var expected := OperationsCoordinator.new({ + &"start_minute": OPERATIONS_COORDINATOR_ORIGIN_MINUTE, + &"world_seed": expected_world_seed, + }) + if not _strict_variants_equal(coordinator.get_config(), expected.get_config()): + return false + var profiles := coordinator.get_demand_profiles() + if profiles.size() != DEFAULT_OPERATIONS_DEMAND_PROFILES.size(): + return false + var expected_identities: Dictionary = {} + for raw_profile: Variant in DEFAULT_OPERATIONS_DEMAND_PROFILES: + var profile: Dictionary = raw_profile + var key := "%s|%s|%s" % [ + profile[&"facility_tag"], profile[&"guest_class"], profile[&"time_band"], + ] + expected_identities[key] = true + var seen: Dictionary = {} + var expected_components := { + &"base": 100, &"reputation": 0, &"unmet_need": 0, + &"event": 0, &"recent_price_penalty": 0, &"wait_penalty": 0, + } + for profile: Dictionary in profiles: + var key := "%s|%s|%s" % [ + profile.get(&"facility_tag", &""), + profile.get(&"guest_class", &""), + profile.get(&"time_band", &""), + ] + if not expected_identities.has(key) or seen.has(key) \ + or not _strict_variants_equal( + profile.get(&"components", {}), expected_components + ): + return false + seen[key] = true + return seen.size() == expected_identities.size() + + +func _staff_payroll_arrears_total(service: StaffService) -> int: + var total := 0 + for row: Dictionary in service.get_payroll_obligations(): + var unpaid: Variant = row.get(&"unpaid_wages", null) + if typeof(unpaid) != TYPE_INT or int(unpaid) < 0: + return -1 + total += int(unpaid) + return total + + +func _operations_failure_report_is_valid(report: Dictionary, minute: int) -> bool: + if report.size() == 2 \ + and _dictionary_has_exact_canonical_keys(report, [&"code", &"minute"]): + var simple_code: Variant = report.get(&"code", null) + var simple_minute: Variant = report.get(&"minute", null) + return typeof(simple_code) in [TYPE_STRING, TYPE_STRING_NAME] \ + and not String(simple_code).is_empty() \ + and typeof(simple_minute) == TYPE_INT and int(simple_minute) == minute + var result_keys: Array[StringName] = [ + &"code", &"command_id", &"target_id", &"message", + &"resolution_hints", &"details", &"is_replay", + ] + if not _dictionary_has_exact_canonical_keys(report, result_keys): + return false + var code_value: Variant = report.get(&"code", null) + var command_id_value: Variant = report.get(&"command_id", null) + var target_value: Variant = report.get(&"target_id", null) + var message_value: Variant = report.get(&"message", null) + var hints_value: Variant = report.get(&"resolution_hints", null) + var details_value: Variant = report.get(&"details", null) + if typeof(code_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or String(code_value).is_empty() or String(code_value) == "OK" \ + or typeof(command_id_value) != TYPE_INT or int(command_id_value) < 0 \ + or typeof(target_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(message_value) != TYPE_STRING \ + or not hints_value is Array or hints_value.size() > 32 \ + or not details_value is Dictionary \ + or typeof(report.get(&"is_replay", null)) != TYPE_BOOL: + return false + for raw_hint: Variant in hints_value: + if typeof(raw_hint) != TYPE_STRING or String(raw_hint).length() > 4_096: + return false + return JSON.stringify(details_value).length() <= 262_144 + + +func _dictionary_has_exact_canonical_keys( + source: Dictionary, + expected_keys: Array[StringName] +) -> bool: + if source.size() != expected_keys.size(): + return false + var seen: Dictionary = {} + for raw_key: Variant in source: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var key := StringName(raw_key) + if not expected_keys.has(key) or seen.has(key): + return false + seen[key] = true + return seen.size() == expected_keys.size() + + +func _operations_api_failure( + code: StringName, + message: String, + details: Dictionary = {} +) -> Dictionary: + return { + &"ok": false, + &"code": code, + &"message": message, + &"details": details.duplicate(true), + } + + +func register_save_migration_step( + from_version: int, + migration_id: StringName, + migrator: Callable +) -> Dictionary: + return save_migration_registry.register_step(from_version, migration_id, migrator) + + +func queue_checkpoint( + save_path := "", + metadata := {}, + use_autosave := false, + reason: StringName = &"manual" +) -> Dictionary: + if not _initialized: + return { + &"ok": false, + &"code": &"RUNTIME_NOT_INITIALIZED", + &"message": "Simulation runtime is not initialized.", + } + var resolved_path := str(save_path) + if resolved_path.is_empty(): + resolved_path = str(_save_policy[&"autosave_path"]) + var request_id := _next_checkpoint_request_id + _next_checkpoint_request_id += 1 + _pending_checkpoints.append({ + &"request_id": request_id, + &"save_path": resolved_path, + &"metadata": metadata.duplicate(true) if metadata is Dictionary else {}, + &"use_autosave": bool(use_autosave), + &"reason": reason if not reason.is_empty() else &"manual", + }) + return { + &"ok": true, + &"code": &"CHECKPOINT_QUEUED", + &"request_id": request_id, + &"save_path": resolved_path, + &"queued_for_tick_boundary": true, + &"queued_at_absolute_minute": _clock.absolute_minute, + } + + +func queue_manual_checkpoint(save_path: String, metadata := {}) -> Dictionary: + return queue_checkpoint(save_path, metadata, false, &"manual") + + +func save_to_path(save_path: String, metadata := {}) -> Dictionary: + return _perform_checkpoint_save(save_path, metadata, false, &"manual", 0) + + +func inspect_recovery_candidates( + save_path := "", + generation_count := -1 +) -> Dictionary: + var resolved_path := str(save_path) + if resolved_path.is_empty(): + resolved_path = str(_save_policy[&"autosave_path"]) + var resolved_generations := generation_count + if resolved_generations < 0: + resolved_generations = int(_save_policy[&"autosave_generations"]) + var started_at_us := Time.get_ticks_usec() + var result := SaveService.inspect_recovery_candidates( + resolved_path, + resolved_generations + ) + var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0) + _record_runtime_performance(&"load", runtime_duration_us) + return _with_operation_context(result, { + &"operation": &"inspect_recovery", + &"save_path": resolved_path, + &"runtime_duration_us": runtime_duration_us, + }) + + +func load_from_path(save_path: String) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var previous_paused := _clock.paused + _clock.set_paused(true) + var result := SaveService.load_snapshot(save_path) + if not result[&"ok"] and result[&"code"] == &"UNSUPPORTED_FORMAT" \ + and bool(_save_policy.get(&"auto_migrate", true)): + var migration := SaveService.migrate_save( + save_path, + save_migration_registry, + SaveService.FORMAT_VERSION + ) + _record_runtime_performance(&"save", int(migration.get(&"duration_us", 0))) + if migration[&"ok"]: + result = SaveService.load_snapshot(save_path) + else: + result = _with_operation_context(migration, { + &"operation": &"load", + &"migration_attempted": true, + }) + return _finish_load_operation( + result, + save_path, + previous_paused, + started_at_us, + &"load", + &"SAVE_LOADED", + &"SAVE_LOAD_FAILED" + ) + + +func load_with_recovery( + save_path := "", + generation_count := -1 +) -> Dictionary: + var resolved_path := str(save_path) + if resolved_path.is_empty(): + resolved_path = str(_save_policy[&"autosave_path"]) + var resolved_generations := generation_count + if resolved_generations < 0: + resolved_generations = int(_save_policy[&"autosave_generations"]) + var started_at_us := Time.get_ticks_usec() + var previous_paused := _clock.paused + # Recovery is a two-stage decision. SaveService establishes package, + # checksum, format, and generic snapshot validity; Runtime then tries every + # package-loadable candidate in the same stable order against authoritative + # domain and cross-link rules. Failed domain attempts never reach the commit + # boundary in _restore_snapshot_internal. + var live_snapshot_before := create_snapshot() + _clock.set_paused(true) + var package_inventory := SaveService.inspect_recovery_candidates( + resolved_path, + resolved_generations + ) + var result := _attempt_domain_valid_recovery( + resolved_path, + package_inventory + ) + return _finish_recovery_operation( + result, + resolved_path, + previous_paused, + started_at_us, + live_snapshot_before + ) + + +func _attempt_domain_valid_recovery( + save_path: String, + package_inventory: Dictionary +) -> Dictionary: + var package_report := package_inventory.duplicate(true) + # The selected package snapshot is an implementation detail of the package + # inspection pass. Every actual attempt is loaded again through the explicit, + # read-only candidate API so the final selection cannot accidentally reuse a + # different candidate's payload. + package_report.erase(&"snapshot") + var package_recovery_report: Dictionary = {} + var package_recovery_value: Variant = package_inventory.get(&"recovery_report", {}) + if package_recovery_value is Dictionary: + package_recovery_report = package_recovery_value.duplicate(true) + var raw_candidates_value: Variant = package_inventory.get(&"candidates", []) + var candidates: Array[Dictionary] = [] + if raw_candidates_value is Array: + for raw_candidate: Variant in raw_candidates_value: + if not raw_candidate is Dictionary: + continue + var candidate: Dictionary = raw_candidate.duplicate(true) + var package_loadable := bool(candidate.get(&"valid", false)) + candidate[&"package_loadable"] = package_loadable + candidate[&"package_status"] = &"VALID" if package_loadable else &"REJECTED" + candidate[&"package_rejected"] = not package_loadable + candidate[&"package_rejection_code"] = ( + &"" if package_loadable else StringName(candidate.get(&"code", &"PACKAGE_REJECTED")) + ) + candidate[&"domain_status"] = &"NOT_ATTEMPTED" + candidate[&"domain_attempted"] = false + candidate[&"domain_valid"] = false + candidate[&"domain_rejected"] = false + candidate[&"domain_rejection_code"] = &"" + candidate[&"failed_attempt_live_state_preserved"] = true + candidate[&"selected"] = false + candidates.append(candidate) + + if not bool(package_inventory.get(&"ok", false)) \ + and not _has_package_loadable_recovery_candidate(candidates): + return _recovery_failure_result( + StringName(package_inventory.get(&"code", &"NO_VALID_RECOVERY_CANDIDATE")), + str(package_inventory.get(&"message", save_path)), + candidates, + package_report, + package_recovery_report, + &"NO_PACKAGE_VALID_RECOVERY_CANDIDATE" + ) + + for candidate_index in range(candidates.size()): + var candidate: Dictionary = candidates[candidate_index] + if not bool(candidate[&"package_loadable"]): + continue + var candidate_id := StringName(candidate.get(&"id", &"")) + var generation := int(candidate.get(&"generation", -1)) + var loaded := SaveService.load_recovery_candidate( + save_path, + candidate_id, + generation + ) + if not bool(loaded.get(&"ok", false)): + # A candidate may disappear or become corrupt between inventory and + # its explicit load. Treat that race as a package rejection and keep + # walking the already-established priority order. + candidate[&"package_status"] = &"REJECTED" + candidate[&"package_rejected"] = true + candidate[&"package_load_succeeded"] = false + candidate[&"package_rejection_code"] = StringName( + loaded.get(&"code", &"PACKAGE_LOAD_FAILED") + ) + candidate[&"package_rejection_message"] = str(loaded.get(&"message", "")) + candidates[candidate_index] = candidate + continue + + candidate[&"package_load_succeeded"] = true + candidate[&"domain_attempted"] = true + var package_attempt_report: Dictionary = {} + var loaded_report_value: Variant = loaded.get(&"recovery_report", {}) + if loaded_report_value is Dictionary: + package_attempt_report = loaded_report_value.duplicate(true) + var attempt_live_guard := create_snapshot() + var attempt_pause_guard := _clock.paused + if not _restore_snapshot_internal(loaded[&"snapshot"], true): + candidate[&"domain_status"] = &"REJECTED" + candidate[&"domain_rejected"] = true + candidate[&"domain_rejection_code"] = &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED" + candidate[&"failed_attempt_live_state_preserved"] = ( + create_snapshot() == attempt_live_guard + and _clock.paused == attempt_pause_guard + ) + candidates[candidate_index] = candidate + continue + + candidate[&"domain_status"] = &"VALID" + candidate[&"domain_valid"] = true + candidate[&"selected"] = true + candidates[candidate_index] = candidate + var result := loaded.duplicate(true) + result[&"ok"] = true + result[&"code"] = &"OK" + result[&"selection_rule"] = package_inventory.get( + &"selection_rule", + &"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST" + ) + result[&"selected"] = candidate.duplicate(true) + result[&"candidates"] = candidates.duplicate(true) + result[&"candidate_attempts"] = candidates.duplicate(true) + result[&"package_candidate_report"] = package_report + result[&"package_recovery_report"] = package_attempt_report + result[&"restore_safety_report"] = latest_restore_safety_report.duplicate(true) + result[&"recovery_report"] = latest_restore_safety_report.duplicate(true) + result[&"recovery_report"][&"restore_committed"] = true + result[&"recovery_report"][&"selected_candidate_id"] = candidate_id + result[&"recovery_report"][&"package_rejections"] = _recovery_package_rejections(candidates) + result[&"recovery_report"][&"domain_rejections"] = _recovery_domain_rejections(candidates) + return result + + return _recovery_failure_result( + &"RESTORE_FAILED", + save_path, + candidates, + package_report, + package_recovery_report, + &"NO_DOMAIN_VALID_RECOVERY_CANDIDATE" + ) + + +func _finish_recovery_operation( + loaded_result: Dictionary, + save_path: String, + previous_paused: bool, + started_at_us: int, + live_snapshot_before: Dictionary +) -> Dictionary: + var result := loaded_result.duplicate(true) + if not bool(result.get(&"ok", false)): + _clock.set_paused(previous_paused) + var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0) + _record_runtime_performance(&"load", runtime_duration_us) + var live_state_preserved := true + if not bool(result.get(&"ok", false)): + live_state_preserved = create_snapshot() == live_snapshot_before + result = _with_operation_context(result, { + &"operation": &"recovery", + &"save_path": save_path, + &"runtime_duration_us": runtime_duration_us, + &"loaded_clock_paused": _clock.paused if result.get(&"ok", false) else previous_paused, + &"preload_pause_restored": true if result.get(&"ok", false) else _clock.paused == previous_paused, + &"live_state_preserved": live_state_preserved, + }) + if bool(result.get(&"ok", false)): + _complete_save_operation(result, &"SAVE_RECOVERY_LOADED") + else: + # A failed recovery is an observational operation. Publishing a persisted + # DomainEvent here would itself violate the promised all-live-state atomicity. + latest_save_operation_result = _save_event_payload(result) + save_operation_completed.emit(result.duplicate(true)) + return result + + +func _has_package_loadable_recovery_candidate(candidates: Array[Dictionary]) -> bool: + for candidate: Dictionary in candidates: + if bool(candidate.get(&"package_loadable", false)): + return true + return false + + +func _recovery_package_rejections(candidates: Array[Dictionary]) -> Array[Dictionary]: + var rejected: Array[Dictionary] = [] + for candidate: Dictionary in candidates: + if not bool(candidate.get(&"package_rejected", false)): + continue + rejected.append({ + &"id": StringName(candidate.get(&"id", &"")), + &"code": StringName(candidate.get(&"package_rejection_code", &"PACKAGE_REJECTED")), + }) + return rejected + + +func _recovery_domain_rejections(candidates: Array[Dictionary]) -> Array[Dictionary]: + var rejected: Array[Dictionary] = [] + for candidate: Dictionary in candidates: + if not bool(candidate.get(&"domain_rejected", false)): + continue + rejected.append({ + &"id": StringName(candidate.get(&"id", &"")), + &"code": StringName(candidate.get( + &"domain_rejection_code", + &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED" + )), + }) + return rejected + + +func _recovery_failure_result( + code: StringName, + message: String, + candidates: Array[Dictionary], + package_report: Dictionary, + package_recovery_report: Dictionary, + rejection_reason: StringName +) -> Dictionary: + var restore_safety_report := { + &"report_version": 2, + &"recovery_mode": true, + &"repair_policy": &"WHITELISTED_AUTHORITATIVE_ONLY", + &"automatic_repairs_applied": false, + &"restore_committed": false, + &"rejection_reason": rejection_reason, + &"package_rejections": _recovery_package_rejections(candidates), + &"domain_rejections": _recovery_domain_rejections(candidates), + &"forbidden_repairs_applied": false, + } + return { + &"ok": false, + &"code": code, + &"message": message, + &"selection_rule": package_report.get( + &"selection_rule", + &"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST" + ), + &"selected": {}, + &"candidates": candidates.duplicate(true), + &"candidate_attempts": candidates.duplicate(true), + &"package_candidate_report": package_report.duplicate(true), + &"package_recovery_report": package_recovery_report.duplicate(true), + &"restore_safety_report": restore_safety_report.duplicate(true), + &"recovery_report": restore_safety_report, + } + + +func load_recovery_candidate( + save_path: String, + candidate_id: StringName, + generation := -1 +) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var previous_paused := _clock.paused + _clock.set_paused(true) + var result := SaveService.load_recovery_candidate(save_path, candidate_id, generation) + return _finish_load_operation( + result, + save_path, + previous_paused, + started_at_us, + &"recovery_candidate", + &"SAVE_RECOVERY_CANDIDATE_LOADED", + &"SAVE_RECOVERY_FAILED" + ) + + +func migrate_save_path( + save_path: String, + target_version := SaveService.FORMAT_VERSION +) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var previous_paused := _clock.paused + _clock.set_paused(true) + var result := SaveService.migrate_save(save_path, save_migration_registry, target_version) + _clock.set_paused(previous_paused) + var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0) + _record_runtime_performance(&"save", runtime_duration_us) + result = _with_operation_context(result, { + &"operation": &"migration", + &"save_path": save_path, + &"runtime_duration_us": runtime_duration_us, + &"clock_pause_restored": _clock.paused == previous_paused, + }) + _complete_save_operation( + result, + &"SAVE_MIGRATION_SUCCEEDED" if result[&"ok"] else &"SAVE_MIGRATION_FAILED" + ) + return result + + +func _process_save_checkpoints(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + if bool(_save_policy.get(&"enabled", false)) \ + and bool(_save_policy.get(&"daily_checkpoint_enabled", true)) \ + and posmod(absolute_minute, SimulationKernel.MINUTES_PER_DAY) == SimulationKernel.START_ABSOLUTE_MINUTE: + var checkpoint_day := absolute_minute / SimulationKernel.MINUTES_PER_DAY + 1 + if checkpoint_day != _last_daily_checkpoint_day: + var previous_checkpoint_day := _last_daily_checkpoint_day + _last_daily_checkpoint_day = checkpoint_day + var daily_result := _perform_checkpoint_save( + str(_save_policy[&"autosave_path"]), + { + &"display_name": "Daily Autosave", + &"checkpoint_day": checkpoint_day, + &"checkpoint_absolute_minute": absolute_minute, + }, + true, + &"daily_0600", + 0 + ) + if not daily_result[&"ok"]: + _last_daily_checkpoint_day = previous_checkpoint_day + + if _pending_checkpoints.is_empty(): + return + var requests := _pending_checkpoints.duplicate(true) + _pending_checkpoints.clear() + for request_value: Variant in requests: + if not request_value is Dictionary: + continue + var request: Dictionary = request_value + _perform_checkpoint_save( + str(request[&"save_path"]), + request[&"metadata"], + bool(request[&"use_autosave"]), + StringName(request[&"reason"]), + int(request[&"request_id"]) + ) + + +func _perform_checkpoint_save( + save_path: String, + metadata: Variant, + use_autosave: bool, + reason: StringName, + request_id: int +) -> Dictionary: + if not _initialized or _clock == null: + return { + &"ok": false, + &"code": &"RUNTIME_NOT_INITIALIZED", + &"message": "Simulation runtime is not initialized.", + } + var started_at_us := Time.get_ticks_usec() + var previous_paused := _clock.paused + _clock.set_paused(true) + var snapshot := create_snapshot() + # Persistence pauses I/O, not game semantics. The package must retain the + # state that should resume after load rather than the temporary I/O pause. + snapshot[&"clock"][&"paused"] = previous_paused + var effective_metadata: Dictionary = metadata.duplicate(true) if metadata is Dictionary else {} + effective_metadata[&"checkpoint_reason"] = reason + var result := ( + SaveService.save_autosave( + save_path, + snapshot, + effective_metadata, + int(_save_policy[&"autosave_generations"]) + ) + if use_autosave + else SaveService.save_snapshot(save_path, snapshot, effective_metadata) + ) + _clock.set_paused(previous_paused) + var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0) + _record_runtime_performance(&"save", runtime_duration_us) + result = _with_operation_context(result, { + &"operation": &"autosave" if use_autosave else &"save", + &"save_path": save_path, + &"reason": reason, + &"request_id": request_id, + &"checkpoint_absolute_minute": int(snapshot[&"clock"][&"absolute_minute"]), + &"runtime_duration_us": runtime_duration_us, + &"clock_pause_restored": _clock.paused == previous_paused, + }) + var event_type := ( + &"AUTOSAVE_CHECKPOINT_SUCCEEDED" + if use_autosave and result[&"ok"] + else &"AUTOSAVE_CHECKPOINT_FAILED" + if use_autosave + else &"SAVE_CHECKPOINT_SUCCEEDED" + if result[&"ok"] + else &"SAVE_CHECKPOINT_FAILED" + ) + _complete_save_operation(result, event_type) + return result + + +func _finish_load_operation( + loaded_result: Dictionary, + save_path: String, + previous_paused: bool, + started_at_us: int, + operation: StringName, + success_event_type: StringName, + failure_event_type: StringName +) -> Dictionary: + var result := loaded_result.duplicate(true) + var recovery_mode := operation in [&"recovery", &"recovery_candidate"] + var package_recovery_report: Variant = result.get(&"recovery_report", null) + if result[&"ok"] and not _restore_snapshot_internal(result[&"snapshot"], recovery_mode): + result[&"ok"] = false + result[&"source_code"] = result.get(&"code", &"OK") + result[&"code"] = &"RESTORE_FAILED" + result[&"message"] = save_path + result[&"recovery_report"] = { + &"report_version": 1, + &"recovery_mode": recovery_mode, + &"repair_policy": &"WHITELISTED_AUTHORITATIVE_ONLY", + &"automatic_repairs_applied": false, + &"restore_committed": false, + &"rejection_reason": &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED", + &"forbidden_repairs_applied": false, + } + elif result[&"ok"]: + result[&"restore_safety_report"] = latest_restore_safety_report.duplicate(true) + if recovery_mode: + result[&"recovery_report"] = latest_restore_safety_report.duplicate(true) + result[&"recovery_report"][&"restore_committed"] = true + if package_recovery_report is Dictionary: + result[&"package_recovery_report"] = package_recovery_report.duplicate(true) + if not result[&"ok"]: + _clock.set_paused(previous_paused) + var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0) + _record_runtime_performance(&"load", runtime_duration_us) + result = _with_operation_context(result, { + &"operation": operation, + &"save_path": save_path, + &"runtime_duration_us": runtime_duration_us, + &"loaded_clock_paused": _clock.paused if result[&"ok"] else previous_paused, + &"preload_pause_restored": true if result[&"ok"] else _clock.paused == previous_paused, + }) + _complete_save_operation( + result, + success_event_type if result[&"ok"] else failure_event_type + ) + return result + + +func _complete_save_operation(result: Dictionary, event_type: StringName) -> void: + latest_save_operation_result = _save_event_payload(result) + _publish_actor_event( + event_type, + &"save_service", + [], + latest_save_operation_result + ) + save_operation_completed.emit(result.duplicate(true)) + + +func _save_event_payload(result: Dictionary) -> Dictionary: + var payload := { + &"ok": bool(result.get(&"ok", false)), + &"code": StringName(result.get(&"code", &"UNKNOWN")), + &"message": str(result.get(&"message", "")), + &"operation": StringName(result.get(&"operation", &"unknown")), + &"save_path": str(result.get(&"save_path", result.get(&"path", ""))), + &"reason": StringName(result.get(&"reason", &"")), + &"request_id": int(result.get(&"request_id", 0)), + &"checkpoint_absolute_minute": int(result.get(&"checkpoint_absolute_minute", _clock.absolute_minute)), + &"duration_us": int(result.get(&"duration_us", 0)), + &"runtime_duration_us": int(result.get(&"runtime_duration_us", 0)), + } + var selected_value: Variant = result.get(&"selected", null) + if selected_value is Dictionary: + payload[&"selected_candidate_id"] = StringName(selected_value.get(&"id", &"")) + payload[&"selected_candidate_kind"] = StringName(selected_value.get(&"kind", &"")) + payload[&"selected_generation"] = int(selected_value.get(&"generation", -1)) + var manifest_value: Variant = result.get(&"manifest", null) + if manifest_value is Dictionary: + payload[&"save_id"] = str(manifest_value.get("save_id", "")) + payload[&"saved_absolute_minute"] = int(manifest_value.get("absolute_minute", -1)) + return payload + + +func _with_operation_context(result: Dictionary, context: Dictionary) -> Dictionary: + var enriched := result.duplicate(true) + for key: Variant in context: + enriched[key] = context[key] + return enriched + + +func _record_runtime_performance(metric_id: StringName, duration_us: int) -> void: + if _clock != null and _clock.performance_budget != null: + _clock.performance_budget.record_sample(metric_id, maxi(duration_us, 0)) + + +func _release_recovery_task_items( + inventory: InventoryService, + reservation_ids: Array[StringName], + task: StaffTask, + absolute_minute: int +) -> Dictionary: + var before := inventory.create_snapshot() + for reservation_id: StringName in reservation_ids: + var result := inventory.release_reservation( + reservation_id, + StringName("recovery.staff.task.release.%s.%s" % [task.task_id, reservation_id]), + absolute_minute + ) + if not bool(result.get(&"ok", false)): + inventory.restore_snapshot(before) + return result + return {&"ok": true} + + +func _merge_content_filter_profiles(current: Dictionary, saved: Dictionary) -> Dictionary: + var disabled_by_name: Dictionary = {} + for raw_tag: Variant in current.get(&"disabled_content_tags", []): + var tag := StringName(raw_tag) + if not tag.is_empty(): + disabled_by_name[tag] = true + for raw_tag: Variant in saved.get(&"disabled_content_tags", []): + var tag := StringName(raw_tag) + if not tag.is_empty(): + disabled_by_name[tag] = true + var disabled_tags: Array[StringName] = [] + for raw_tag: Variant in disabled_by_name: + disabled_tags.append(StringName(raw_tag)) + disabled_tags.sort_custom( + func(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + ) + return { + &"filter_mode": maxi( + int(current.get(&"filter_mode", EventDefinition.FilterBehavior.NONE)), + int(saved.get(&"filter_mode", EventDefinition.FilterBehavior.NONE)) + ) as EventDefinition.FilterBehavior, + &"maximum_content_rating": mini( + int(current.get(&"maximum_content_rating", EventDefinition.ContentRating.GENERAL)), + int(saved.get(&"maximum_content_rating", EventDefinition.ContentRating.GENERAL)) + ) as EventDefinition.ContentRating, + &"disabled_content_tags": disabled_tags, + } + + +func _content_filter_restore_report( + current: Dictionary, + saved: Dictionary, + effective: Dictionary +) -> Dictionary: + var current_tags: Array = current.get(&"disabled_content_tags", []) + var saved_tags: Array = saved.get(&"disabled_content_tags", []) + var saved_was_weaker := ( + int(saved.get(&"filter_mode", 0)) < int(current.get(&"filter_mode", 0)) + or int(saved.get(&"maximum_content_rating", 0)) > int(current.get(&"maximum_content_rating", 0)) + ) + for raw_tag: Variant in current_tags: + if not saved_tags.has(StringName(raw_tag)) and not saved_tags.has(String(raw_tag)): + saved_was_weaker = true + break + return { + &"saved_profile_was_weaker": saved_was_weaker, + &"strict_profile_preserved": true, + &"current_before_load": _content_filter_profile_payload(current), + &"saved_profile": _content_filter_profile_payload(saved), + &"effective_profile": _content_filter_profile_payload(effective), + } + + +func _content_filter_profile_payload(profile: Dictionary) -> Dictionary: + var mode := clampi( + int(profile.get(&"filter_mode", EventDefinition.FilterBehavior.NONE)), + EventDefinition.FilterBehavior.NONE, + EventDefinition.FilterBehavior.DISABLE + ) + var rating := clampi( + int(profile.get(&"maximum_content_rating", EventDefinition.ContentRating.GENERAL)), + EventDefinition.ContentRating.GENERAL, + EventDefinition.ContentRating.RESTRICTED + ) + var tags: Array[String] = [] + for raw_tag: Variant in profile.get(&"disabled_content_tags", []): + tags.append(String(raw_tag)) + tags.sort() + return { + &"filter_mode": EventDefinition.FilterBehavior.keys()[mode], + &"maximum_content_rating": EventDefinition.ContentRating.keys()[rating], + &"disabled_content_tags": tags, + } + + +func _save_policy_path_is_safe(save_path: String) -> bool: + if not save_path.begins_with("user://") or save_path == "user://": + return false + var relative := save_path.trim_prefix("user://").replace("\\", "/") + if relative.is_empty() or relative.is_absolute_path(): + return false + for segment: String in relative.split("/", false): + if segment.is_empty() or segment == "." or segment == "..": + return false + return true + + +func _runtime_command_history_crosslinks_are_valid( + queue_snapshot: Dictionary, + next_command_id: int, + events: DomainEventStream +) -> bool: + if next_command_id <= 0 or events == null \ + or typeof(queue_snapshot.get("version", null)) != TYPE_INT \ + or int(queue_snapshot["version"]) != CommandQueue.SNAPSHOT_VERSION \ + or not queue_snapshot.get("accepted", null) is Array: + return false + var seen_ids: Dictionary = {} + var processed_by_id: Dictionary = {} + var command_idempotency: Dictionary = {} + for collection_key: String in ["pending", "in_flight", "processed"]: + var rows_value: Variant = queue_snapshot.get(collection_key, null) + if not rows_value is Array: + return false + if collection_key == "in_flight" and not rows_value.is_empty(): + # Runtime commands are synchronously completed in APPLY_COMMANDS and + # have no resumable in-flight execution path. + return false + for row_value: Variant in rows_value: + if not row_value is Dictionary: + return false + var row: Dictionary = row_value + var command_id_value: Variant = row.get("command_id", null) + if typeof(command_id_value) != TYPE_INT or int(command_id_value) <= 0 \ + or seen_ids.has(int(command_id_value)): + return false + var command_id := int(command_id_value) + seen_ids[command_id] = true + if collection_key == "processed": + processed_by_id[command_id] = row + else: + var raw_key: Variant = row.get("idempotency_key", "") + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var key := StringName(raw_key) + if not key.is_empty(): + command_idempotency[command_id] = key + if seen_ids.size() != next_command_id - 1: + return false + for command_id in range(1, next_command_id): + if not seen_ids.has(command_id): + return false + var accepted_by_id: Dictionary = {} + var previous_accepted_id := 0 + for raw_metadata: Variant in queue_snapshot["accepted"]: + if not raw_metadata is Dictionary: + return false + var metadata: Dictionary = raw_metadata + var command_id_value: Variant = metadata.get("command_id", null) + var key_value: Variant = metadata.get("idempotency_key", null) + if metadata.size() != 2 or typeof(command_id_value) != TYPE_INT \ + or int(command_id_value) <= previous_accepted_id \ + or typeof(key_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not seen_ids.has(int(command_id_value)): + return false + var command_id := int(command_id_value) + if command_idempotency.has(command_id) \ + and StringName(command_idempotency[command_id]) != StringName(key_value): + return false + accepted_by_id[command_id] = StringName(key_value) + command_idempotency[command_id] = StringName(key_value) + previous_accepted_id = command_id + if accepted_by_id.size() != seen_ids.size(): + return false + var idempotency_value: Variant = queue_snapshot.get("idempotency", null) + if not idempotency_value is Dictionary: + return false + var mapped_ids: Dictionary = {} + for raw_key: Variant in idempotency_value: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(raw_key).is_empty() \ + or typeof(idempotency_value[raw_key]) != TYPE_INT: + return false + var command_id := int(idempotency_value[raw_key]) + if not seen_ids.has(command_id) or mapped_ids.has(command_id): + return false + mapped_ids[command_id] = StringName(raw_key) + for command_id: int in command_idempotency: + var accepted_key := StringName(command_idempotency[command_id]) + if accepted_key.is_empty(): + if mapped_ids.has(command_id): + return false + elif mapped_ids.get(command_id, &"") != accepted_key: + return false + + var completion_by_id: Dictionary = {} + for event: SimulationDomainEvent in events.recent_events(): + if event.event_type not in [&"COMMAND_COMPLETED", &"COMMAND_FAILED"]: + continue + var command_id := event.causation_command_id + if command_id <= 0 or not processed_by_id.has(command_id) \ + or completion_by_id.has(command_id) \ + or event.source_service != &"simulation_runtime" \ + or not _strict_variants_equal(event.payload, processed_by_id[command_id]): + return false + var result_code := String((processed_by_id[command_id] as Dictionary).get("code", "")) + if (result_code == "OK") != (event.event_type == &"COMMAND_COMPLETED"): + return false + completion_by_id[command_id] = true + # Completion events share a bounded stream with all other DomainEvents. Any + # retained completion must match exactly; absence alone is not corruption + # because unrelated events can legitimately evict it. + return true + + +func _legacy_operations_history_is_absent(runtime_data: Dictionary) -> bool: + # v1-v4 had no Operations wrapper or payroll settlement semantics. Reject a + # relabelled v5 payload instead of silently discarding production history. + if runtime_data.has(&"operations") or runtime_data.has("operations"): + return false + var staff_value: Variant = runtime_data.get(&"staff", null) + if staff_value is Dictionary: + var settlements: Variant = (staff_value as Dictionary).get(&"payroll_settlements", []) + if settlements is Array and not settlements.is_empty(): + return false + var economy_value: Variant = runtime_data.get(&"economy", null) + if economy_value is Dictionary: + var entries: Variant = (economy_value as Dictionary).get(&"entries", []) + if entries is Array: + for raw_entry: Variant in entries: + if raw_entry is Dictionary \ + and StringName((raw_entry as Dictionary).get(&"category", "")) == &"WAGE": + return false + return true + + +func _legacy_infrastructure_history_is_absent(runtime_data: Dictionary) -> bool: + # Infrastructure aggregates first exist in Runtime v6. A lower version with + # any look-alike field or retained v6-only history is a relabelled payload, + # not a migratable legacy save. + for key: StringName in [ + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", + ]: + if runtime_data.has(key) or runtime_data.has(String(key)): + return false + var stream_value: Variant = runtime_data.get(&"domain_events", null) + if stream_value is Dictionary: + var recent_events: Variant = (stream_value as Dictionary).get("recent_events", []) + if recent_events is Array: + for raw_event: Variant in recent_events: + if raw_event is Dictionary and StringName( + (raw_event as Dictionary).get("event_type", "") + ) in [&"FACILITY_INFRASTRUCTURE_PROJECTED", &"EXCAVATION_COMPLETED"]: + return false + var queue_value: Variant = runtime_data.get(&"command_queue", null) + if queue_value is Dictionary: + for queue_key: String in ["pending", "in_flight"]: + var commands: Variant = (queue_value as Dictionary).get(queue_key, []) + if not commands is Array: + continue + for raw_command: Variant in commands: + if raw_command is Dictionary and StringName( + (raw_command as Dictionary).get("command_type", "") + ) in [&"start_excavation", &"pause_excavation", &"resume_excavation"]: + return false + return true + + +func _runtime_v5_outer_snapshot_shape_is_valid(snapshot: Dictionary) -> bool: + if not _dictionary_has_exact_canonical_keys( + snapshot, [&"schema_version", &"clock", &"game_state", &"systems"] + ) or typeof(snapshot.get(&"schema_version", null)) != TYPE_INT \ + or int(snapshot[&"schema_version"]) != SimulationSnapshot.SCHEMA_VERSION: + return false + var systems_value: Variant = snapshot.get(&"systems", null) + var clock_value: Variant = snapshot.get(&"clock", null) + var state_value: Variant = snapshot.get(&"game_state", null) + if not systems_value is Dictionary or not clock_value is Dictionary \ + or not state_value is Dictionary: + return false + var systems: Dictionary = systems_value + var clock_data: Dictionary = clock_value + var state_data: Dictionary = state_value + if not _dictionary_has_exact_canonical_keys(systems, [&"runtime"]) \ + or not systems[&"runtime"] is Dictionary \ + or not _dictionary_has_exact_canonical_keys( + clock_data, + [&"absolute_minute", &"speed", &"paused", &"snapshot_version"] + ) \ + or typeof(clock_data.get(&"absolute_minute", null)) != TYPE_INT \ + or int(clock_data[&"absolute_minute"]) < 0 \ + or typeof(clock_data.get(&"speed", null)) != TYPE_INT \ + or int(clock_data[&"speed"]) not in [1, 2, 4] \ + or typeof(clock_data.get(&"paused", null)) != TYPE_BOOL \ + or typeof(clock_data.get(&"snapshot_version", null)) != TYPE_INT \ + or int(clock_data[&"snapshot_version"]) < 0: + return false + if not _dictionary_has_exact_canonical_keys( + state_data, + [&"resources", &"world_tree_rank", &"active_layer", &"revision"] + ) or not state_data[&"resources"] is Dictionary \ + or typeof(state_data.get(&"world_tree_rank", null)) != TYPE_INT \ + or int(state_data[&"world_tree_rank"]) < 1 \ + or int(state_data[&"world_tree_rank"]) > 7 \ + or typeof(state_data.get(&"active_layer", null)) != TYPE_INT \ + or int(state_data[&"active_layer"]) not in [0, 1] \ + or typeof(state_data.get(&"revision", null)) != TYPE_INT \ + or int(state_data[&"revision"]) < 0: + return false + var resources: Dictionary = state_data[&"resources"] + var seen_resources: Dictionary = {} + for raw_resource_id: Variant in resources: + if typeof(raw_resource_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var resource_id := StringName(raw_resource_id) + if resource_id.is_empty() or seen_resources.has(resource_id) \ + or typeof(resources[raw_resource_id]) != TYPE_INT \ + or int(resources[raw_resource_id]) < 0: + return false + seen_resources[resource_id] = true + for required_resource: StringName in [&"gold", &"mana", &"materials", &"faith"]: + if not seen_resources.has(required_resource): + return false + return true + + +func _connect_path_performance_signal() -> void: + if path_service == null or not path_service.has_signal(&"path_query_measured"): + return + var callback := Callable(self, "_on_path_query_measured") + if not path_service.is_connected(&"path_query_measured", callback): + path_service.connect(&"path_query_measured", callback) + + +func _on_path_query_measured(duration_us: int) -> void: + _record_runtime_performance(&"path_query", duration_us) + +func _initialize_rng_streams() -> void: + _rng_streams.clear() + for stream_id in RNG_STREAM_IDS: + _rng_streams[stream_id] = DeterministicRng.from_root_seed(world_seed, stream_id) + +func _connect_construction_signals() -> void: + if construction_service == null: + return + var completed_callback := Callable(self, "_on_facility_completed") + var demolished_callback := Callable(self, "_on_facility_demolished") + if not construction_service.facility_completed.is_connected(completed_callback): + construction_service.facility_completed.connect(completed_callback) + if not construction_service.facility_demolished.is_connected(demolished_callback): + construction_service.facility_demolished.connect(demolished_callback) + + +func _initialize_infrastructure_services() -> bool: + spatial_subslot_service = SpatialSubslotService.new() + route_subslot_path_adapter = RouteSubslotPathAdapter.new() + if not route_subslot_path_adapter.configure( + spatial_subslot_service, + path_service, + world_service.get_walkable_cells() + ): + return false + facility_infrastructure_projector = FacilityInfrastructureProjector.new() + if not facility_infrastructure_projector.configure( + route_subslot_path_adapter, + spatial_subslot_service, + path_service + ): + return false + excavation_service = ExcavationService.new(world_service, construction_resource_state) + excavation_service.current_minute = _clock.absolute_minute + var excavation_callback := Callable(self, "_on_excavation_completed") + if not excavation_service.excavation_completed.is_connected(excavation_callback): + excavation_service.excavation_completed.connect(excavation_callback) + return true + +func rebuild_path_graph_from_world() -> bool: + if path_service == null or world_service == null: + return false + var walkable_cells: Array[Vector3i] = world_service.get_walkable_cells() + var projected_walkable_cells: Array[Vector3i] = walkable_cells.duplicate() + if route_subslot_path_adapter != null \ + and route_subslot_path_adapter.is_configured() \ + and spatial_subslot_service != null: + var seen_projected: Dictionary = {} + projected_walkable_cells.clear() + for cell: Vector3i in walkable_cells: + if not seen_projected.has(cell): + seen_projected[cell] = true + projected_walkable_cells.append(cell) + for cell: Vector3i in spatial_subslot_service.get_route_cells(): + if not seen_projected.has(cell): + seen_projected[cell] = true + projected_walkable_cells.append(cell) + var current_path_snapshot: Dictionary = path_service.create_snapshot() + var raw_actor_cells: Variant = current_path_snapshot.get(&"actor_cells", null) + var has_runtime_actor_state: bool = ( + not _movement_credits.is_empty() + or (raw_actor_cells is Array and not raw_actor_cells.is_empty()) + ) + # PathService correctly owns its own graph, but Runtime owns the cross-system + # Actor/Path projection contract. Reject a topology change that would evict + # a live Guest/Staff projection instead of allowing PathService to prune it. + if has_runtime_actor_state: + if _clock == null or not _validate_runtime_path_crosslinks( + path_service, + guest_service, + staff_service, + _movement_credits, + _clock.absolute_minute + ): + return false + var walkable_set: Dictionary = {} + for cell: Vector3i in projected_walkable_cells: + walkable_set[cell] = true + var expected_cells: Dictionary = _expected_runtime_actor_cells( + guest_service, + staff_service + ) + for actor_id: StringName in _sorted_name_keys(expected_cells): + if not walkable_set.has(expected_cells[actor_id]): + return false + if route_subslot_path_adapter != null \ + and route_subslot_path_adapter.is_configured(): + var synchronized := route_subslot_path_adapter.synchronize_projection( + StringName( + "runtime.base_sync.%08d" % (route_subslot_path_adapter.projection_revision + 1) + ), + walkable_cells + ) + return synchronized.is_success() + return path_service.rebuild_graph(walkable_cells) + +func _initialize_world(rank: int) -> void: + var bounds := _bounds_for_rank(rank) + world_service.reset(bounds) + _seed_boundary_cells(bounds) + rebuild_path_graph_from_world() + path_service.add_connector( + &"starter.staff_door", + Vector3i(0, 0, WorldService.LAYER_FRONT), + Vector3i(0, 0, WorldService.LAYER_BACK), + PathService.DEFAULT_CONNECTOR_COST, + true, + &"staff" + ) + +func _initialize_mana_network() -> void: + mana_network_service.register_source(STARTER_MANA_SOURCE, STARTER_MANA_OUTPUT) + mana_network_service.register_connector(STARTER_MANA_BUS) + mana_network_service.connect_nodes(STARTER_MANA_SOURCE, STARTER_MANA_BUS) + +func _initialize_economy() -> void: + economy_service = EconomyService.new({ + PLAYER_CASH_ACCOUNT: {&"allow_negative": false}, + CONSTRUCTION_EXPENSE_ACCOUNT: {&"allow_negative": false}, + OPERATING_EXPENSE_ACCOUNT: {&"allow_negative": false}, + AUTHORITY_FINE_ACCOUNT: {&"allow_negative": false}, + WAGE_EXPENSE_ACCOUNT: {&"allow_negative": false}, + }) + economy_service.initialize_account( + PLAYER_CASH_ACCOUNT, + int(_state.get_resource(&"gold")), + _clock.absolute_minute, + &"runtime.initial_player_cash" + ) + construction_resource_state = _create_resource_adapter( + economy_service, + CONSTRUCTION_EXPENSE_ACCOUNT, + &"CONSTRUCTION", + &"CONSTRUCTION_REFUND", + &"construction_service" + ) + operating_resource_state = _create_resource_adapter( + economy_service, + OPERATING_EXPENSE_ACCOUNT, + &"OPERATING_EXPENSE", + &"OPERATING_REFUND", + &"simulation_runtime" + ) + _connect_economy_signals() + +func _create_resource_adapter( + economy: EconomyService, + counter_account: StringName, + expense_category: StringName, + refund_category: StringName, + source_entity_id: StringName +) -> LedgerBackedResourceState: + return LedgerBackedResourceState.new( + _state, + economy, + counter_account, + expense_category, + refund_category, + source_entity_id, + func() -> int: return _runtime_event_minute() + ) + +func _connect_economy_signals() -> void: + if economy_service == null: + return + economy_service.transaction_posted.connect(Callable(self, "_on_ledger_transaction_posted")) + +func _initialize_s2_operations() -> void: + inventory_service = InventoryService.new() + inventory_service.current_minute = _clock.absolute_minute + logistics_dispatcher = LogisticsDispatcher.new() + logistics_requests = {} + facility_service = FacilityService.new() + facility_service.current_minute = _clock.absolute_minute + _configure_facility_boundaries() + _connect_facility_signals() + +func _initialize_s3_actors() -> void: + guest_service = GuestService.new() + guest_service.current_minute = _clock.absolute_minute + guest_service.create_definition(&"traveler", { + &"tags": [&"general", &"traveler"], + &"wealth_min": 120, + &"wealth_max": 450, + &"base_patience": 45, + &"risk_tolerance": 50, + &"preference_weights": {&"dining": 8, &"lodging": 7, &"social": 3}, + &"group_size_weights": {1: 70, 2: 25, 3: 5}, + &"legal_sensitivity": 65, + &"quality_sensitivity": 50, + &"need_rates": {&"hunger": 1, &"sleep": 1, &"hygiene": 0, &"medical": 0, &"leisure": 1, &"intimacy": 0}, + &"credit_limit_gold": 50, + &"minimum_quality": 25, + }) + guest_service.create_definition(&"merchant", { + &"tags": [&"general", &"merchant", &"frugal"], + &"wealth_min": 300, + &"wealth_max": 1_200, + &"base_patience": 35, + &"risk_tolerance": 35, + &"preference_weights": {&"lodging": 9, &"quiet": 7, &"service": 4}, + &"group_size_weights": {1: 60, 2: 40}, + &"legal_sensitivity": 80, + &"quality_sensitivity": 70, + &"need_rates": {&"hunger": 1, &"sleep": 1, &"hygiene": 0, &"medical": 0, &"leisure": 1, &"intimacy": 0}, + &"credit_limit_gold": 200, + &"minimum_quality": 40, + }) + guest_service.create_definition(&"adventurer", { + &"tags": [&"adventurer", &"general", &"spendthrift"], + &"wealth_min": 180, + &"wealth_max": 800, + &"base_patience": 30, + &"risk_tolerance": 85, + &"preference_weights": {&"dining": 6, &"social": 8, &"service": 2}, + &"group_size_weights": {1: 45, 2: 35, 3: 15, 4: 5}, + &"legal_sensitivity": 35, + &"quality_sensitivity": 35, + &"adult_verified": true, + &"need_rates": {&"hunger": 2, &"sleep": 1, &"hygiene": 0, &"medical": 0, &"leisure": 1, &"intimacy": 0}, + &"credit_limit_gold": 100, + &"minimum_quality": 20, + }) + if bool(mvp_content_report.get(&"valid", false)): + for definition: GuestDefinition in mvp_content_catalog.build_all_guest_definitions(): + if not guest_service.register_definition(definition): + mvp_content_report = mvp_content_report.duplicate(true) + mvp_content_report[&"valid"] = false + mvp_content_report[&"runtime_registration_error"] = guest_service.last_error + break + staff_service = StaffService.new() + staff_service.current_minute = _clock.absolute_minute + _configure_staff_boundaries() + _connect_guest_signals() + _connect_staff_signals() + +func _configure_staff_boundaries() -> void: + if staff_service == null: + return + staff_service.configure_callbacks( + Callable(self, "_reserve_staff_task_items"), + Callable(self, "_release_staff_task_items"), + Callable(self, "_estimate_staff_task_travel") + ) + +func _connect_guest_signals() -> void: + if guest_service == null: + return + guest_service.guest_spawned.connect(Callable(self, "_on_guest_spawned")) + guest_service.guest_state_changed.connect(Callable(self, "_on_guest_state_changed")) + guest_service.guest_queue_abandoned.connect(Callable(self, "_on_guest_queue_abandoned")) + guest_service.guest_recovery_started.connect(Callable(self, "_on_guest_recovery_started")) + guest_service.guest_despawned.connect(Callable(self, "_on_guest_despawned")) + +func _connect_staff_signals() -> void: + if staff_service == null: + return + staff_service.staff_registered.connect(Callable(self, "_on_staff_registered")) + staff_service.staff_removed.connect(Callable(self, "_on_staff_removed")) + staff_service.shift_registered.connect(Callable(self, "_on_shift_registered")) + staff_service.shift_state_changed.connect(Callable(self, "_on_shift_state_changed")) + staff_service.task_registered.connect(Callable(self, "_on_staff_task_registered")) + staff_service.task_assigned.connect(Callable(self, "_on_staff_task_assigned")) + staff_service.task_reopened.connect(Callable(self, "_on_staff_task_reopened")) + staff_service.task_completed.connect(Callable(self, "_on_staff_task_completed")) + +func _initialize_s4_relationship_events() -> void: + relationship_service = RelationshipService.new() + event_service = EventService.new() + event_service.create_definition(&"relationship.threshold", { + &"category": &"RELATIONSHIP", + &"priority": 80, + &"foreground": true, + &"expires_after": 1_440, + &"transaction_policy": &"ROLLBACK_ALL", + &"missing_actor_policy": &"CANCEL", + &"content_rating": &"GENERAL", + &"choices": [ + { + &"choice_id": &"acknowledge", + &"text_key": &"event.relationship.threshold.acknowledge", + &"actions": [{ + &"action_id": &"record_acknowledgement", + &"type": &"SET_FLAG", + &"payload": {&"flag_id": &"relationship_threshold_acknowledged"}, + }], + }, + { + &"choice_id": &"defer", + &"text_key": &"event.relationship.threshold.defer", + &"actions": [], + }, + ], + }) + _configure_relationship_event_boundaries() + _connect_relationship_signals() + _connect_event_signals() + +func _configure_relationship_event_boundaries() -> void: + if relationship_service == null or event_service == null: + return + relationship_service.configure_candidate_callback( + Callable(self, "_on_relationship_threshold_candidate") + ) + event_service.configure_relationship_service(relationship_service) + event_service.configure_callbacks( + Callable(self, "_event_actor_exists"), + Callable(self, "_execute_event_action_boundary"), + Callable(self, "_reserve_event_cost"), + Callable(self, "_release_event_cost"), + Callable(self, "_commit_event_cost"), + Callable(self, "_event_entity_exists"), + Callable(self, "_event_path_exists") + ) + +func _connect_relationship_signals() -> void: + if relationship_service == null: + return + relationship_service.relationship_registered.connect(Callable(self, "_on_relationship_registered")) + relationship_service.relationship_value_changed.connect(Callable(self, "_on_relationship_value_changed")) + relationship_service.shield_changed.connect(Callable(self, "_on_relationship_shield_changed")) + relationship_service.route_lock_changed.connect(Callable(self, "_on_relationship_route_lock_changed")) + +func _connect_event_signals() -> void: + if event_service == null: + return + event_service.event_enqueued.connect(Callable(self, "_on_event_enqueued")) + event_service.event_started.connect(Callable(self, "_on_event_started")) + event_service.event_finished.connect(Callable(self, "_on_event_finished")) + event_service.event_cancelled.connect(Callable(self, "_on_event_cancelled")) + event_service.choice_resolved.connect(Callable(self, "_on_event_choice_resolved")) + +func _initialize_s5_authority() -> void: + authority_service = AuthorityService.new() + authority_service.current_minute = _clock.absolute_minute + _configure_authority_boundaries() + _connect_authority_signals() + +func _configure_authority_boundaries() -> void: + if authority_service == null: + return + authority_service.configure_callbacks( + Callable(self, "_authority_line_of_sight"), + Callable(self, "_authority_path_query"), + Callable(self, "_apply_authority_resolution_boundary"), + Callable(self, "_roll_authority_inspection") + ) + +func _connect_authority_signals() -> void: + if authority_service == null: + return + authority_service.heat_changed.connect(Callable(self, "_on_authority_heat_changed")) + authority_service.incident_created.connect(Callable(self, "_on_authority_incident_created")) + authority_service.incident_detector_added.connect(Callable(self, "_on_authority_incident_detector_added")) + authority_service.evidence_registered.connect(Callable(self, "_on_authority_evidence_registered")) + authority_service.evidence_discovered.connect(Callable(self, "_on_authority_evidence_discovered")) + authority_service.inspection_scheduled.connect(Callable(self, "_on_authority_inspection_scheduled")) + authority_service.inspection_state_changed.connect(Callable(self, "_on_authority_inspection_state_changed")) + authority_service.resolution_applied.connect(Callable(self, "_on_authority_resolution_applied")) + +func _initialize_s5_defense() -> void: + defense_service = DefenseService.new() + defense_service.current_minute = _clock.absolute_minute + _configure_defense_boundaries() + _connect_defense_signals() + +func _defense_operation_minute() -> int: + return _active_command_minute if _active_command_minute >= 0 else _clock.absolute_minute + +func _runtime_event_minute() -> int: + return _active_command_minute if _active_command_minute >= 0 else _clock.absolute_minute + +func _configure_defense_boundaries() -> void: + if defense_service == null: + return + defense_service.configure_callbacks( + Callable(self, "_roll_defense_round"), + Callable(self, "_apply_defense_skill_boundary"), + Callable(self, "_apply_defense_outcome_boundary"), + Callable(self, "_apply_defense_rng_boundary") + ) + +func _connect_defense_signals() -> void: + if defense_service == null: + return + var bindings := [ + [&"raid_created", Callable(self, "_on_defense_raid_created")], + [&"raid_state_changed", Callable(self, "_on_defense_raid_state_changed")], + [&"active_skill_applied", Callable(self, "_on_defense_active_skill_applied")], + [&"round_resolved", Callable(self, "_on_defense_round_resolved")], + [&"raid_resolved", Callable(self, "_on_defense_raid_resolved")], + ] + for binding: Array in bindings: + var signal_name: StringName = binding[0] + var callback: Callable = binding[1] + if not defense_service.is_connected(signal_name, callback): + defense_service.connect(signal_name, callback) + +func _detach_defense_service(service: DefenseService) -> void: + if service == null: + return + # A caller may retain a RefCounted aggregate across Runtime initialize/load. + # Retiring it must sever both transaction callbacks and event projection so + # stale objects cannot consume the new Runtime's RNG/resources or emit ghost + # DomainEvents after an otherwise atomic restore. + service.configure_callbacks() + var bindings := [ + [&"raid_created", Callable(self, "_on_defense_raid_created")], + [&"raid_state_changed", Callable(self, "_on_defense_raid_state_changed")], + [&"active_skill_applied", Callable(self, "_on_defense_active_skill_applied")], + [&"round_resolved", Callable(self, "_on_defense_round_resolved")], + [&"raid_resolved", Callable(self, "_on_defense_raid_resolved")], + ] + for binding: Array in bindings: + var signal_name: StringName = binding[0] + var callback: Callable = binding[1] + if service.is_connected(signal_name, callback): + service.disconnect(signal_name, callback) + +func _configure_facility_boundaries() -> void: + if facility_service == null: + return + facility_service.configure_callbacks( + Callable(self, "_validate_service_start"), + Callable(self, "_complete_service_transaction") + ) + +func _connect_facility_signals() -> void: + if facility_service == null: + return + facility_service.facility_state_changed.connect(Callable(self, "_on_facility_state_changed")) + facility_service.service_started.connect(Callable(self, "_on_service_started")) + facility_service.service_interrupted.connect(Callable(self, "_on_service_interrupted")) + facility_service.service_completed.connect(Callable(self, "_on_service_completed")) + facility_service.service_failed.connect(Callable(self, "_on_service_failed")) + +func _seed_boundary_cells(bounds: Dictionary) -> void: + for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1): + for layer in [WorldService.LAYER_FRONT, WorldService.LAYER_BACK]: + var cell := Vector3i(x, 0, layer) + world_service.set_walkable(cell, true) + world_service.set_support(cell, 0, true) + world_service.consume_dirty_cells() + +func _expand_world_to_rank(rank: int) -> bool: + var bounds := _bounds_for_rank(rank) + if not world_service.expand_bounds(bounds): + return false + _seed_boundary_cells(bounds) + return rebuild_path_graph_from_world() + +func _bounds_for_rank(rank: int) -> Dictionary: + var source: Variant = ProgressionService.RANK_BOUNDS.get(clampi(rank, 1, 7), {}) + return source.duplicate(true) if source is Dictionary else {} + + +func _sync_progression_live_metrics() -> bool: + return progression_service.update_metrics({ + &"active_above_ground_facilities": _count_active_facilities(facility_service, false), + &"active_underground_facilities": _count_active_facilities(facility_service, true), + &"asset_gold": economy_service.get_balance(PLAYER_CASH_ACCOUNT), + }) + + +func _count_active_facilities(service: FacilityService, underground: bool) -> int: + var count := 0 + for instance: FacilityInstance in service.get_instances(): + if instance.state not in [FacilityInstance.State.OPEN, FacilityInstance.State.DEGRADED]: + continue + if (instance.origin.y < 0) == underground: + count += 1 + return count + + +func _capture_rank_external_state() -> Dictionary: + return { + &"world": world_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"excavation": excavation_service.create_snapshot(), + &"spatial_subslots": spatial_subslot_service.create_snapshot(), + &"route_projection": route_subslot_path_adapter.create_snapshot(), + &"facility_infrastructure": facility_infrastructure_projector.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"movement_credits": _movement_credits.duplicate(true), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + } + + +func _apply_rank_external_state(target_rank: int, expected_bounds: Dictionary) -> bool: + if target_rank != int(_state.world_tree_rank) + 1 \ + or not _strict_variants_equal(_bounds_for_rank(target_rank), expected_bounds): + return false + if not _expand_world_to_rank(target_rank): + return false + if not _state.set_world_tree_rank(target_rank): + return false + return ( + int(_state.world_tree_rank) == target_rank + and _strict_variants_equal(world_service.get_bounds(), expected_bounds) + and _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ) + and _validate_runtime_path_crosslinks( + path_service, + guest_service, + staff_service, + _movement_credits, + _clock.absolute_minute + ) + ) + + +func _rollback_rank_external_state(snapshot: Dictionary) -> bool: + if not snapshot.get(&"world", null) is Dictionary \ + or not snapshot.get(&"path", null) is Dictionary \ + or not snapshot.get(&"excavation", null) is Dictionary \ + or not snapshot.get(&"spatial_subslots", null) is Dictionary \ + or not snapshot.get(&"route_projection", null) is Dictionary \ + or not snapshot.get(&"facility_infrastructure", null) is Dictionary \ + or not snapshot.get(&"game_state", null) is Dictionary \ + or not snapshot.get(&"movement_credits", null) is Dictionary \ + or not snapshot.get(&"domain_events", null) is Dictionary \ + or not snapshot.get(&"id_generator", null) is Dictionary: + return false + var restored := world_service.restore_snapshot(snapshot[&"world"]) + restored = spatial_subslot_service.restore_snapshot(snapshot[&"spatial_subslots"]) and restored + var route_snapshot: Dictionary = snapshot[&"route_projection"] + var projected_walkable: Variant = route_snapshot.get(&"projected_walkable_cells", null) + if not projected_walkable is Array: + return false + restored = path_service.rebuild_graph(projected_walkable) and restored + restored = path_service.restore_snapshot(snapshot[&"path"]) and restored + restored = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored + restored = facility_infrastructure_projector.restore_snapshot( + snapshot[&"facility_infrastructure"] + ) and restored + restored = excavation_service.restore_snapshot(snapshot[&"excavation"]) and restored + restored = _state.restore_snapshot(snapshot[&"game_state"]) and restored + _movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true) + restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored + restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored + if not restored: + return false + return ( + _strict_variants_equal(world_service.create_snapshot(), snapshot[&"world"]) + and _strict_variants_equal(path_service.create_snapshot(), snapshot[&"path"]) + and _strict_variants_equal( + excavation_service.create_snapshot(), snapshot[&"excavation"] + ) + and _strict_variants_equal( + spatial_subslot_service.create_snapshot(), snapshot[&"spatial_subslots"] + ) + and _strict_variants_equal( + route_subslot_path_adapter.create_snapshot(), snapshot[&"route_projection"] + ) + and _strict_variants_equal( + facility_infrastructure_projector.create_snapshot(), + snapshot[&"facility_infrastructure"] + ) + and _strict_variants_equal(_state.create_snapshot(), snapshot[&"game_state"]) + and _strict_variants_equal(_movement_credits, snapshot[&"movement_credits"]) + and _strict_variants_equal(domain_events.create_snapshot(), snapshot[&"domain_events"]) + and _strict_variants_equal(id_generator.create_snapshot(), snapshot[&"id_generator"]) + and _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ) + and _validate_runtime_path_crosslinks( + path_service, + guest_service, + staff_service, + _movement_credits, + _clock.absolute_minute + ) + ) + +func _process_commands(absolute_minute: int) -> bool: + var previous_command_minute := _active_command_minute + _active_command_minute = absolute_minute + var results := command_queue.execute_ready(absolute_minute, Callable(self, "_execute_command")) + _active_command_minute = previous_command_minute + _synchronize_operations_population() + for result in results: + var entity_ids: Array[StringName] = [] + if not result.target_id.is_empty(): + entity_ids.append(result.target_id) + var event := SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"COMMAND_COMPLETED" if result.is_success() else &"COMMAND_FAILED", + absolute_minute, + &"simulation_runtime", + entity_ids, + result.to_dictionary(), + result.command_id + ) + _publish_domain_event(event) + command_completed.emit(result) + if _operations_resume_publish_pending: + _operations_resume_publish_pending = false + _publish_snapshot(absolute_minute) + _process_save_checkpoints(absolute_minute) + return true + return false + +func _process_tick_commands(current_absolute_minute: int) -> void: + if _operations_enabled: + _operations_tick_blocked = _operations_should_block_tick() + if _operations_tick_blocked: + _clock.set_paused(true) + _clock.request_current_tick_abort() + return + _process_commands(current_absolute_minute + 1) + _synchronize_operations_population() + + +func _synchronize_operations_population() -> bool: + if not _operations_enabled or operations_coordinator == null \ + or _operations_transaction_active: + return true + var active_count := guest_service.guest_count() + if operations_coordinator.get_population_count() == active_count: + return true + var synchronized := operations_coordinator.set_population_count(active_count) + if synchronized.is_success(): + return true + _operations_runtime_failure_latched = true + _operations_runtime_failure_report = synchronized.to_dictionary() + _operations_tick_blocked = true + _clock.set_paused(true) + return false + +func _execute_command(command: SimulationCommand) -> CommandResult: + if not _executors.has(command.command_type): + return CommandResult.failed( + CommandResult.Code.INVALID_COMMAND, + command.command_id, + "No executor is registered for command type '%s'." % command.command_type + ) + if command.command_type in DEFENSE_COMMAND_TYPES: + var advanced := defense_service.advance_time(_defense_operation_minute()) + if not advanced.is_success(): + advanced.command_id = command.command_id + return advanced + var executor: Callable = _executors[command.command_type] + var returned: Variant = executor.call(command) + if returned is CommandResult: + return returned + return CommandResult.failed( + CommandResult.Code.EXECUTOR_FAILED, + command.command_id, + "Command executor returned an invalid result." + ) + +func _execute_change_resources(command: SimulationCommand) -> CommandResult: + var deltas: Variant = command.payload.get(&"deltas", command.payload.get("deltas", null)) + if not deltas is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "deltas must be a Dictionary.") + var state_before: Dictionary = _state.create_snapshot() + var economy_before := economy_service.create_snapshot() + var progression_before := progression_service.create_snapshot() + var events_before := domain_events.create_snapshot() + var ids_before := id_generator.create_snapshot() + if not _apply_command_resource_deltas(command, deltas): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Resource balance would become negative.") + var faith_delta := 0 + for raw_resource_id: Variant in deltas: + if StringName(raw_resource_id) == &"faith": + faith_delta += int(deltas[raw_resource_id]) + if faith_delta > 0: + var next_faith_lifetime := int(progression_service.metrics[&"faith_lifetime"]) + faith_delta + if not progression_service.update_metrics({&"faith_lifetime": next_faith_lifetime}): + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + progression_service.restore_snapshot(progression_before) + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(ids_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Progression lifetime Faith could not be updated atomically." + ) + return CommandResult.succeeded(command.command_id, { + &"revision": _state.revision, + &"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT), + &"faith_lifetime": int(progression_service.metrics[&"faith_lifetime"]), + }) + +func _execute_spend_resources(command: SimulationCommand) -> CommandResult: + var cost: Variant = command.payload.get(&"cost", command.payload.get("cost", null)) + if not cost is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "cost must be a Dictionary.") + if not operating_resource_state.spend(cost): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Resources are insufficient.") + return CommandResult.succeeded(command.command_id, { + &"revision": _state.revision, + &"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT), + }) + +func _apply_command_resource_deltas(command: SimulationCommand, deltas: Dictionary) -> bool: + var normalized: Dictionary = {} + for raw_name: Variant in deltas: + var resource_name := StringName(raw_name) + normalized[resource_name] = int(normalized.get(resource_name, 0)) + int(deltas[raw_name]) + for resource_name: StringName in normalized: + var current := economy_service.get_balance(PLAYER_CASH_ACCOUNT) \ + if resource_name == &"gold" else int(_state.get_resource(resource_name)) + if current + int(normalized[resource_name]) < 0: + return false + + var before_state: Dictionary = _state.create_snapshot() + var before_economy: Dictionary = economy_service.create_snapshot() + var non_gold := normalized.duplicate(true) + var gold_delta := int(non_gold.get(&"gold", 0)) + non_gold.erase(&"gold") + if not non_gold.is_empty() and not _state.apply_resource_deltas(non_gold): + return false + if gold_delta != 0: + var ledger_result: CommandResult + var ledger_idempotency := StringName("runtime.resource.%08d" % command.command_id) + if gold_delta > 0: + ledger_result = economy_service.transfer( + EconomyService.SYSTEM_SOURCE, + PLAYER_CASH_ACCOUNT, + gold_delta, + _runtime_event_minute(), + &"GENERATION", + command.actor_id, + ledger_idempotency + ) + else: + ledger_result = economy_service.transfer( + PLAYER_CASH_ACCOUNT, + EconomyService.SYSTEM_SINK, + -gold_delta, + _runtime_event_minute(), + &"DESTRUCTION", + command.actor_id, + ledger_idempotency + ) + if not ledger_result.is_success(): + _state.restore_snapshot(before_state) + economy_service.restore_snapshot(before_economy) + return false + _state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT)) + return true + +func _execute_add_inventory_stack(command: SimulationCommand) -> CommandResult: + var owner_id := StringName(command.payload.get(&"owner_inventory_id", command.payload.get("owner_inventory_id", ""))) + var item_id := StringName(command.payload.get(&"item_id", command.payload.get("item_id", ""))) + var quantity := int(command.payload.get(&"quantity", command.payload.get("quantity", 0))) + var result := inventory_service.add_stack( + owner_id, + item_id, + quantity, + int(command.payload.get(&"quality", command.payload.get("quality", InventoryService.DEFAULT_QUALITY))), + int(command.payload.get(&"expires_at", command.payload.get("expires_at", -1))), + _runtime_event_minute(), + int(command.payload.get(&"unit_weight", command.payload.get("unit_weight", InventoryService.DEFAULT_UNIT_WEIGHT))), + StringName(command.payload.get(&"stack_id", command.payload.get("stack_id", ""))) + ) + return _command_result_from_dictionary(command.command_id, result, "Inventory stack could not be added.") + +func _execute_create_logistics_request(command: SimulationCommand) -> CommandResult: + var source_id := StringName(command.payload.get(&"source_inventory_id", command.payload.get("source_inventory_id", ""))) + var destination_id := StringName(command.payload.get(&"destination_inventory_id", command.payload.get("destination_inventory_id", ""))) + var raw_requirements: Variant = command.payload.get(&"item_requirements", command.payload.get("item_requirements", null)) + if source_id.is_empty() or destination_id.is_empty() or source_id == destination_id \ + or not raw_requirements is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Source, destination, and item requirements are required.") + var requirements: Dictionary = {} + for raw_item_id: Variant in raw_requirements: + var item_id := StringName(raw_item_id) + var quantity := int(raw_requirements[raw_item_id]) + if item_id.is_empty() or quantity <= 0: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Logistics requirements must be positive.") + requirements[item_id] = quantity + if requirements.is_empty(): + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "At least one item is required.") + + var inventory_before := inventory_service.create_snapshot() + var id_before := id_generator.create_snapshot() + var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", ""))) + if request_id.is_empty(): + request_id = id_generator.next_id(&"logistics_request") + if logistics_requests.has(request_id): + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.DUPLICATE_COMMAND_ID, command.command_id, "Logistics request id already exists.") + var reservation_ids: Array[StringName] = [] + var stack_ids: Array[StringName] = [] + var total_weight := 0 + var deadline := int(command.payload.get(&"deadline", command.payload.get("deadline", -1))) + for item_id: StringName in _sorted_name_keys(requirements): + var reserve_result := inventory_service.reserve_item( + source_id, + item_id, + int(requirements[item_id]), + request_id, + &"logistics", + _runtime_event_minute(), + deadline, + StringName("logistics.reserve.%s.%s" % [request_id, item_id]) + ) + if not bool(reserve_result.get(&"ok", false)): + inventory_service.restore_snapshot(inventory_before) + id_generator.restore_snapshot(id_before) + return _command_result_from_dictionary(command.command_id, reserve_result, "Logistics stock could not be reserved.") + for reservation_id: StringName in reserve_result.get(&"reservation_ids", []): + reservation_ids.append(reservation_id) + var reservation := inventory_service.get_reservation(reservation_id) + var stack := inventory_service.get_stack(reservation.stack_id) + stack_ids.append(reservation.stack_id) + total_weight += reservation.quantity * int(stack.get(&"unit_weight", 1)) + var request := LogisticsRequest.new().configure( + request_id, + source_id, + destination_id, + requirements, + int(command.payload.get(&"priority", command.payload.get("priority", 50))), + _runtime_event_minute(), + deadline, + total_weight + ) + if not request.mark_reserved(reservation_ids, stack_ids, _runtime_event_minute()): + inventory_service.restore_snapshot(inventory_before) + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Logistics request could not enter RESERVED state.") + logistics_requests[request_id] = request + return CommandResult.succeeded(command.command_id, { + &"request_id": request_id, + &"reservation_ids": reservation_ids, + &"total_weight": total_weight, + }) + +func _execute_dispatch_logistics(command: SimulationCommand) -> CommandResult: + var carriers: Variant = command.payload.get(&"carriers", command.payload.get("carriers", null)) + if not carriers is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "carriers must be a Dictionary.") + var assignments := logistics_dispatcher.dispatch(_sorted_logistics_requests(), carriers, _runtime_event_minute()) + return CommandResult.succeeded(command.command_id, { + &"assignments": assignments, + &"report": logistics_dispatcher.last_report.duplicate(true), + }) + +func _execute_advance_logistics_request(command: SimulationCommand) -> CommandResult: + var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", ""))) + if not logistics_requests.has(request_id): + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Logistics request was not found.") + var request: LogisticsRequest = logistics_requests[request_id] + var request_before := request.create_snapshot() + var inventory_before_command := inventory_service.create_snapshot() + var dispatcher_before := logistics_dispatcher.create_snapshot() + match request.state: + LogisticsRequest.State.ASSIGNED: + if not request.transition_to(LogisticsRequest.State.PICKING_UP, "", _runtime_event_minute()): + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Logistics pickup could not begin.") + LogisticsRequest.State.PICKING_UP: + var carrier_id := request.assigned_carrier_id + var pickup := inventory_service.transfer_reserved( + request.reservation_ids, + StringName("cargo:%s" % carrier_id), + StringName("logistics.pickup.%s" % request_id), + _runtime_event_minute() + ) + if not bool(pickup.get(&"ok", false)): + request.mark_blocked(String(pickup.get(&"code", &"PICKUP_FAILED")), _runtime_event_minute()) + var released := logistics_dispatcher.release_assignment_for_request( + request_id, carrier_id, request.total_weight + ) + if not released: + inventory_service.restore_snapshot(inventory_before_command) + logistics_dispatcher.restore_snapshot(dispatcher_before) + logistics_requests[request_id] = LogisticsRequest.from_snapshot(request_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Logistics pickup failure could not release request ownership." + ) + return _command_result_from_dictionary(command.command_id, pickup, "Logistics pickup failed.") + request.transition_to(LogisticsRequest.State.DELIVERING, "", _runtime_event_minute()) + LogisticsRequest.State.DELIVERING: + var carrier_id := request.assigned_carrier_id + var cargo_id := StringName("cargo:%s" % carrier_id) + var inventory_before := inventory_service.create_snapshot() + for item_id: StringName in _sorted_name_keys(request.item_requirements): + var delivery := inventory_service.transfer_available( + cargo_id, + request.destination_inventory_id, + item_id, + int(request.item_requirements[item_id]), + StringName("logistics.delivery.%s.%s" % [request_id, item_id]), + _runtime_event_minute() + ) + if not bool(delivery.get(&"ok", false)): + inventory_service.restore_snapshot(inventory_before) + request.mark_blocked(String(delivery.get(&"code", &"DELIVERY_FAILED")), _runtime_event_minute()) + var released := logistics_dispatcher.release_assignment_for_request( + request_id, carrier_id, request.total_weight + ) + if not released: + inventory_service.restore_snapshot(inventory_before_command) + logistics_dispatcher.restore_snapshot(dispatcher_before) + logistics_requests[request_id] = LogisticsRequest.from_snapshot(request_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Logistics delivery failure could not release request ownership." + ) + return _command_result_from_dictionary(command.command_id, delivery, "Logistics delivery failed.") + request.transition_to(LogisticsRequest.State.COMPLETED, "", _runtime_event_minute()) + var released := logistics_dispatcher.release_assignment_for_request( + request_id, carrier_id, request.total_weight + ) + if not released: + inventory_service.restore_snapshot(inventory_before_command) + logistics_dispatcher.restore_snapshot(dispatcher_before) + logistics_requests[request_id] = LogisticsRequest.from_snapshot(request_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Completed Logistics delivery could not release request ownership." + ) + _: + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Logistics request cannot advance from its current state.") + return CommandResult.succeeded(command.command_id, { + &"request_id": request_id, + &"state": LogisticsRequest.State.keys()[request.state], + }) + +func _execute_register_guest_wallet(command: SimulationCommand) -> CommandResult: + var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", ""))) + var amount := int(command.payload.get(&"gold", command.payload.get("gold", 0))) + if guest_id.is_empty() or amount < 0: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "A guest id and non-negative Gold are required.") + var account_id := _guest_wallet_account(guest_id) + if economy_service.has_account(account_id): + return CommandResult.failed(CommandResult.Code.DUPLICATE_COMMAND_ID, command.command_id, "Guest wallet already exists.") + var before := economy_service.create_snapshot() + if not economy_service.register_account(account_id): + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Guest wallet account could not be registered.") + if amount > 0: + var initialized := economy_service.initialize_account( + account_id, + amount, + _runtime_event_minute(), + StringName("guest.wallet.%s" % guest_id) + ) + if not initialized.is_success(): + economy_service.restore_snapshot(before) + return initialized + return CommandResult.succeeded(command.command_id, {&"account_id": account_id, &"balance": amount}) + +func _execute_spawn_guest(command: SimulationCommand) -> CommandResult: + if _operations_enabled and operations_coordinator != null: + if operations_coordinator.is_terminal() \ + or operations_coordinator.is_external_quarantined(): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Operations currently block new Guest arrivals." + ) + if guest_service.guest_count() >= int( + operations_coordinator.get_config().get(&"population_cap", 0) + ): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Operations Guest population capacity is full." + ) + var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", ""))) + var id_before := id_generator.create_snapshot() + if guest_id.is_empty(): + guest_id = id_generator.next_id(&"guest") + if String(guest_id).begins_with("guest-auto-") \ + and not (_operations_transaction_active \ + and operations_coordinator != null \ + and command.actor_id == &"operations_coordinator"): + id_generator.restore_snapshot(id_before) + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "guest-auto IDs are reserved for the OperationsCoordinator." + ) + var guest_type_id := StringName(command.payload.get(&"guest_type_id", command.payload.get("guest_type_id", "traveler"))) + var definition := guest_service.get_definition(guest_type_id) + if definition == null: + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Guest definition was not found.") + if guest_service.has_guest(guest_id): + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.DUPLICATE_COMMAND_ID, command.command_id, "Guest already exists.") + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {} + var initial_cell_value: Variant = _parse_world_cell( + options.get(&"current_cell", options.get("current_cell", Vector3i.ZERO)) + ) + if initial_cell_value == null: + id_generator.restore_snapshot(id_before) + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "Guest current_cell must be Vector3i or [x, y, layer]." + ) + var initial_cell: Vector3i = initial_cell_value + var initial_traffic := path_service.get_cell_traffic(initial_cell) + var path_actor_id := _guest_path_actor_id(guest_id) + if initial_traffic.is_empty() or bool(initial_traffic.get(&"temporary_block", false)) \ + or path_service.has_actor_projection(path_actor_id): + id_generator.restore_snapshot(id_before) + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Guest current_cell must be an unblocked walkable cell with a free Path actor id." + ) + options[&"current_cell"] = initial_cell + var requested_gold := int(command.payload.get( + &"gold", + command.payload.get("gold", options.get(&"wallet_gold", options.get("wallet_gold", definition.wealth_min))) + )) + if requested_gold < 0: + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Guest Gold cannot be negative.") + var defer_spawn_notification := _operations_transaction_active \ + and operations_coordinator != null \ + and command.actor_id == &"operations_coordinator" + var account_id := _guest_wallet_account(guest_id) + var economy_before := economy_service.create_snapshot() + var guest_before := guest_service.create_snapshot() + var path_before := path_service.create_snapshot() + var movement_credits_before := _movement_credits.duplicate(true) + var events_before := domain_events.create_snapshot() + var progression_before := progression_service.create_snapshot() + if economy_service.has_account(account_id): + requested_gold = economy_service.get_balance(account_id) + else: + if not economy_service.register_account(account_id): + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Guest wallet account could not be registered.") + if requested_gold > 0: + var initialized := economy_service.initialize_account( + account_id, + requested_gold, + _runtime_event_minute(), + StringName("guest.wallet.%s" % guest_id), + not defer_spawn_notification + ) + if not initialized.is_success(): + economy_service.restore_snapshot(economy_before) + id_generator.restore_snapshot(id_before) + initialized.command_id = command.command_id + return initialized + if defer_spawn_notification \ + and not _stage_operations_economy_notification(initialized): + economy_service.restore_snapshot(economy_before) + id_generator.restore_snapshot(id_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Guest wallet notification could not be staged." + ) + options[&"wallet_gold"] = requested_gold + var guest := guest_service.spawn_guest( + guest_id, + guest_type_id, + _runtime_event_minute(), + options, + not defer_spawn_notification + ) + if guest != null and defer_spawn_notification: + # Runtime's Path projection and DomainEvent are part of the external + # transaction, while the observable GuestService signal is held until the + # Coordinator minute and DomainEvent batch have both committed. + _on_guest_spawned(guest) + if guest == null or not path_service.has_actor_projection(path_actor_id) \ + or path_service.get_actor_projected_cell(path_actor_id) != guest.current_cell \ + or not _movement_credits.has(path_actor_id): + economy_service.restore_snapshot(economy_before) + guest_service.restore_snapshot(guest_before) + path_service.restore_snapshot(path_before) + _movement_credits = movement_credits_before + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(id_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + guest_service.last_error if guest == null else "Guest Path projection failed." + ) + var next_guest_lifetime := int(progression_service.metrics[&"guest_lifetime"]) + 1 + if not progression_service.update_metrics({&"guest_lifetime": next_guest_lifetime}): + economy_service.restore_snapshot(economy_before) + guest_service.restore_snapshot(guest_before) + path_service.restore_snapshot(path_before) + _movement_credits = movement_credits_before + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(id_before) + progression_service.restore_snapshot(progression_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Progression lifetime Guest count could not be updated atomically." + ) + if defer_spawn_notification: + _operations_queued_guest_spawn_notifications.append(guest_id) + return CommandResult.succeeded(command.command_id, { + &"guest_id": guest_id, + &"guest_type_id": guest_type_id, + &"wallet_account": account_id, + &"wallet_gold": requested_gold, + &"guest_lifetime": next_guest_lifetime, + }) + +func _execute_transition_guest(command: SimulationCommand) -> CommandResult: + var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", ""))) + var state_index := _guest_state_index(command.payload.get(&"state", command.payload.get("state", -1))) + if guest_id.is_empty() or state_index < 0: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Guest id and valid state are required.") + var guest := guest_service.get_guest(guest_id) + if guest == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Guest was not found.") + if not guest.queue_request_id.is_empty() or not guest.current_service_id.is_empty(): + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "A queued or serviced Guest must transition through the owning Facility transaction." + ) + var result := guest_service.transition_guest( + guest_id, + state_index, + _runtime_event_minute(), + StringName(command.payload.get(&"reason", command.payload.get("reason", "PLAYER_COMMAND"))) + ) + return _command_result_from_dictionary(command.command_id, result, "Guest transition failed.") + +func _execute_decide_guest(command: SimulationCommand) -> CommandResult: + var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", ""))) + var guest := guest_service.get_guest(guest_id) + if guest == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Guest was not found.") + var wallet_account := _guest_wallet_account(guest_id) + if economy_service.has_account(wallet_account): + guest.wallet_gold = economy_service.get_balance(wallet_account) + var raw_candidates: Variant = command.payload.get(&"candidates", command.payload.get("candidates", [])) + var candidates: Array = raw_candidates.duplicate(true) if raw_candidates is Array else [] + if candidates.is_empty(): + candidates = _project_guest_candidates(guest_id) + var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {})) + var context: Dictionary = raw_context.duplicate(true) if raw_context is Dictionary else {} + context[&"absolute_minute"] = _runtime_event_minute() + var result := guest_service.decide(guest_id, candidates, _runtime_event_minute(), context) + return _command_result_from_dictionary(command.command_id, result, "Guest decision failed.") + +func _execute_register_staff(command: SimulationCommand) -> CommandResult: + var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", ""))) + var role_id := StringName(command.payload.get(&"role_id", command.payload.get("role_id", ""))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if staff_id.is_empty() or role_id.is_empty() or not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Staff id, role id and options are required.") + if _is_mvp_staff_candidate_id(staff_id): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Catalog Staff candidates must be created through hire_staff_candidate." + ) + var options: Dictionary = raw_options.duplicate(true) + var initial_cell_value: Variant = _parse_world_cell( + options.get(&"current_cell", options.get("current_cell", Vector3i.ZERO)) + ) + if initial_cell_value == null: + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "Staff current_cell must be Vector3i or [x, y, layer]." + ) + var initial_cell: Vector3i = initial_cell_value + var initial_traffic := path_service.get_cell_traffic(initial_cell) + var path_actor_id := _staff_path_actor_id(staff_id) + if initial_traffic.is_empty() or bool(initial_traffic.get(&"temporary_block", false)) \ + or path_service.has_actor_projection(path_actor_id): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Staff current_cell must be an unblocked walkable cell with a free Path actor id." + ) + options[&"current_cell"] = initial_cell + var staff_before := staff_service.create_snapshot() + var path_before := path_service.create_snapshot() + var credits_before := _movement_credits.duplicate(true) + var events_before := domain_events.create_snapshot() + var ids_before := id_generator.create_snapshot() + options[&"employment_effective_minute"] = _runtime_event_minute() + var result := staff_service.create_staff(staff_id, role_id, options) + if result.is_success() and ( + not path_service.has_actor_projection(path_actor_id) + or path_service.get_actor_projected_cell(path_actor_id) != initial_cell + or not _movement_credits.has(path_actor_id) + ): + staff_service.restore_snapshot(staff_before) + path_service.restore_snapshot(path_before) + _movement_credits = credits_before + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(ids_before) + result = CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Staff Path projection failed." + ) + result.command_id = command.command_id + return result + + +func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: + # A new employment contract creates an immediate signing expense and a + # recurring wage liability, so it follows the same Operations investment + # gate as Construction and Excavation once production operations are active. + var operations_gate := _operations_construction_investment_gate(command.command_id) + if operations_gate != null: + return operations_gate + var raw_candidate_id: Variant = command.payload.get( + &"candidate_id", command.payload.get("candidate_id", null) + ) + if typeof(raw_candidate_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "A catalog Staff candidate id is required." + ) + var candidate_id := StringName(raw_candidate_id) + var canonical := mvp_content_catalog.staff_hire_payload(candidate_id) + if canonical.is_empty(): + return CommandResult.failed( + CommandResult.Code.NOT_FOUND, + command.command_id, + "Staff candidate was not found in the validated MVP catalog." + ) + if not bool(canonical.get(&"adult_verified", false)): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Staff candidate does not have verified adult eligibility." + ) + if _hired_staff_candidate_ids.has(candidate_id) or staff_service.has_staff(candidate_id): + return CommandResult.failed( + CommandResult.Code.DUPLICATE_COMMAND_ID, + command.command_id, + "Staff candidate has already been hired." + ) + var initial_cell_value: Variant = _parse_world_cell( + command.payload.get(&"current_cell", command.payload.get("current_cell", Vector3i.ZERO)) + ) + if initial_cell_value == null: + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "Staff current_cell must be Vector3i or [x, y, layer]." + ) + var initial_cell: Vector3i = initial_cell_value + var path_actor_id := _staff_path_actor_id(candidate_id) + var traffic := path_service.get_cell_traffic(initial_cell) + if traffic.is_empty() or bool(traffic.get(&"temporary_block", false)) \ + or path_service.has_actor_projection(path_actor_id): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Staff current_cell must be an unblocked walkable cell with a free Path actor id." + ) + var assigned_facility_id := StringName(command.payload.get( + &"facility_id", command.payload.get("facility_id", "") + )) + if not assigned_facility_id.is_empty() \ + and not facility_service.has_instance(assigned_facility_id): + return CommandResult.failed( + CommandResult.Code.NOT_FOUND, + command.command_id, + "Assigned Staff facility was not found." + ) + + var before := { + &"economy": economy_service.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"staff": staff_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"movement_credits": _movement_credits.duplicate(true), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + &"hired_ids": _hired_staff_candidate_ids.duplicate(true), + &"staffing_projection_enabled": _staffing_projection_enabled, + } + var signing_cost := int(canonical[&"signing_cost"]) + if signing_cost > 0: + var payment := economy_service.transfer( + PLAYER_CASH_ACCOUNT, + OPERATING_EXPENSE_ACCOUNT, + signing_cost, + _runtime_event_minute(), + &"STAFF_SIGNING_COST", + candidate_id, + StringName("staff_signing_%s" % candidate_id), + &"staff.signing_cost" + ) + if not payment.is_success(): + payment.command_id = command.command_id + return payment + + var options: Dictionary = (canonical[&"options"] as Dictionary).duplicate(true) + options[&"current_cell"] = initial_cell + options[&"assigned_facility_id"] = assigned_facility_id + options[&"employment_effective_minute"] = _runtime_event_minute() + var staff_result := staff_service.create_staff( + candidate_id, + StringName(canonical[&"role_id"]), + options + ) + if not staff_result.is_success(): + _rollback_staff_candidate_hire(before) + staff_result.command_id = command.command_id + return staff_result + var preferred_shift := StringName(canonical[&"preferred_shift"]) + var shift_day := _candidate_shift_start_day(preferred_shift, _runtime_event_minute()) + var shift_result: CommandResult + if preferred_shift in [&"day", &"night"]: + shift_result = staff_service.create_standard_shift( + candidate_id, + preferred_shift, + shift_day, + assigned_facility_id + ) + else: + shift_result = staff_service.create_flex_shift( + candidate_id, + shift_day, + ShiftInstance.DAY_START, + ShiftInstance.DAY_END, + assigned_facility_id + ) + var shift_id := StringName(shift_result.details.get(&"shift_id", &"")) + var hired_staff := staff_service.get_staff(candidate_id) + var hired_shift := staff_service.get_shift(shift_id) + if not shift_result.is_success() or hired_staff == null or hired_shift == null \ + or hired_staff.daily_wage != int(canonical[&"daily_wage"]) \ + or hired_staff.get_employment_kind_name() != &"FREE_HIRE" \ + or not path_service.has_actor_projection(path_actor_id) \ + or path_service.get_actor_projected_cell(path_actor_id) != initial_cell \ + or not _movement_credits.has(path_actor_id): + _rollback_staff_candidate_hire(before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Staff hire could not commit its employee, Shift, finance, and Path state atomically." + ) + _hired_staff_candidate_ids[candidate_id] = true + _publish_actor_event( + &"STAFF_CANDIDATE_HIRED", + &"simulation_runtime", + [candidate_id, shift_id], + { + &"candidate_id": candidate_id, + &"role_id": StringName(canonical[&"role_id"]), + &"daily_wage": int(canonical[&"daily_wage"]), + &"employment_kind": &"FREE_HIRE", + &"signing_cost": signing_cost, + &"shift_id": shift_id, + &"preferred_shift": preferred_shift, + } + ) + return CommandResult.succeeded(command.command_id, { + &"candidate_id": candidate_id, + &"staff_id": candidate_id, + &"role_id": StringName(canonical[&"role_id"]), + &"daily_wage": int(canonical[&"daily_wage"]), + &"employment_kind": &"FREE_HIRE", + &"signing_cost": signing_cost, + &"shift_id": shift_id, + &"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT), + }) + +func _execute_create_staff_shift(command: SimulationCommand) -> CommandResult: + var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", ""))) + var shift_id := StringName(command.payload.get(&"shift_id", command.payload.get("shift_id", ""))) + var start_day := int(command.payload.get(&"start_day", command.payload.get("start_day", _runtime_event_minute() / 1_440))) + var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", ""))) + var template_id := StringName(command.payload.get(&"template_id", command.payload.get("template_id", "FLEX"))) + var result: CommandResult + if String(template_id).to_upper() in ["DAY", "NIGHT"]: + result = staff_service.create_standard_shift(staff_id, template_id, start_day, facility_id, shift_id) + else: + var start_minute := int(command.payload.get(&"start_minute_of_day", command.payload.get("start_minute_of_day", posmod(_runtime_event_minute(), 1_440)))) + var end_minute := int(command.payload.get(&"end_minute_of_day", command.payload.get("end_minute_of_day", posmod(start_minute + 480, 1_440)))) + result = staff_service.create_flex_shift(staff_id, start_day, start_minute, end_minute, facility_id, shift_id) + result.command_id = command.command_id + return result + + +func _candidate_shift_start_day(preferred_shift: StringName, absolute_minute: int) -> int: + var day := absolute_minute / ShiftInstance.MINUTES_PER_DAY + var minute_of_day := posmod(absolute_minute, ShiftInstance.MINUTES_PER_DAY) + # A NIGHT record starts at 18:00 of its named day and remains meaningful even + # when hired after that start. DAY/FLEX records move to tomorrow once today's + # daytime window has ended. + if preferred_shift != &"night" and minute_of_day >= ShiftInstance.DAY_END: + day += 1 + return day + + +func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool: + var restored := economy_service.restore_snapshot(snapshot[&"economy"]) + restored = _state.restore_snapshot(snapshot[&"game_state"]) and restored + restored = staff_service.restore_snapshot(snapshot[&"staff"]) and restored + restored = path_service.restore_snapshot(snapshot[&"path"]) and restored + _movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true) + restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored + restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored + _hired_staff_candidate_ids = (snapshot[&"hired_ids"] as Dictionary).duplicate(true) + _staffing_projection_enabled = bool(snapshot[&"staffing_projection_enabled"]) + return restored and _validate_runtime_path_crosslinks( + path_service, + guest_service, + staff_service, + _movement_credits, + _runtime_event_minute() + ) + +func _execute_set_staff_schedule_override(command: SimulationCommand) -> CommandResult: + var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", ""))) + var start_day := int(command.payload.get(&"start_day", command.payload.get("start_day", -1))) + var patch_value: Variant = command.payload.get(&"patch", command.payload.get("patch", null)) + if not patch_value is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Schedule override patch must be a Dictionary.") + var result := staff_service.set_schedule_override(staff_id, start_day, patch_value) + result.command_id = command.command_id + return result + +func _execute_create_staff_task(command: SimulationCommand) -> CommandResult: + var task_type := StringName(command.payload.get(&"task_type", command.payload.get("task_type", ""))) + var priority := int(command.payload.get(&"priority", command.payload.get("priority", 50))) + var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", ""))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if task_type.is_empty() or not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Task type and options are required.") + var result := staff_service.create_task(task_type, priority, _runtime_event_minute(), raw_options, task_id) + result.command_id = command.command_id + return result + +func _execute_begin_staff_task(command: SimulationCommand) -> CommandResult: + var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", ""))) + var result := staff_service.begin_task(task_id, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_progress_staff_task(command: SimulationCommand) -> CommandResult: + var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", ""))) + var minutes := int(command.payload.get(&"minutes", command.payload.get("minutes", 1))) + var result := staff_service.progress_task(task_id, minutes, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_create_relationship(command: SimulationCommand) -> CommandResult: + var actor_a_id := StringName(command.payload.get(&"actor_a_id", command.payload.get("actor_a_id", ""))) + var actor_b_id := StringName(command.payload.get(&"actor_b_id", command.payload.get("actor_b_id", ""))) + if not _event_actor_exists(actor_a_id) or not _event_actor_exists(actor_b_id): + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Both relationship actors must exist.") + var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", ""))) + var id_before := id_generator.create_snapshot() + if relationship_id.is_empty(): + relationship_id = id_generator.next_id(&"relationship") + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {} + var result := relationship_service.create_relationship(relationship_id, actor_a_id, actor_b_id, options) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + return result + +func _execute_change_relationship(command: SimulationCommand) -> CommandResult: + var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", ""))) + var metric_id := StringName(command.payload.get(&"metric_id", command.payload.get("metric_id", ""))) + var delta := int(command.payload.get(&"delta", command.payload.get("delta", 0))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {} + var result := relationship_service.apply_value_change( + relationship_id, metric_id, delta, _runtime_event_minute(), options + ) + result.command_id = command.command_id + return result + +func _execute_attack_relationship_shield(command: SimulationCommand) -> CommandResult: + var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", ""))) + var action_id := StringName(command.payload.get(&"action_id", command.payload.get("action_id", ""))) + var raw_terms: Variant = command.payload.get(&"terms", command.payload.get("terms", {})) + if not raw_terms is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Shield attack terms must be a Dictionary.") + var result := relationship_service.apply_shield_attack( + relationship_id, action_id, _runtime_event_minute(), raw_terms + ) + result.command_id = command.command_id + return result + +func _execute_request_route_lock(command: SimulationCommand) -> CommandResult: + var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", ""))) + var route_id := StringName(command.payload.get(&"route_id", command.payload.get("route_id", ""))) + var lock_index := _route_lock_index(command.payload.get(&"route_lock", command.payload.get("route_lock", -1))) + if lock_index < 0: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "A valid Route Lock state is required.") + var result := relationship_service.request_route_lock( + relationship_id, + lock_index, + route_id, + bool(command.payload.get(&"irreversible_event", command.payload.get("irreversible_event", false))) + ) + result.command_id = command.command_id + return result + +func _execute_confirm_route_lock(command: SimulationCommand) -> CommandResult: + var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", ""))) + var route_id := StringName(command.payload.get(&"route_id", command.payload.get("route_id", ""))) + var result := relationship_service.confirm_route_lock(relationship_id, route_id) + result.command_id = command.command_id + return result + +func _execute_register_event_definition(command: SimulationCommand) -> CommandResult: + var raw_definition: Variant = command.payload.get(&"definition", command.payload.get("definition", null)) + var definition := EventDefinition.from_variant(raw_definition) + if definition == null: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Event definition is malformed.") + var result := event_service.register_definition(definition) + result.command_id = command.command_id + return result + +func _execute_enqueue_event(command: SimulationCommand) -> CommandResult: + var event_id := StringName(command.payload.get(&"event_id", command.payload.get("event_id", ""))) + var actor_ids := _name_array(command.payload.get(&"actor_ids", command.payload.get("actor_ids", []))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {} + var result := event_service.enqueue_candidate(event_id, actor_ids, _runtime_event_minute(), options) + result.command_id = command.command_id + return result + +func _execute_reserve_event_choice_cost(command: SimulationCommand) -> CommandResult: + var instance_id := StringName(command.payload.get(&"event_instance_id", command.payload.get("event_instance_id", ""))) + var choice_id := StringName(command.payload.get(&"choice_id", command.payload.get("choice_id", ""))) + var result := event_service.reserve_choice_cost(instance_id, choice_id, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_submit_event_choice(command: SimulationCommand) -> CommandResult: + var instance_id := StringName(command.payload.get(&"event_instance_id", command.payload.get("event_instance_id", ""))) + var choice_instance_id := StringName(command.payload.get(&"choice_instance_id", command.payload.get("choice_instance_id", ""))) + var choice_id := StringName(command.payload.get(&"choice_id", command.payload.get("choice_id", ""))) + var result := event_service.submit_choice( + instance_id, choice_instance_id, choice_id, _runtime_event_minute() + ) + if result.is_success() and event_service.get_foreground_instance() == null: + _clock.set_paused(false) + result.command_id = command.command_id + return result + +func _execute_report_authority_incident(command: SimulationCommand) -> CommandResult: + var incident_id := StringName(command.payload.get(&"incident_id", command.payload.get("incident_id", ""))) + var incident_type := StringName(command.payload.get(&"incident_type", command.payload.get("incident_type", ""))) + var detector_id := StringName(command.payload.get(&"detector_id", command.payload.get("detector_id", command.actor_id))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Incident options must be a Dictionary.") + var id_before := id_generator.create_snapshot() + if incident_id.is_empty(): + incident_id = id_generator.next_id(&"incident") + var result := authority_service.create_incident( + incident_id, + incident_type, + _runtime_event_minute(), + detector_id, + (raw_options as Dictionary).duplicate(true) + ) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + return result + +func _execute_register_authority_evidence(command: SimulationCommand) -> CommandResult: + var evidence_id := StringName(command.payload.get(&"evidence_id", command.payload.get("evidence_id", ""))) + var source := StringName(command.payload.get(&"source", command.payload.get("source", ""))) + var severity := int(command.payload.get(&"severity", command.payload.get("severity", -1))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Evidence options must be a Dictionary.") + var options: Dictionary = (raw_options as Dictionary).duplicate(true) + if not options.has(&"created_at") and not options.has("created_at"): + options[&"created_at"] = _runtime_event_minute() + var id_before := id_generator.create_snapshot() + if evidence_id.is_empty(): + evidence_id = id_generator.next_id(&"evidence") + var result := authority_service.create_evidence(evidence_id, source, severity, options) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + return result + +func _execute_discover_authority_evidence(command: SimulationCommand) -> CommandResult: + var evidence_id := StringName(command.payload.get(&"evidence_id", command.payload.get("evidence_id", ""))) + var inspector_id := StringName(command.payload.get(&"inspector_id", command.payload.get("inspector_id", command.actor_id))) + var parsed_cell: Variant = _parse_world_cell(command.payload.get(&"inspector_cell", command.payload.get("inspector_cell", null))) + var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {})) + if parsed_cell == null or not raw_context is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Evidence discovery requires inspector_cell and Dictionary context.") + var result := authority_service.attempt_discover_evidence( + evidence_id, + inspector_id, + parsed_cell, + _runtime_event_minute(), + (raw_context as Dictionary).duplicate(true) + ) + result.command_id = command.command_id + return result + +func _execute_evaluate_authority_inspection(command: SimulationCommand) -> CommandResult: + var raw_terms: Variant = command.payload.get(&"terms", command.payload.get("terms", {})) + if not raw_terms is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection evaluation terms must be a Dictionary.") + var result := authority_service.evaluate_inspection( + _runtime_event_minute(), + (raw_terms as Dictionary).duplicate(true) + ) + if result.is_success() and not result.is_replay: + _publish_authority_evaluation(result.details) + result.command_id = command.command_id + return result + +func _execute_schedule_authority_inspection(command: SimulationCommand) -> CommandResult: + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection options must be a Dictionary.") + var scheduled_at := int(command.payload.get(&"scheduled_at", command.payload.get("scheduled_at", _runtime_event_minute()))) + var result := authority_service.schedule_inspection( + scheduled_at, + (raw_options as Dictionary).duplicate(true) + ) + result.command_id = command.command_id + return result + +func _execute_start_authority_inspection(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var result := authority_service.start_inspection(inspection_id, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_arrive_authority_inspection(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var result := authority_service.mark_inspector_arrived(inspection_id, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_plan_authority_inspection(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var raw_candidates: Variant = command.payload.get(&"candidates", command.payload.get("candidates", null)) + if not raw_candidates is Array: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection patrol candidates must be an Array.") + var time_budget := int(command.payload.get(&"time_budget_minutes", command.payload.get("time_budget_minutes", 120))) + var result := authority_service.plan_patrol( + inspection_id, + raw_candidates, + _runtime_event_minute(), + time_budget + ) + result.command_id = command.command_id + return result + +func _execute_advance_authority_inspection(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {})) + if not raw_context is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection path context must be a Dictionary.") + var result := authority_service.advance_patrol( + inspection_id, + _runtime_event_minute(), + (raw_context as Dictionary).duplicate(true) + ) + if result.is_success() and result.details.has(&"reachable"): + var event_type := &"INSPECTION_WAYPOINT_REACHED" if bool(result.details[&"reachable"]) else &"INSPECTION_PATH_BLOCKED" + _publish_actor_event( + event_type, + &"authority_service", + [inspection_id, StringName(result.details.get(&"waypoint_id", ""))], + result.details.duplicate(true) + ) + result.command_id = command.command_id + return result + +func _execute_inspect_authority_waypoint(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var evidence_ids := _name_array(command.payload.get(&"evidence_ids", command.payload.get("evidence_ids", []))) + var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {})) + if not raw_context is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection evidence context must be a Dictionary.") + var result := authority_service.inspect_current_waypoint( + inspection_id, + evidence_ids, + _runtime_event_minute(), + (raw_context as Dictionary).duplicate(true) + ) + if result.is_success(): + _publish_actor_event( + &"INSPECTION_WAYPOINT_INSPECTED", + &"authority_service", + [inspection_id], + result.details.duplicate(true) + ) + result.command_id = command.command_id + return result + +func _execute_resolve_authority_inspection(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", ""))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection resolution options must be a Dictionary.") + var id_before := id_generator.create_snapshot() + if resolution_id.is_empty(): + resolution_id = id_generator.next_id(&"resolution") + var result := authority_service.resolve_inspection( + inspection_id, + resolution_id, + _runtime_event_minute(), + (raw_options as Dictionary).duplicate(true) + ) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + return result + +func _execute_depart_authority_inspection(command: SimulationCommand) -> CommandResult: + var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", ""))) + var result := authority_service.depart_inspection(inspection_id, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_create_defense_raid(command: SimulationCommand) -> CommandResult: + var raw_definition: Variant = command.payload.get(&"definition", command.payload.get("definition", null)) + if not raw_definition is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense Raid definition must be a Dictionary.") + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var id_before := id_generator.create_snapshot() + if raid_id.is_empty(): + raid_id = id_generator.next_id(&"defense_raid") + var definition: Dictionary = (raw_definition as Dictionary).duplicate(true) + if not definition.has(&"source_id") and not definition.has("source_id"): + definition[&"source_id"] = command.actor_id + var result := defense_service.create_raid(raid_id, _defense_operation_minute(), definition) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_register_defense_combatant(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var actor_id := StringName(command.payload.get(&"actor_id", command.payload.get("actor_id", command.payload.get(&"staff_id", command.payload.get("staff_id", ""))))) + var side := StringName(String(command.payload.get(&"side", command.payload.get("side", ""))).to_upper()) + var combat := int(command.payload.get(&"combat", command.payload.get("combat", 0))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if raid_id.is_empty() or actor_id.is_empty() or not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense combatant requires raid_id, actor_id and Dictionary options.") + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before combatant registration.") + var options: Dictionary = (raw_options as Dictionary).duplicate(true) + var max_health := int(command.payload.get(&"max_health", command.payload.get("max_health", 100))) + if side == &"DEFENDER": + var staff := staff_service.get_staff(actor_id) + if staff == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Defense defender must reference existing Staff.") + if not staff.can_take_shift() or not staff.current_task_id.is_empty(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense defender must be healthy enough to participate and free of Staff tasks.") + var lock_owner: StringName = _defense_lock_owner(actor_id, &"DEFENDER", raid_id) + if not lock_owner.is_empty(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Staff is already assigned to another non-terminal Raid.", {&"raid_id": lock_owner}) + if max_health != StaffRuntime.MAX_PSYCHOLOGY_VALUE \ + or ((options.has(&"health") or options.has("health")) and int(options.get(&"health", options.get("health", -1))) != staff.health): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defender health must use the current Staff health on a 100-point baseline.") + max_health = StaffRuntime.MAX_PSYCHOLOGY_VALUE + options[&"health"] = staff.health + options[&"fatigue"] = staff.fatigue + var staff_before: Dictionary = staff_service.create_snapshot() + staff.transition_to(StaffRuntime.State.UNAVAILABLE, &"DEFENSE_ASSIGNED", _defense_operation_minute()) + var defender_result := defense_service.register_combatant( + raid_id, actor_id, side, combat, max_health, options + ) + if not defender_result.is_success(): + staff_service.restore_snapshot(staff_before) + defender_result.command_id = command.command_id + defender_result.target_id = raid_id + return defender_result + elif side != &"ATTACKER": + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense combatant side must be DEFENDER or ATTACKER.") + var result := defense_service.register_combatant( + raid_id, actor_id, side, combat, max_health, options + ) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_register_defense_asset(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var asset_id := StringName(command.payload.get(&"asset_id", command.payload.get("asset_id", command.payload.get(&"facility_id", command.payload.get("facility_id", ""))))) + var kind := StringName(String(command.payload.get(&"kind", command.payload.get("kind", ""))).to_upper()) + var defense := int(command.payload.get(&"defense", command.payload.get("defense", 0))) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if raid_id.is_empty() or asset_id.is_empty() or not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense asset requires raid_id, facility asset_id and Dictionary options.") + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before asset registration.") + var facility := facility_service.get_instance(asset_id) + if facility == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Defense asset must reference an existing Facility.") + if not facility.queue_entries.is_empty() or not facility.active_services.is_empty(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "A busy Facility cannot become a Defense asset.") + var lock_owner: StringName = _defense_lock_owner(asset_id, &"ASSET", raid_id) + if not lock_owner.is_empty(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Facility is already assigned to another non-terminal Raid.", {&"raid_id": lock_owner}) + var supplied_maximum := int(command.payload.get(&"max_integrity", command.payload.get("max_integrity", FacilityInstance.DEFAULT_DURABILITY))) + var options: Dictionary = (raw_options as Dictionary).duplicate(true) + if supplied_maximum != FacilityInstance.DEFAULT_DURABILITY \ + or ((options.has(&"integrity") or options.has("integrity")) and int(options.get(&"integrity", options.get("integrity", -1))) != facility.durability): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense asset integrity must use the current Facility durability on a 10000-point baseline.") + options[&"integrity"] = facility.durability + var result := defense_service.register_asset( + raid_id, + asset_id, + kind, + FacilityInstance.DEFAULT_DURABILITY, + defense, + options + ) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_start_defense_raid(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before Raid start.") + var result := defense_service.start_raid(raid_id, _defense_operation_minute()) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_set_defense_retreat_policy(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var policy := StringName(String(command.payload.get(&"policy", command.payload.get("policy", ""))).to_upper()) + var threshold := int(command.payload.get(&"threshold", command.payload.get("threshold", -1))) + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before retreat-policy update.") + var result := defense_service.set_retreat_policy(raid_id, policy, threshold) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_queue_defense_skill(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var action_id := StringName(command.payload.get(&"action_id", command.payload.get("action_id", ""))) + var actor_id := StringName(command.payload.get(&"actor_id", command.payload.get("actor_id", command.actor_id))) + var kind := StringName(String(command.payload.get(&"kind", command.payload.get("kind", ""))).to_upper()) + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense skill options must be a Dictionary.") + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before skill queueing.") + var id_before := id_generator.create_snapshot() + if action_id.is_empty(): + action_id = id_generator.next_id(&"defense_skill_action") + var result := defense_service.queue_active_skill( + raid_id, action_id, actor_id, kind, (raw_options as Dictionary).duplicate(true) + ) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_resolve_defense_round(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", ""))) + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before round resolution.") + var id_before := id_generator.create_snapshot() + if resolution_id.is_empty(): + resolution_id = id_generator.next_id(&"defense_round_resolution") + var result := defense_service.resolve_next_round(raid_id, resolution_id, _defense_operation_minute()) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_begin_next_defense_wave(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before the next Wave.") + var result := defense_service.begin_next_wave(raid_id, _defense_operation_minute()) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_retreat_defenders(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", ""))) + var reason := StringName(command.payload.get(&"reason", command.payload.get("reason", &"PLAYER_RETREAT"))) + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before defender retreat.") + var id_before := id_generator.create_snapshot() + if resolution_id.is_empty(): + resolution_id = id_generator.next_id(&"defense_retreat_resolution") + var result := defense_service.retreat_defenders(raid_id, resolution_id, _defense_operation_minute(), reason) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_cancel_defense_raid(command: SimulationCommand) -> CommandResult: + var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) + var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", ""))) + var reason := StringName(command.payload.get(&"reason", command.payload.get("reason", &"CANCELLED"))) + if not _validate_live_defense_crosslinks(): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before Raid cancellation.") + var id_before := id_generator.create_snapshot() + if resolution_id.is_empty(): + resolution_id = id_generator.next_id(&"defense_cancel_resolution") + var result := defense_service.cancel_raid(raid_id, _defense_operation_minute(), reason, resolution_id) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + result.command_id = command.command_id + result.target_id = raid_id + return result + +func _execute_create_service_request(command: SimulationCommand) -> CommandResult: + var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", ""))) + var instance := facility_service.get_instance(facility_id) + if instance == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Facility was not found.") + var defense_owner: StringName = _defense_lock_owner(facility_id, &"ASSET") + if not defense_owner.is_empty(): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "A Facility assigned to a non-terminal Defense Raid cannot accept service requests.", + {&"raid_id": defense_owner} + ) + var guest_ids := _name_array(command.payload.get(&"guest_ids", command.payload.get("guest_ids", []))) + if guest_ids.is_empty(): + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "At least one guest id is required.") + for guest_id: StringName in guest_ids: + if not economy_service.has_account(_guest_wallet_account(guest_id)): + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Every guest needs a registered wallet.") + var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if not raw_options is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Service request options must be a Dictionary.") + var options: Dictionary = raw_options.duplicate(true) + var quoted_price := maxi( + int(options.get(&"quoted_price", options.get("quoted_price", instance.base_price))), + 0 + ) + var payer_account := _guest_wallet_account(guest_ids[0]) + var committed_price := _outstanding_service_commitment(guest_ids[0]) + if not economy_service.account_allows_negative(payer_account) \ + and economy_service.get_balance(payer_account) < committed_price + quoted_price: + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "The guest cannot fund this service together with existing commitments.", + { + &"available_gold": economy_service.get_balance(payer_account), + &"committed_gold": committed_price, + &"quoted_price": quoted_price, + } + ) + + var inventory_before := inventory_service.create_snapshot() + var facility_before := facility_service.create_snapshot() + var guest_before := guest_service.create_snapshot() + var events_before := domain_events.create_snapshot() + var id_before := id_generator.create_snapshot() + var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", ""))) + if request_id.is_empty(): + request_id = id_generator.next_id(&"service_request") + var definition := definition_registry.get_facility(instance.definition_id) + if definition == null: + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Facility definition was not found.") + var reservation_ids: Array[StringName] = [] + for item_id: StringName in _sorted_name_keys(definition.inventory_inputs): + var reserve_result := inventory_service.reserve_item( + instance.local_inventory_id, + item_id, + int(definition.inventory_inputs[item_id]), + request_id, + &"facility_service", + _runtime_event_minute(), + _runtime_event_minute() + 240, + StringName("service.reserve.%s.%s" % [request_id, item_id]) + ) + if not bool(reserve_result.get(&"ok", false)): + inventory_service.restore_snapshot(inventory_before) + id_generator.restore_snapshot(id_before) + return _command_result_from_dictionary(command.command_id, reserve_result, "Facility input could not be reserved.") + for reservation_id: StringName in reserve_result.get(&"reservation_ids", []): + reservation_ids.append(reservation_id) + options[&"reserved_inventory_ids"] = reservation_ids + var request := facility_service.create_request( + request_id, + facility_id, + guest_ids, + StringName(command.payload.get(&"service_type", command.payload.get("service_type", "default"))), + _runtime_event_minute(), + options + ) + var enqueue_result: CommandResult + var runtime_guests: Array[StringName] = [] + for guest_id: StringName in guest_ids: + if guest_service.has_guest(guest_id): + runtime_guests.append(guest_id) + if runtime_guests.is_empty(): + enqueue_result = facility_service.enqueue_request(request, _runtime_event_minute()) + else: + var first_guest := runtime_guests[0] + var queue_result := guest_service.enter_queue( + first_guest, + facility_id, + request_id, + _runtime_event_minute(), + int(options.get(&"initial_patience", options.get("initial_patience", -1))), + func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> CommandResult: + return facility_service.enqueue_request(request, _runtime_event_minute()) + ) + if bool(queue_result.get(&"ok", false)): + for guest_index in range(1, runtime_guests.size()): + var joined := guest_service.enter_queue( + runtime_guests[guest_index], facility_id, request_id, _runtime_event_minute(), + int(options.get(&"initial_patience", options.get("initial_patience", -1))) + ) + if not bool(joined.get(&"ok", false)): + queue_result = joined + break + enqueue_result = CommandResult.succeeded(0, { + &"expected_wait": request.expected_wait, + }) if bool(queue_result.get(&"ok", false)) else CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, 0, + String(queue_result.get(&"message", "Guest could not enter the facility queue.")), + queue_result + ) + if not enqueue_result.is_success(): + inventory_service.restore_snapshot(inventory_before) + facility_service.restore_snapshot(facility_before) + guest_service.restore_snapshot(guest_before) + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(id_before) + enqueue_result.command_id = command.command_id + return enqueue_result + return CommandResult.succeeded(command.command_id, { + &"request_id": request_id, + &"reservation_ids": reservation_ids, + &"expected_wait": enqueue_result.details.get(&"expected_wait", 0), + }) + +func _execute_set_facility_conditions(command: SimulationCommand) -> CommandResult: + var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", ""))) + var conditions: Variant = command.payload.get(&"conditions", command.payload.get("conditions", null)) + if not conditions is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "conditions must be a Dictionary.") + var defense_owner: StringName = _defense_lock_owner(facility_id, &"ASSET") + if not defense_owner.is_empty() and (conditions.has(&"durability") or conditions.has("durability")): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Defense-owned Facility durability can only change through its Raid.", + {&"raid_id": defense_owner} + ) + var result := facility_service.set_operational_status(facility_id, conditions, _runtime_event_minute()) + result.command_id = command.command_id + return result + +func _execute_set_facility_policy(command: SimulationCommand) -> CommandResult: + var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", ""))) + var patch: Variant = command.payload.get(&"patch", command.payload.get("patch", null)) + if not patch is Dictionary: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "patch must be a Dictionary.") + var effective_at := int(command.payload.get(&"effective_at", command.payload.get("effective_at", -1))) + var result := facility_service.set_policy(facility_id, patch, effective_at) + result.command_id = command.command_id + return result + +func _execute_leave_facility_queue(command: SimulationCommand) -> CommandResult: + var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", ""))) + var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", ""))) + var request := facility_service.get_request(request_id) + if request == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Queued service request was not found.") + var inventory_before := inventory_service.create_snapshot() + var facility_before := facility_service.create_snapshot() + var guest_before := guest_service.create_snapshot() + var events_before := domain_events.create_snapshot() + var id_before := id_generator.create_snapshot() + var external_leave := func(_guest_id: StringName = &"", _facility_id: StringName = &"", _request_id: StringName = &"") -> Dictionary: + for reservation_id: StringName in request.reserved_inventory_ids: + var released := inventory_service.release_reservation( + reservation_id, + StringName("service.release.%s.%s" % [request_id, reservation_id]), + _runtime_event_minute() + ) + if not bool(released.get(&"ok", false)): + return released + var leave_result := facility_service.leave_queue(facility_id, request_id) + return {&"ok": leave_result.is_success(), &"message": leave_result.message} + var runtime_guests: Array[StringName] = [] + for guest_id: StringName in request.guest_ids: + if guest_service.has_guest(guest_id): + runtime_guests.append(guest_id) + var successful := true + var failure_message := "Facility queue removal failed." + if runtime_guests.is_empty(): + var external_result: Dictionary = external_leave.call() + successful = bool(external_result.get(&"ok", false)) + failure_message = String(external_result.get(&"message", failure_message)) + else: + for guest_index in range(runtime_guests.size()): + var queued_guest := guest_service.get_guest(runtime_guests[guest_index]) + var abandon := guest_service.tick_queue_patience( + queued_guest.guest_id, + _runtime_event_minute(), + -queued_guest.patience_remaining, + external_leave if guest_index == 0 else Callable() + ) + if not bool(abandon.get(&"ok", false)): + successful = false + failure_message = String(abandon.get(&"message", failure_message)) + break + if not successful: + inventory_service.restore_snapshot(inventory_before) + facility_service.restore_snapshot(facility_before) + guest_service.restore_snapshot(guest_before) + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(id_before) + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, failure_message) + return CommandResult.succeeded(command.command_id, {&"request_id": request_id, &"reason": &"GUEST_LEFT"}) + +func _execute_build_facility(command: SimulationCommand) -> CommandResult: + var operations_gate := _operations_construction_investment_gate(command.command_id) + if operations_gate != null: + return operations_gate + var definition_id := StringName(command.payload.get(&"definition_id", command.payload.get("definition_id", ""))) + var definition := definition_registry.get_facility(definition_id) + if definition == null: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Facility definition was not found.") + if _facility_definition_is_unsupported_vertical_proxy(definition): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Vertical infrastructure topology is not supported.", + { + &"reason": FacilityInfrastructureProjector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, + &"definition_id": definition_id, + &"unchanged": true, + } + ) + var origin_value: Variant = _parse_world_cell(command.payload.get(&"origin", command.payload.get("origin", null))) + if origin_value == null: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "origin must be Vector3i or [x, y, layer].") + var origin: Vector3i = origin_value + var rotation := int(command.payload.get(&"rotation", command.payload.get("rotation", 0))) + if rotation not in definition.allowed_rotations: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "The selected rotation is not allowed.") + var result := construction_service.start_construction( + definition, + origin, + rotation, + definition.build_minutes, + _runtime_event_minute() + ) + result.command_id = command.command_id + return result + +func _execute_cancel_construction(command: SimulationCommand) -> CommandResult: + var job_id := StringName(command.payload.get(&"job_id", command.payload.get("job_id", ""))) + if job_id.is_empty(): + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "job_id is required.") + var result := construction_service.cancel_job(job_id, _runtime_event_minute()) + result.command_id = command.command_id + return result + + +func _execute_start_excavation(command: SimulationCommand) -> CommandResult: + var operations_gate := _operations_construction_investment_gate(command.command_id) + if operations_gate != null: + return operations_gate + if progression_service.current_rank < 2: + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "Excavation requires World Tree Rank 2.", + {&"required_rank": 2, &"current_rank": progression_service.current_rank} + ) + var parsed_cell: Variant = _parse_world_cell( + command.payload.get(&"cell", command.payload.get("cell", null)) + ) + if parsed_cell == null: + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "cell must be Vector3i or [x, y, layer]." + ) + var result := excavation_service.start_excavation(parsed_cell, _runtime_event_minute()) + result.command_id = command.command_id + return result + + +func _execute_pause_excavation(command: SimulationCommand) -> CommandResult: + var job_id := StringName(command.payload.get(&"job_id", command.payload.get("job_id", ""))) + if job_id.is_empty(): + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "job_id is required." + ) + var result := excavation_service.pause_job(job_id) + result.command_id = command.command_id + return result + + +func _execute_resume_excavation(command: SimulationCommand) -> CommandResult: + var job_id := StringName(command.payload.get(&"job_id", command.payload.get("job_id", ""))) + if job_id.is_empty(): + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "job_id is required." + ) + var result := excavation_service.resume_job(job_id) + result.command_id = command.command_id + return result + +func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: + var instance_id := StringName(command.payload.get(&"instance_id", command.payload.get("instance_id", ""))) + if instance_id.is_empty(): + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "instance_id is required.") + var defense_owner: StringName = _defense_lock_owner(instance_id, &"ASSET") + if not defense_owner.is_empty(): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "A Facility assigned to a non-terminal Defense Raid cannot be demolished.", + {&"raid_id": defense_owner} + ) + var operational_instance := facility_service.get_instance(instance_id) + if operational_instance != null \ + and (not operational_instance.queue_entries.is_empty() or not operational_instance.active_services.is_empty()): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "A busy facility cannot be demolished.") + if facility_infrastructure_projector.has_projection(instance_id): + var boundary_before := _capture_infrastructure_demolition_state() + if boundary_before.is_empty(): + return CommandResult.failed( + CommandResult.Code.TRANSACTION_CLOSED, + command.command_id, + "Infrastructure demolition state could not be captured." + ) + var unprojected := facility_infrastructure_projector.unproject_facility( + StringName("runtime.demolish:%s" % instance_id), + instance_id + ) + if not unprojected.is_success(): + if not _infrastructure_demolition_state_matches(boundary_before): + _restore_infrastructure_demolition_state(boundary_before) + var rejection_details := unprojected.details.duplicate(true) + rejection_details[&"reason"] = &"INFRASTRUCTURE_UNPROJECT_FAILED" + rejection_details[&"instance_id"] = instance_id + return CommandResult.failed( + unprojected.code, + command.command_id, + unprojected.message, + rejection_details + ) + var demolished := construction_service.demolish_facility(instance_id) + demolished.command_id = command.command_id + if not demolished.is_success() \ + or facility_infrastructure_projector.has_projection(instance_id) \ + or not construction_service.get_facility(instance_id).is_empty() \ + or not world_service.get_entity_cells(instance_id).is_empty() \ + or not _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ): + var rolled_back := _restore_infrastructure_demolition_state(boundary_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Infrastructure demolition failed and was rolled back.", + { + &"instance_id": instance_id, + &"rolled_back": rolled_back, + &"cause": demolished.to_dictionary(), + } + ) + return demolished + var result := construction_service.demolish_facility(instance_id) + result.command_id = command.command_id + return result + + +func _capture_infrastructure_demolition_state() -> Dictionary: + return { + &"game_state": _state.create_snapshot(), + &"economy": economy_service.create_snapshot(), + &"world": world_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"construction": construction_service.create_snapshot(), + &"excavation": excavation_service.create_snapshot(), + &"spatial_subslots": spatial_subslot_service.create_snapshot(), + &"route_projection": route_subslot_path_adapter.create_snapshot(), + &"facility_infrastructure": facility_infrastructure_projector.create_snapshot(), + &"facilities": facility_service.create_snapshot(), + &"mana": mana_network_service.create_snapshot(), + &"movement_credits": _movement_credits.duplicate(true), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + } + + +func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: + for key: StringName in [ + &"game_state", &"economy", &"world", &"path", &"construction", + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", &"facilities", &"mana", &"movement_credits", + &"domain_events", &"id_generator", + ]: + if not snapshot.get(key, null) is Dictionary: + return false + var restored: bool = bool(_state.restore_snapshot(snapshot[&"game_state"])) + restored = economy_service.restore_snapshot(snapshot[&"economy"]) and restored + restored = world_service.restore_snapshot(snapshot[&"world"]) and restored + restored = spatial_subslot_service.restore_snapshot(snapshot[&"spatial_subslots"]) and restored + var route_snapshot: Dictionary = snapshot[&"route_projection"] + var projected_walkable: Variant = route_snapshot.get(&"projected_walkable_cells", null) + if not projected_walkable is Array: + return false + restored = path_service.rebuild_graph(projected_walkable) and restored + restored = path_service.restore_snapshot(snapshot[&"path"]) and restored + restored = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored + restored = facility_infrastructure_projector.restore_snapshot( + snapshot[&"facility_infrastructure"] + ) and restored + restored = construction_service.restore_snapshot(snapshot[&"construction"]) and restored + restored = excavation_service.restore_snapshot(snapshot[&"excavation"]) and restored + restored = facility_service.restore_snapshot(snapshot[&"facilities"]) and restored + restored = mana_network_service.restore_snapshot(snapshot[&"mana"]) and restored + _movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true) + restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored + restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored + return restored and _infrastructure_demolition_state_matches(snapshot) \ + and _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ) + + +func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool: + return _strict_variants_equal(_state.create_snapshot(), snapshot.get(&"game_state", {})) \ + and _strict_variants_equal(economy_service.create_snapshot(), snapshot.get(&"economy", {})) \ + and _strict_variants_equal(world_service.create_snapshot(), snapshot.get(&"world", {})) \ + and _strict_variants_equal(path_service.create_snapshot(), snapshot.get(&"path", {})) \ + and _strict_variants_equal(construction_service.create_snapshot(), snapshot.get(&"construction", {})) \ + and _strict_variants_equal(excavation_service.create_snapshot(), snapshot.get(&"excavation", {})) \ + and _strict_variants_equal(spatial_subslot_service.create_snapshot(), snapshot.get(&"spatial_subslots", {})) \ + and _strict_variants_equal(route_subslot_path_adapter.create_snapshot(), snapshot.get(&"route_projection", {})) \ + and _strict_variants_equal(facility_infrastructure_projector.create_snapshot(), snapshot.get(&"facility_infrastructure", {})) \ + and _strict_variants_equal(facility_service.create_snapshot(), snapshot.get(&"facilities", {})) \ + and _strict_variants_equal(mana_network_service.create_snapshot(), snapshot.get(&"mana", {})) \ + and _strict_variants_equal(_movement_credits, snapshot.get(&"movement_credits", {})) \ + and _strict_variants_equal(domain_events.create_snapshot(), snapshot.get(&"domain_events", {})) \ + and _strict_variants_equal(id_generator.create_snapshot(), snapshot.get(&"id_generator", {})) + +func _execute_set_world_tree_rank(command: SimulationCommand) -> CommandResult: + var operations_gate := _operations_construction_investment_gate(command.command_id) + if operations_gate != null: + return operations_gate + var rank := int(command.payload.get(&"rank", command.payload.get("rank", 0))) + var request_id := StringName(command.payload.get( + &"request_id", + command.payload.get("request_id", "rank_up_%d" % rank) + )) + var progression_before := progression_service.create_snapshot() + if not _sync_progression_live_metrics(): + progression_service.restore_snapshot(progression_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "Live progression facts could not be synchronized." + ) + var external_before := _capture_rank_external_state() + var result := progression_service.request_rank_up( + request_id, + rank, + func(_current_rank: int, _target_rank: int) -> Dictionary: + return {&"ok": true, &"snapshot": external_before.duplicate(true)}, + func(target_rank: int, expected_bounds: Dictionary) -> Dictionary: + return {&"ok": _apply_rank_external_state(target_rank, expected_bounds)}, + func(snapshot: Dictionary) -> Dictionary: + return {&"ok": _rollback_rank_external_state(snapshot)}, + ) + result.command_id = command.command_id + if not result.is_success(): + progression_service.restore_snapshot(progression_before) + return result + if result.is_replay: + # Progression replays are read-only. The live-fact synchronization above is + # deliberately rolled back so a resent command reproduces the first result + # without silently changing any metric. + progression_service.restore_snapshot(progression_before) + return result + var event := SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"WORLD_TREE_RANK_ADVANCED", + _runtime_event_minute(), + &"progression_service", + [StringName("world_tree_rank_%d" % rank)], + result.details.duplicate(true), + command.command_id + ) + if not _publish_domain_event(event): + _rollback_rank_external_state(external_before) + progression_service.restore_snapshot(progression_before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + command.command_id, + "World Tree promotion event could not be published; the promotion was rolled back." + ) + return result + + +func _execute_update_progression_metrics(command: SimulationCommand) -> CommandResult: + var patch: Variant = command.payload.get(&"metrics", command.payload.get("metrics", null)) + if not patch is Dictionary: + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "Progression metrics must be a Dictionary." + ) + if patch.has(&"guest_lifetime") or patch.has("guest_lifetime"): + var requested_value: Variant = patch.get( + &"guest_lifetime", patch.get("guest_lifetime", null) + ) + var guest_snapshot := guest_service.create_snapshot() + var records_value: Variant = guest_snapshot.get(&"persistent_records", null) + var derived: int = guest_service.guest_count() \ + + (records_value.size() if records_value is Array else -1) + if typeof(requested_value) != TYPE_INT or derived < 0 \ + or int(requested_value) != derived: + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + "guest_lifetime is an authoritative Guest aggregate fact." + ) + if not progression_service.update_metrics(patch): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + progression_service.last_error + ) + return CommandResult.succeeded(command.command_id, { + &"metrics": progression_service.metrics.duplicate(true), + }) + + +func _execute_complete_progression_quest(command: SimulationCommand) -> CommandResult: + var raw_quest_id: Variant = command.payload.get(&"quest_id", command.payload.get("quest_id", null)) + if typeof(raw_quest_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, + command.command_id, + "A progression quest id is required." + ) + var quest_id := StringName(raw_quest_id) + if not progression_service.complete_quest(quest_id): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + progression_service.last_error + ) + return CommandResult.succeeded(command.command_id, {&"quest_id": quest_id}) + + +func _execute_acknowledge_operations_pause(command: SimulationCommand) -> CommandResult: + var acknowledged := acknowledge_operations_pause() + if not bool(acknowledged.get(&"ok", false)): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command.command_id, + String(acknowledged.get(&"message", "Operations acknowledgement failed.")), + acknowledged.get(&"details", {}) + ) + return CommandResult.succeeded(command.command_id, acknowledged) + + +func _operations_construction_investment_gate(command_id: int) -> CommandResult: + if not _operations_enabled or operations_coordinator == null: + return null + var gate := operations_coordinator.can_start_construction_or_investment() + if bool(gate.get(&"allowed", false)): + return null + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command_id, + "Operations currently block construction and investment.", + gate + ) + +func _advance_construction(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + var preflight := _preflight_proxy_completions(absolute_minute) + if not bool(preflight.get(&"ok", false)): + _latch_infrastructure_failure( + StringName(preflight.get(&"code", &"INFRASTRUCTURE_PREFLIGHT_FAILED")), + absolute_minute, + String(preflight.get(&"reason", "Infrastructure completion preflight failed.")), + preflight.get(&"details", {}) + ) + return + # The expensive composed rollback token is only needed when Construction can + # cross the completion boundary this minute. Idle ticks and ordinary progress + # retain ConstructionService's direct job_changed notification semantics. + if not _construction_completion_is_due_this_tick(): + var fast_results: Array[CommandResult] = construction_service.advance_one_minute( + absolute_minute + ) + for fast_result: CommandResult in fast_results: + if fast_result == null or not fast_result.is_success(): + _latch_infrastructure_failure( + &"CONSTRUCTION_ADVANCE_FAILED", + absolute_minute, + ( + fast_result.message + if fast_result != null else "Construction returned an invalid result." + ), + ( + fast_result.to_dictionary() + if fast_result != null else {} + ) + ) + return + excavation_service.advance_one_minute(absolute_minute) + if _infrastructure_failure_latched: + return + if not _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ): + _latch_infrastructure_failure( + &"INFRASTRUCTURE_POSTCONDITION_FAILED", + absolute_minute, + "Construction and Excavation ended the minute with invalid infrastructure cross-links." + ) + return + var boundary_before := _capture_infrastructure_demolition_state() + if boundary_before.is_empty(): + _latch_infrastructure_failure( + &"INFRASTRUCTURE_CAPTURE_FAILED", + absolute_minute, + "The Construction completion boundary could not be captured." + ) + return + var previous_facility_ids: Dictionary = {} + for instance: Dictionary in construction_service.get_facilities(): + previous_facility_ids[StringName(instance.get(&"instance_id", &""))] = true + + _construction_completion_transaction_active = true + _construction_queued_domain_events.clear() + var progress_results: Array[CommandResult] = construction_service.advance_one_minute( + absolute_minute, {}, true + ) + var apply_failure: Dictionary = {} + for progress_result: CommandResult in progress_results: + if progress_result == null or not progress_result.is_success(): + apply_failure = { + &"code": &"CONSTRUCTION_ADVANCE_FAILED", + &"reason": ( + progress_result.message + if progress_result != null else "Construction returned an invalid result." + ), + &"details": ( + progress_result.to_dictionary() + if progress_result != null else {} + ), + } + break + if apply_failure.is_empty(): + for instance: Dictionary in construction_service.get_facilities(): + var instance_id := StringName(instance.get(&"instance_id", &"")) + if previous_facility_ids.has(instance_id): + continue + var applied := _apply_completed_facility_internal(instance, absolute_minute) + if not bool(applied.get(&"ok", false)): + apply_failure = applied + break + if apply_failure.is_empty() and not _construction_completion_crosslinks_are_valid( + absolute_minute + ): + apply_failure = { + &"code": &"CONSTRUCTION_COMPLETION_CROSSLINK_FAILED", + &"reason": "Construction completion produced invalid Runtime cross-links.", + &"details": {}, + } + if not apply_failure.is_empty(): + _fail_construction_completion_boundary( + boundary_before, + StringName(apply_failure.get(&"code", &"CONSTRUCTION_COMPLETION_FAILED")), + absolute_minute, + String(apply_failure.get(&"reason", "Construction completion failed.")), + apply_failure.get(&"details", {}) + ) + return + + # DomainEventStream validates and commits the whole batch before notifying + # subscribers. Construction's own raw signals are released only after the + # authoritative Runtime state and event batch have both committed. + var queued_events: Array[SimulationDomainEvent] = [] + for event: SimulationDomainEvent in _construction_queued_domain_events: + queued_events.append(event.copy()) + _construction_completion_transaction_active = false + _construction_queued_domain_events.clear() + if not domain_events.publish_batch(queued_events): + _fail_construction_completion_boundary( + boundary_before, + &"CONSTRUCTION_EVENT_COMMIT_FAILED", + absolute_minute, + "Construction completion DomainEvents could not be committed." + ) + return + _construction_committing_notifications = true + var notifications_committed := construction_service.commit_deferred_notifications() + _construction_committing_notifications = false + if not notifications_committed: + # Service-created deferred rows are prevalidated by ConstructionService, + # so this branch represents an unrecoverable notification boundary fault. + _latch_infrastructure_failure( + &"CONSTRUCTION_NOTIFICATION_COMMIT_FAILED", + absolute_minute, + "Committed Construction state could not release its deferred notifications.", + {&"rolled_back": false} + ) + return + + # Construction is authoritative first. Excavation then advances to the same + # absolute minute, preventing an excavation completion signal from escaping + # when the Construction boundary must be rolled back. + excavation_service.advance_one_minute(absolute_minute) + if _infrastructure_failure_latched: + return + if not _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector + ): + _latch_infrastructure_failure( + &"INFRASTRUCTURE_POSTCONDITION_FAILED", + absolute_minute, + "Construction and Excavation ended the minute with invalid infrastructure cross-links." + ) + + +func _construction_completion_is_due_this_tick() -> bool: + for job: ConstructionJob in construction_service.get_jobs(): + if job == null or job.is_terminal(): + continue + if job.work_units_done + ConstructionJob.NORMAL_WORK_RATE \ + >= job.build_minutes * ConstructionJob.MAX_PROGRESS: + return true + return false + + +func _apply_completed_facility_internal(instance: Dictionary, absolute_minute: int) -> Dictionary: + var instance_id := StringName(instance.get(&"instance_id", &"")) + var definition_id := StringName(instance.get(&"definition_id", &"")) + var definition := definition_registry.get_facility(definition_id) + if instance_id.is_empty() or definition == null: + return { + &"ok": false, + &"code": &"INVALID_COMPLETED_FACILITY", + &"reason": "Construction produced an invalid completed Facility.", + &"details": {&"instance_id": instance_id, &"definition_id": definition_id}, + } + if _facility_definition_is_schema_proxy(definition): + if not _facility_definition_is_supported_infrastructure_proxy(definition): + return { + &"ok": false, + &"code": &"UNSUPPORTED_VERTICAL_TOPOLOGY", + &"reason": "An unsupported vertical proxy reached Construction completion.", + &"details": {&"instance_id": instance_id, &"definition_id": definition.id}, + } + if not facility_infrastructure_projector.has_projection(instance_id): + var projection_request_id := StringName( + "runtime.complete:%s" % String(instance.get(&"source_job_id", instance_id)) + ) + var projected := facility_infrastructure_projector.project_completed_facility( + projection_request_id, definition, instance + ) + if not projected.is_success(): + return { + &"ok": false, + &"code": &"FACILITY_INFRASTRUCTURE_PROJECTION_FAILED", + &"reason": projected.message, + &"details": projected.details.duplicate(true), + } + if not _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"FACILITY_INFRASTRUCTURE_PROJECTED", + absolute_minute, + &"facility_infrastructure_projector", + [instance_id], + projected.details.duplicate(true), + 0 + )): + return { + &"ok": false, + &"code": &"FACILITY_INFRASTRUCTURE_EVENT_FAILED", + &"reason": "The infrastructure projection event could not be staged.", + &"details": {&"instance_id": instance_id}, + } + else: + if not facility_service.has_instance(instance_id): + _register_facility_mana_consumer(instance) + _register_operational_facility(instance) + if not facility_service.has_instance(instance_id) \ + or not economy_service.has_account(_facility_revenue_account(instance_id)): + return { + &"ok": false, + &"code": &"OPERATIONAL_FACILITY_REGISTRATION_FAILED", + &"reason": "The completed Facility could not be registered operationally.", + &"details": {&"instance_id": instance_id}, + } + if definition.maintenance_mana > 0 \ + and mana_network_service.get_node(instance_id).is_empty(): + return { + &"ok": false, + &"code": &"FACILITY_MANA_REGISTRATION_FAILED", + &"reason": "The completed Facility Mana consumer could not be registered.", + &"details": {&"instance_id": instance_id}, + } + if not _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"FACILITY_COMPLETED", + absolute_minute, + &"construction_service", + [instance_id], + instance.duplicate(true), + 0 + )): + return { + &"ok": false, + &"code": &"FACILITY_COMPLETION_EVENT_FAILED", + &"reason": "The Facility completion event could not be staged.", + &"details": {&"instance_id": instance_id}, + } + return {&"ok": true} + + +func _construction_completion_crosslinks_are_valid(absolute_minute: int) -> bool: + return construction_service.current_minute == absolute_minute \ + and bool(mana_network_service.validate_invariants().get(&"valid", false)) \ + and _validate_construction_facility_crosslinks( + world_service, + construction_service, + facility_service, + facility_infrastructure_projector + ) \ + and _validate_infrastructure_crosslinks( + world_service, + path_service, + construction_service, + construction_resource_state, + excavation_service, + spatial_subslot_service, + route_subslot_path_adapter, + facility_infrastructure_projector, + false + ) + + +func _fail_construction_completion_boundary( + boundary_before: Dictionary, + code: StringName, + absolute_minute: int, + reason: String, + details: Dictionary = {} +) -> void: + _construction_completion_transaction_active = false + _construction_committing_notifications = false + _construction_queued_domain_events.clear() + var notifications_discarded := construction_service.discard_deferred_notifications() + var rolled_back := notifications_discarded \ + and _restore_infrastructure_demolition_state(boundary_before) + var failure_details := details.duplicate(true) + failure_details[&"notifications_discarded"] = notifications_discarded + failure_details[&"rolled_back"] = rolled_back + _latch_infrastructure_failure( + code if rolled_back else &"INFRASTRUCTURE_COMPLETION_ROLLBACK_FAILED", + absolute_minute, + reason, + failure_details + ) + + +func _preflight_proxy_completions(absolute_minute: int) -> Dictionary: + if construction_service == null or facility_infrastructure_projector == null \ + or route_subslot_path_adapter == null or spatial_subslot_service == null: + return { + &"ok": false, + &"code": &"INFRASTRUCTURE_UNAVAILABLE", + &"reason": "Infrastructure services are unavailable.", + } + var due_proxy_ids: Array[StringName] = [] + for job: ConstructionJob in construction_service.get_jobs(): + if job == null or job.is_terminal(): + continue + var definition := definition_registry.get_facility(job.definition_id) + if definition == null: + return { + &"ok": false, + &"code": &"MISSING_FACILITY_DEFINITION", + &"reason": "A Construction job references a missing Facility definition.", + &"details": {&"job_id": job.job_id, &"definition_id": job.definition_id}, + } + if not _facility_definition_is_supported_infrastructure_proxy(definition): + continue + if job.work_units_done + ConstructionJob.NORMAL_WORK_RATE \ + >= job.build_minutes * ConstructionJob.MAX_PROGRESS: + due_proxy_ids.append(job.job_id) + if due_proxy_ids.is_empty(): + return {&"ok": true} + if route_subslot_path_adapter.is_quarantined() \ + or route_subslot_path_adapter.is_projection_stale() \ + or facility_infrastructure_projector.is_quarantined() \ + or facility_infrastructure_projector.is_projection_stale() \ + or not bool(spatial_subslot_service.validate_invariants().get(&"ok", false)) \ + or not bool(route_subslot_path_adapter.validate_invariants().get(&"ok", false)) \ + or not bool(facility_infrastructure_projector.validate_invariants().get(&"ok", false)): + return { + &"ok": false, + &"code": &"INFRASTRUCTURE_DEPENDENCY_STALE", + &"reason": "Infrastructure dependencies are stale or quarantined.", + &"details": {&"job_ids": due_proxy_ids.duplicate()}, + } + var projector_snapshot := facility_infrastructure_projector.create_snapshot() + if int((projector_snapshot.get(&"projections", []) as Array).size()) \ + > FacilityInfrastructureProjector.MAX_PROJECTIONS - due_proxy_ids.size() \ + or int((projector_snapshot.get(&"processed_requests", []) as Array).size()) \ + > FacilityInfrastructureProjector.MAX_PROCESSED_REQUESTS - due_proxy_ids.size(): + return { + &"ok": false, + &"code": &"INFRASTRUCTURE_CAPACITY_EXHAUSTED", + &"reason": "Infrastructure projection capacity is exhausted.", + &"details": {&"job_ids": due_proxy_ids.duplicate()}, + } + return {&"ok": true} + + +func _latch_infrastructure_failure( + code: StringName, + absolute_minute: int, + reason: String, + details: Dictionary = {} +) -> void: + _infrastructure_failure_latched = true + _infrastructure_failure_report = { + &"code": code, + &"minute": absolute_minute, + &"reason": reason, + &"details": details.duplicate(true), + } + _operations_tick_blocked = true + if _clock != null: + _clock.set_paused(true) + + +func get_infrastructure_report() -> Dictionary: + return { + &"failure_latched": _infrastructure_failure_latched, + &"failure_report": _infrastructure_failure_report.duplicate(true), + &"excavation": ( + excavation_service.validate_invariants().duplicate(true) + if excavation_service != null else {&"ok": false} + ), + &"spatial_subslots": ( + spatial_subslot_service.validate_invariants().duplicate(true) + if spatial_subslot_service != null else {&"ok": false} + ), + &"route_projection": ( + route_subslot_path_adapter.get_projection_status().duplicate(true) + if route_subslot_path_adapter != null else {&"configured": false} + ), + &"facility_infrastructure": ( + facility_infrastructure_projector.get_status().duplicate(true) + if facility_infrastructure_projector != null else {&"configured": false} + ), + }.duplicate(true) + +func _advance_inventory_and_logistics(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + inventory_service.current_minute = absolute_minute + inventory_service.process_expired(absolute_minute, &"inventory.waste") + +func _advance_facilities(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + facility_service.advance_one_minute(absolute_minute) + +func _advance_guest_vitals(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + var minute_of_day := posmod(absolute_minute, 1_440) + guest_service.advance_one_minute(absolute_minute, { + &"default": { + &"unstimulated": true, + &"solo_guest": true, + &"is_night": minute_of_day < 360 or minute_of_day >= 1_080, + &"intimacy_day": 3, + &"intimacy_night": 7, + } + }) + +func _advance_guest_queues(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + var guests_by_request: Dictionary = {} + for guest: GuestRuntime in guest_service.get_guests(): + if guest.state != GuestRuntime.State.QUEUING: + continue + if not guests_by_request.has(guest.queue_request_id): + guests_by_request[guest.queue_request_id] = [] + (guests_by_request[guest.queue_request_id] as Array).append(guest) + for request_id: StringName in _sorted_name_keys(guests_by_request): + var grouped_guests: Array = guests_by_request[request_id] + var abandon_group := false + for guest: GuestRuntime in grouped_guests: + if guest.patience_remaining <= 1: + abandon_group = true + break + if not abandon_group: + for guest: GuestRuntime in grouped_guests: + guest_service.tick_queue_patience(guest.guest_id, absolute_minute) + continue + var inventory_before := inventory_service.create_snapshot() + var facility_before := facility_service.create_snapshot() + var guest_before := guest_service.create_snapshot() + var events_before := domain_events.create_snapshot() + var id_before := id_generator.create_snapshot() + var group_succeeded := true + for guest_index in range(grouped_guests.size()): + var guest: GuestRuntime = grouped_guests[guest_index] + var abandoned := guest_service.tick_queue_patience( + guest.guest_id, + absolute_minute, + -guest.patience_remaining, + Callable(self, "_leave_guest_queue_boundary") if guest_index == 0 else Callable() + ) + if not bool(abandoned.get(&"ok", false)): + group_succeeded = false + break + if not group_succeeded: + inventory_service.restore_snapshot(inventory_before) + facility_service.restore_snapshot(facility_before) + guest_service.restore_snapshot(guest_before) + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(id_before) + +func _advance_authority_incidents(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + if _operations_morning_already_processed(absolute_minute): + return + var advanced := authority_service.advance_time(absolute_minute) + if not advanced.is_success(): + _clock.set_paused(true) + _publish_actor_event(&"AUTHORITY_ADVANCE_FAILED", &"authority_service", [], { + &"message": advanced.message, + &"details": advanced.details.duplicate(true), + }) + return + if posmod(absolute_minute, 1_440) != AuthorityService.INSPECTION_EVALUATION_MINUTE: + return + var evaluated := authority_service.evaluate_inspection(absolute_minute) + if not evaluated.is_success(): + _clock.set_paused(true) + _publish_actor_event(&"AUTHORITY_INSPECTION_EVALUATION_FAILED", &"authority_service", [], { + &"message": evaluated.message, + &"details": evaluated.details.duplicate(true), + }) + return + if not evaluated.is_replay: + _publish_authority_evaluation(evaluated.details) + +func _advance_defense(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + var advanced := defense_service.advance_time(absolute_minute) + if advanced.is_success(): + return + _clock.set_paused(true) + _publish_actor_event(&"DEFENSE_ADVANCE_FAILED", &"defense_service", [], { + &"message": advanced.message, + &"details": advanced.details.duplicate(true), + }) + +func _advance_relationship_events(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + if _operations_morning_already_processed(absolute_minute): + return + event_service.process_expired(absolute_minute) + event_service.audit_actor_availability(absolute_minute) + _enqueue_pending_relationship_candidates() + if event_service.get_foreground_instance() == null: + var started := event_service.start_next_event( + absolute_minute, + _build_event_context(absolute_minute) + ) + if started.is_success() and bool(started.details.get(&"waiting_for_choice", false)): + _clock.set_paused(true) + if event_service.pause_requested: + _clock.set_paused(true) + + +func _operations_morning_already_processed(absolute_minute: int) -> bool: + if not _operations_enabled or operations_coordinator == null \ + or posmod(absolute_minute, SimulationKernel.MINUTES_PER_DAY) \ + != OPERATIONS_START_MINUTE: + return false + var day := absolute_minute / SimulationKernel.MINUTES_PER_DAY + 1 + var report := operations_coordinator.get_morning_report(day) + return not report.is_empty() and int(report.get(&"minute", -1)) == absolute_minute + +func _enqueue_pending_relationship_candidates() -> void: + for candidate: Dictionary in relationship_service.get_threshold_candidates(): + _on_relationship_threshold_candidate(candidate) + +func _build_event_context(absolute_minute: int) -> Dictionary: + return { + &"absolute_minute": absolute_minute, + &"day": absolute_minute / 1_440 + 1, + &"minute_of_day": posmod(absolute_minute, 1_440), + &"player": { + &"gold": economy_service.get_balance(PLAYER_CASH_ACCOUNT), + &"world_tree_rank": int(_state.world_tree_rank), + }, + } + +func _advance_staff_ai(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + staff_service.advance_one_minute(absolute_minute) + for task: StaffTask in staff_service.get_tasks(): + if task.state == StaffTask.State.RESERVED: + if task.state_changed_at < absolute_minute: + task.begin_moving(task.state_changed_at) + elif task.state == StaffTask.State.WORKING: + staff_service.progress_task(task.task_id, 1, absolute_minute) + for staff: StaffRuntime in staff_service.get_staff_members(): + if absolute_minute % 30 != 0: + continue + if staff.state == StaffRuntime.State.WORKING: + staff.add_fatigue(1) + elif staff.state == StaffRuntime.State.OFF_DUTY: + staff.add_fatigue(-1) + _sync_facility_staffing(absolute_minute) + _advance_guest_ai_decisions(absolute_minute) + +func _advance_guest_ai_decisions(absolute_minute: int) -> void: + for guest: GuestRuntime in guest_service.get_guests(): + match guest.state: + GuestRuntime.State.SPAWNING: + guest_service.transition_guest(guest.guest_id, GuestRuntime.State.ENTERING, absolute_minute, &"ENTRY_STARTED") + GuestRuntime.State.ENTERING: + guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED") + GuestRuntime.State.DECIDING: + if guest.planned_exit_at <= absolute_minute: + guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT") + continue + guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id)) + guest_service.decide( + guest.guest_id, + _project_guest_candidates(guest.guest_id), + absolute_minute, + {&"absolute_minute": absolute_minute} + ) + GuestRuntime.State.MOVING: + pass + GuestRuntime.State.REPLANNING, GuestRuntime.State.ABANDONING, GuestRuntime.State.INTERRUPTED: + guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"REPLAN_AFTER_INTERRUPTION") + GuestRuntime.State.LEAVING: + if guest.state_entered_at < absolute_minute: + guest_service.despawn_guest(guest.guest_id, absolute_minute) + _advance_runtime_path_traffic(absolute_minute) + + +func _advance_runtime_path_traffic(absolute_minute: int) -> void: + var actors: Array[Dictionary] = [] + for guest: GuestRuntime in guest_service.get_guests(): + actors.append({ + &"actor_id": _guest_path_actor_id(guest.guest_id), + &"kind": &"guest", + &"entity_id": guest.guest_id, + }) + for staff: StaffRuntime in staff_service.get_staff_members(): + actors.append({ + &"actor_id": _staff_path_actor_id(staff.staff_id), + &"kind": &"staff", + &"entity_id": staff.staff_id, + }) + actors.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + return String(left[&"actor_id"]) < String(right[&"actor_id"]) + ) + for entry: Dictionary in actors: + var actor_id: StringName = entry[&"actor_id"] + var kind: StringName = entry[&"kind"] + var entity_id: StringName = entry[&"entity_id"] + if kind == &"guest": + var guest := guest_service.get_guest(entity_id) + if guest == null: + continue + if not _path_projection_matches(actor_id, guest.current_cell): + _report_path_integration_failure(actor_id, entity_id, kind, guest.current_cell) + continue + if guest.state == GuestRuntime.State.MOVING \ + and guest.state_entered_at < absolute_minute: + _advance_guest_path_travel(guest, actor_id, absolute_minute) + else: + _record_expected_actor_wait(actor_id, guest.current_cell, absolute_minute) + continue + var staff := staff_service.get_staff(entity_id) + if staff == null: + continue + if not _path_projection_matches(actor_id, staff.current_cell): + _report_path_integration_failure(actor_id, entity_id, kind, staff.current_cell) + continue + var task := staff_service.get_task(staff.current_task_id) + if task != null and task.state == StaffTask.State.MOVING \ + and task.assigned_staff_id == staff.staff_id: + _advance_staff_task_travel(task, actor_id, absolute_minute) + else: + _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) + + +func _path_projection_matches(actor_id: StringName, cell: Vector3i) -> bool: + return path_service.has_actor_projection(actor_id) \ + and path_service.get_actor_projected_cell(actor_id) == cell \ + and _movement_credits.has(actor_id) + + +func _report_path_integration_failure( + actor_id: StringName, + entity_id: StringName, + kind: StringName, + cell: Vector3i +) -> void: + _clock.set_paused(true) + _publish_actor_event(&"PATH_ACTOR_CROSSLINK_FAILED", &"simulation_runtime", [actor_id, entity_id], { + &"actor_kind": kind, + &"actor_cell": cell, + &"projected_cell": path_service.get_actor_projected_cell(actor_id), + &"has_projection": path_service.has_actor_projection(actor_id), + &"has_movement_credit": _movement_credits.has(actor_id), + }) + + +func _record_expected_actor_wait( + actor_id: StringName, + cell: Vector3i, + absolute_minute: int +) -> void: + # Intentional queue, service, decision, and idle waits are valid progress. Reset + # the stuck baseline so a later movement does not inherit intentional waiting. + path_service.clear_actor_progress(actor_id) + var recorded := path_service.record_actor_progress(actor_id, cell, absolute_minute) + if not bool(recorded.get(&"ok", false)): + _report_path_integration_failure(actor_id, actor_id, &"unknown", cell) + return + _movement_credits[actor_id] = {&"credit": 0, &"updated_at": absolute_minute} + + +func _record_blocked_actor_wait( + actor_id: StringName, + entity_id: StringName, + kind: StringName, + cell: Vector3i, + absolute_minute: int, + credit: int +) -> void: + var recorded := path_service.record_actor_progress(actor_id, cell, absolute_minute) + if not bool(recorded.get(&"ok", false)): + _report_path_integration_failure(actor_id, entity_id, kind, cell) + return + _movement_credits[actor_id] = { + &"credit": clampi(credit, 0, MOVEMENT_CREDIT_SCALE - 1), + &"updated_at": absolute_minute, + } + if int(recorded.get(&"minutes_without_progress", 0)) >= PathService.STUCK_THRESHOLD_MINUTES: + _recover_stuck_actor(actor_id, entity_id, kind, cell, absolute_minute) + + +func _movement_credit_after_one_minute( + actor_id: StringName, + cell: Vector3i, + absolute_minute: int +) -> int: + var current: Dictionary = _movement_credits.get(actor_id, { + &"credit": 0, + &"updated_at": absolute_minute - 1, + }) + var carried := int(current.get(&"credit", 0)) + if int(current.get(&"updated_at", -1)) >= absolute_minute: + return carried + var speed_credit := clampi( + roundi(path_service.get_speed_multiplier(cell) * MOVEMENT_CREDIT_SCALE), + MIN_MOVEMENT_CREDIT_PER_MINUTE, + MOVEMENT_CREDIT_SCALE + ) + return carried + speed_credit + + +func _advance_guest_path_travel( + guest: GuestRuntime, + actor_id: StringName, + absolute_minute: int +) -> void: + var target_cell := _path_handle_target(guest.path_handle, guest.current_cell) + if guest.current_cell == target_cell: + _record_expected_actor_wait(actor_id, guest.current_cell, absolute_minute) + _queue_guest_at_target(guest, absolute_minute) + return + var path := path_service.find_path(guest.current_cell, target_cell) + if path.size() <= 1: + _record_blocked_actor_wait( + actor_id, guest.guest_id, &"guest", guest.current_cell, absolute_minute, 0 + ) + return + var accumulated := _movement_credit_after_one_minute( + actor_id, guest.current_cell, absolute_minute + ) + if accumulated < MOVEMENT_CREDIT_SCALE: + _record_blocked_actor_wait( + actor_id, + guest.guest_id, + &"guest", + guest.current_cell, + absolute_minute, + accumulated + ) + return + var previous_cell := guest.current_cell + var previous_progress_cell := guest.last_progress_cell + var previous_progress_at := guest.last_progress_at + var previous_failure_count := guest.path_failure_count + var previous_recovery_kind := guest.recovery_kind + if not guest_service.record_path_success(guest.guest_id, path[1], absolute_minute) \ + or not path_service.project_actor_position(actor_id, path[1], absolute_minute): + guest.current_cell = previous_cell + guest.last_progress_cell = previous_progress_cell + guest.last_progress_at = previous_progress_at + guest.path_failure_count = previous_failure_count + guest.recovery_kind = previous_recovery_kind + _report_path_integration_failure(actor_id, guest.guest_id, &"guest", previous_cell) + return + var remaining_credit := accumulated - MOVEMENT_CREDIT_SCALE + _movement_credits[actor_id] = { + &"credit": remaining_credit, + &"updated_at": absolute_minute, + } + _publish_actor_event(&"GUEST_MOVED", &"path_service", [guest.guest_id, actor_id], { + &"from_cell": previous_cell, + &"cell": guest.current_cell, + &"target_cell": target_cell, + &"movement_credit": remaining_credit, + }) + if guest.current_cell == target_cell: + _queue_guest_at_target(guest, absolute_minute) + +func _queue_guest_at_target(guest: GuestRuntime, absolute_minute: int) -> void: + var operation_key := StringName( + "guest.auto_queue.%s.%d" % [guest.guest_id, guest.state_entered_at] + ) + var result := submit_command( + &"create_service_request", + { + &"facility_id": guest.target_facility_id, + &"guest_ids": [guest.guest_id], + &"service_type": &"default", + }, + guest.guest_id, + guest.target_facility_id, + 0, + absolute_minute, + operation_key + ) + if not result.is_success() and guest.state == GuestRuntime.State.MOVING: + guest_service.transition_guest( + guest.guest_id, + GuestRuntime.State.DECIDING, + absolute_minute, + &"QUEUE_REQUEST_FAILED" + ) + +func _guest_recovery_targets(current_cell: Vector3i) -> Array: + return [{ + &"kind": &"emergency_exit", + &"cell": current_cell, + &"path_handle": {&"recovery": true, &"target_cell": current_cell}, + }] + +func _guest_recovery_path_exists( + from_cell: Vector3i, + to_cell: Vector3i, + _guest_id: StringName, + _target: Dictionary +) -> bool: + return not path_service.find_path(from_cell, to_cell).is_empty() + +func _advance_staff_task_travel( + task: StaffTask, + actor_id: StringName, + absolute_minute: int +) -> void: + var staff := staff_service.get_staff(task.assigned_staff_id) + if staff == null or staff.current_task_id != task.task_id: + return + if staff.current_cell == task.target_cell: + _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) + staff_service.begin_task(task.task_id, absolute_minute) + return + var path := path_service.find_path( + staff.current_cell, + task.target_cell, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], + [&"staff"] + ) + if path.size() <= 1: + _record_blocked_actor_wait( + actor_id, staff.staff_id, &"staff", staff.current_cell, absolute_minute, 0 + ) + return + var accumulated := _movement_credit_after_one_minute( + actor_id, staff.current_cell, absolute_minute + ) + if accumulated < MOVEMENT_CREDIT_SCALE: + _record_blocked_actor_wait( + actor_id, + staff.staff_id, + &"staff", + staff.current_cell, + absolute_minute, + accumulated + ) + return + var previous_cell := staff.current_cell + staff.current_cell = path[1] + if not path_service.project_actor_position(actor_id, staff.current_cell, absolute_minute): + staff.current_cell = previous_cell + _report_path_integration_failure(actor_id, staff.staff_id, &"staff", previous_cell) + return + var remaining_credit := accumulated - MOVEMENT_CREDIT_SCALE + _movement_credits[actor_id] = { + &"credit": remaining_credit, + &"updated_at": absolute_minute, + } + _publish_actor_event(&"STAFF_MOVED", &"staff_service", [staff.staff_id, task.task_id], { + &"path_actor_id": actor_id, + &"from_cell": previous_cell, + &"cell": staff.current_cell, + &"target_cell": task.target_cell, + &"movement_credit": remaining_credit, + }) + if staff.current_cell == task.target_cell: + staff_service.begin_task(task.task_id, absolute_minute) + + +func _recover_stuck_actor( + actor_id: StringName, + entity_id: StringName, + kind: StringName, + cell: Vector3i, + absolute_minute: int +) -> void: + var path_before := path_service.create_snapshot() + var guests_before := guest_service.create_snapshot() + var staff_before := staff_service.create_snapshot() + var inventory_before := inventory_service.create_snapshot() + var events_before := domain_events.create_snapshot() + var ids_before := id_generator.create_snapshot() + var credits_before := _movement_credits.duplicate(true) + var report := path_service.get_actor_stuck_report( + actor_id, + absolute_minute, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK] + ) + if not bool(report.get(&"ok", false)) or not bool(report.get(&"should_recover", false)): + return + var action := &"NONE" + var action_ok := false + if kind == &"guest": + var guest := guest_service.get_guest(entity_id) + if guest != null: + var recovery_targets: Array = [] + var recovery_value: Variant = report.get(&"recovery", {}) + if recovery_value is Dictionary and bool(recovery_value.get(&"found", false)): + var recovery_cell: Vector3i = recovery_value[&"cell"] + recovery_targets.append({ + &"kind": &"guidance", + &"cell": recovery_cell, + &"path_handle": { + &"recovery": true, + &"target_cell": recovery_cell, + }, + }) + var recovery_result: Dictionary = {} + for _attempt in range(GuestService.PATH_FAILURE_LIMIT): + recovery_result = guest_service.record_path_failure( + entity_id, + absolute_minute, + recovery_targets, + Callable(self, "_guest_recovery_path_exists") + ) + if not bool(recovery_result.get(&"ok", false)) \ + or bool(recovery_result.get(&"recovery_started", false)): + break + action_ok = bool(recovery_result.get(&"ok", false)) \ + and bool(recovery_result.get(&"recovery_started", false)) + action = &"GUEST_SAFE_RECOVERY" if action_ok else &"GUEST_RECOVERY_FAILED" + elif kind == &"staff": + var staff := staff_service.get_staff(entity_id) + if staff != null and not staff.current_task_id.is_empty(): + var cancelled := staff_service.cancel_task( + staff.current_task_id, + &"PATH_STUCK", + absolute_minute + ) + action_ok = cancelled.is_success() + action = &"STAFF_TASK_CANCELLED" if action_ok else &"STAFF_TASK_CANCEL_FAILED" + if not action_ok: + path_service.restore_snapshot(path_before) + guest_service.restore_snapshot(guests_before) + staff_service.restore_snapshot(staff_before) + inventory_service.restore_snapshot(inventory_before) + domain_events.restore_snapshot(events_before) + id_generator.restore_snapshot(ids_before) + _movement_credits = credits_before + return + _movement_credits[actor_id] = {&"credit": 0, &"updated_at": absolute_minute} + _publish_actor_event(&"PATH_ACTOR_STUCK", &"path_service", [actor_id, entity_id], { + &"actor_kind": kind, + &"cell": cell, + &"minutes_without_progress": int(report[&"minutes_without_progress"]), + &"threshold_minutes": int(report[&"threshold_minutes"]), + &"recovery": (report[&"recovery"] as Dictionary).duplicate(true), + &"action": action, + &"implicit_move_applied": false, + }) + +func _leave_guest_queue_boundary( + _guest_id: StringName, + facility_id: StringName, + request_id: StringName +) -> Dictionary: + var request := facility_service.get_request(request_id) + if request == null: + return {&"ok": true, &"orphan_repaired": true} + var inventory_before := inventory_service.create_snapshot() + for reservation_id: StringName in request.reserved_inventory_ids: + var released := inventory_service.release_reservation( + reservation_id, + StringName("guest.abandon.%s.%s" % [request_id, reservation_id]), + _clock.absolute_minute + ) + if not bool(released.get(&"ok", false)): + inventory_service.restore_snapshot(inventory_before) + return released + var leave_result := facility_service.leave_queue(facility_id, request_id, &"PATIENCE_EXHAUSTED") + if not leave_result.is_success(): + inventory_service.restore_snapshot(inventory_before) + return {&"ok": false, &"message": leave_result.message} + return {&"ok": true} + +func _reserve_staff_task_items(task: StaffTask, absolute_minute: int) -> Dictionary: + var inventory_id := &"staff.supplies" + var facility := facility_service.get_instance(task.target_entity_id) + if facility != null: + inventory_id = facility.local_inventory_id + var before := inventory_service.create_snapshot() + var reservation_ids: Array[StringName] = [] + for item_id: StringName in _sorted_name_keys(task.required_items): + var result := inventory_service.reserve_item( + inventory_id, + item_id, + int(task.required_items[item_id]), + task.task_id, + &"staff_task", + absolute_minute, + absolute_minute + 480, + StringName("staff.task.reserve.%s.%s" % [task.task_id, item_id]) + ) + if not bool(result.get(&"ok", false)): + inventory_service.restore_snapshot(before) + return { + &"ok": false, + &"reason": StringName(result.get(&"code", &"ITEM_RESERVATION_FAILED")), + } + for reservation_id: StringName in result.get(&"reservation_ids", []): + reservation_ids.append(reservation_id) + return {&"ok": true, &"reservation_ids": reservation_ids} + +func _release_staff_task_items( + reservation_ids: Array[StringName], + task: StaffTask, + absolute_minute: int +) -> Dictionary: + var before := inventory_service.create_snapshot() + for reservation_id: StringName in reservation_ids: + var result := inventory_service.release_reservation( + reservation_id, + StringName("staff.task.release.%s.%s" % [task.task_id, reservation_id]), + absolute_minute + ) + if not bool(result.get(&"ok", false)): + inventory_service.restore_snapshot(before) + return result + return {&"ok": true} + +func _estimate_staff_task_travel(staff: StaffRuntime, task: StaffTask, _absolute_minute: int) -> int: + if staff.current_cell == task.target_cell: + return 0 + var path := path_service.find_path( + staff.current_cell, + task.target_cell, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], + [&"staff"] + ) + if not path.is_empty(): + return maxi(path.size() - 1, 0) + return -1 + +func _sync_facility_staffing(absolute_minute: int) -> void: + if not _staffing_projection_enabled: + return + var staff_members := staff_service.get_staff_members() + for facility: FacilityInstance in facility_service.get_instances(): + var active_ids: Array[StringName] = [] + for staff: StaffRuntime in staff_members: + if staff.assigned_facility_id == facility.instance_id \ + and staff.state == StaffRuntime.State.AVAILABLE: + active_ids.append(staff.staff_id) + facility_service.set_operational_status( + facility.instance_id, + {&"active_staff_ids": active_ids}, + absolute_minute + ) + +func _project_guest_candidates(guest_id: StringName) -> Array: + var guest := guest_service.get_guest(guest_id) + var candidates: Array = [] + if guest == null: + return candidates + for facility: FacilityInstance in facility_service.get_instances(): + var definition := definition_registry.get_facility(facility.definition_id) + if definition == null: + continue + var has_inputs := true + for item_id: StringName in _sorted_name_keys(definition.inventory_inputs): + if inventory_service.available_quantity(facility.local_inventory_id, item_id) < int(definition.inventory_inputs[item_id]): + has_inputs = false + break + if not has_inputs: + continue + var access := _find_facility_access_path(guest.current_cell, facility) + var path: Array = access.get(&"path", []) + var need_relief := _need_relief_for_facility_tags(definition.tags) + candidates.append({ + &"facility_id": facility.instance_id, + &"facility_tags": definition.tags.duplicate(), + &"is_open": facility.state in [FacilityInstance.State.OPEN, FacilityInstance.State.DEGRADED], + &"reachable": bool(access.get(&"reachable", false)), + &"travel_minutes": maxi(path.size() - 1, 0), + &"expected_wait": facility.queue_entries.size() * facility.base_service_minutes / maxi(facility.capacity, 1), + &"estimated_total_gold": facility.base_price, + &"expected_quality": clampi(facility.base_quality, 0, 100), + &"need_relief": need_relief, + &"risk_level": 0, + &"illegal_level": 0, + &"group_fit": mini(facility.capacity, 4) * 5, + &"service_category": _service_category_for_tags(definition.tags), + &"path_handle": { + &"target_cell": access.get(&"target_cell", guest.current_cell), + &"graph_revision": path_service.graph_revision, + }, + }) + return candidates + +func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: + var candidate_cells: Dictionary = {} + var occupied: Array[Vector3i] = facility.occupied_cells.duplicate() + if occupied.is_empty(): + occupied.append(facility.origin) + for cell: Vector3i in occupied: + for direction: Vector3i in [Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + var candidate := Vector3i(cell.x + direction.x, cell.y + direction.y, cell.z) + if path_service.has_cell(candidate): + candidate_cells[candidate] = true + if path_service.has_cell(facility.origin): + candidate_cells[facility.origin] = true + var ordered: Array = candidate_cells.keys() + ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: + if a.z != b.z: return a.z < b.z + if a.y != b.y: return a.y < b.y + return a.x < b.x + ) + var best_path: Array[Vector3i] = [] + var best_cell := facility.origin + for candidate: Vector3i in ordered: + var path := path_service.find_path(start, candidate) + if path.is_empty(): + continue + if best_path.is_empty() or path.size() < best_path.size(): + best_path.assign(path) + best_cell = candidate + return {&"reachable": not best_path.is_empty(), &"path": best_path, &"target_cell": best_cell} + +func _need_relief_for_facility_tags(tags: Array[StringName]) -> Dictionary: + var relief := { + &"hunger": 0, &"sleep": 0, &"hygiene": 0, + &"medical": 0, &"leisure": 0, &"intimacy": 0, + } + if &"dining" in tags: + relief[&"hunger"] = 80 + relief[&"leisure"] = 10 + if &"lodging" in tags: + relief[&"sleep"] = 90 + relief[&"hygiene"] = maxi(int(relief[&"hygiene"]), 10) + if &"social" in tags or &"leisure" in tags: + relief[&"leisure"] = maxi(int(relief[&"leisure"]), 40) + if &"hygiene" in tags or &"bath" in tags: + relief[&"hygiene"] = maxi(int(relief[&"hygiene"]), 80) + if &"medical" in tags: + relief[&"medical"] = 90 + elif &"wellness" in tags: + relief[&"medical"] = maxi(int(relief[&"medical"]), 20) + if &"adult_only" in tags or &"restricted_service" in tags: + relief[&"intimacy"] = 80 + return relief + +func _service_category_for_tags(tags: Array[StringName]) -> StringName: + if &"dining" in tags: + return &"food" + if &"lodging" in tags: + return &"lodging" + if &"social" in tags: + return &"leisure" + return &"service" + +func _path_handle_target(path_handle: Variant, fallback: Vector3i) -> Vector3i: + if path_handle is Dictionary: + var value: Variant = path_handle.get(&"target_cell", path_handle.get("target_cell", fallback)) + var parsed: Variant = _parse_world_cell(value) + if parsed != null: + return parsed + return fallback + +func _event_actor_exists(actor_id: StringName) -> bool: + if actor_id == &"player": + return true + return guest_service.has_guest(actor_id) or staff_service.has_staff(actor_id) + +func _event_entity_exists(entity_id: Variant) -> bool: + var normalized := StringName(entity_id) + return _event_actor_exists(normalized) \ + or facility_service.has_instance(normalized) \ + or relationship_service.has_relationship(normalized) \ + or event_service.get_instance(normalized) != null + +func _event_path_exists(value: Variant) -> bool: + if not value is Dictionary: + return false + var from_value: Variant = _parse_world_cell(value.get(&"from", value.get("from", null))) + var to_value: Variant = _parse_world_cell(value.get(&"to", value.get("to", null))) + if from_value == null or to_value == null: + return false + return not path_service.find_path(from_value, to_value).is_empty() + +func _roll_authority_inspection(_day_index: int, _chance: int, _terms: Dictionary) -> int: + var stream := get_rng_stream(&"authority") + return stream.randi_range(0, 99) if stream != null else 99 + +func _authority_line_of_sight( + from_cell: Vector3i, + to_cell: Vector3i, + _context: Dictionary +) -> bool: + if from_cell.z != to_cell.z \ + or not world_service.is_in_bounds(from_cell) \ + or not world_service.is_in_bounds(to_cell): + return false + if from_cell == to_cell: + return true + var x := from_cell.x + var y := from_cell.y + var delta_x := absi(to_cell.x - from_cell.x) + var delta_y := absi(to_cell.y - from_cell.y) + var step_x := 1 if from_cell.x < to_cell.x else -1 + var step_y := 1 if from_cell.y < to_cell.y else -1 + var error := delta_x - delta_y + while x != to_cell.x or y != to_cell.y: + var doubled_error := error * 2 + if doubled_error > -delta_y: + error -= delta_y + x += step_x + if doubled_error < delta_x: + error += delta_x + y += step_y + var current := Vector3i(x, y, from_cell.z) + if current == to_cell: + return true + if not world_service.is_walkable(current) \ + or not world_service.get_occupant(current).is_empty(): + return false + return true + +func _authority_path_query( + from_cell: Vector3i, + to_cell: Vector3i, + context: Dictionary +) -> Dictionary: + var connector_access := _name_array( + context.get(&"connector_access", context.get("connector_access", [])) + ) + var path := path_service.find_path( + from_cell, + to_cell, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], + connector_access + ) + if path.is_empty(): + return { + &"reachable": false, + &"reason": StringName(path_service.last_error if not path_service.last_error.is_empty() else "PATH_UNREACHABLE"), + &"travel_minutes": 0, + } + return { + &"reachable": true, + &"reason": &"OK", + &"travel_minutes": maxi(path.size() - 1, 0), + &"path": path, + } + +func _apply_authority_resolution_boundary( + payload: Dictionary, + phase: StringName, + rollback_token: Variant +) -> Dictionary: + if phase == &"rollback": + if not rollback_token is Dictionary \ + or not rollback_token.get(&"economy", null) is Dictionary \ + or not rollback_token.get(&"game_state", null) is Dictionary: + return {&"ok": false, &"error": "Authority rollback token is malformed."} + var economy_ok: bool = economy_service.restore_snapshot(rollback_token[&"economy"]) + var state_ok: bool = _state.restore_snapshot(rollback_token[&"game_state"]) + return { + &"ok": economy_ok and state_ok, + &"error": "Authority fine rollback failed." if not economy_ok or not state_ok else "", + } + var resolution_id := StringName(payload.get(&"resolution_id", payload.get("resolution_id", ""))) + var inspection_id := StringName(payload.get(&"inspection_id", payload.get("inspection_id", ""))) + var fine_amount := int(payload.get(&"fine_amount", payload.get("fine_amount", -1))) + var absolute_minute := int(payload.get(&"absolute_minute", payload.get("absolute_minute", -1))) + var idempotency_key := _authority_resolution_idempotency_key(resolution_id) + if resolution_id.is_empty() or inspection_id.is_empty() or fine_amount < 0 \ + or absolute_minute != _runtime_event_minute() \ + or not economy_service.has_account(PLAYER_CASH_ACCOUNT) \ + or not economy_service.has_account(AUTHORITY_FINE_ACCOUNT): + return {&"ok": false, &"error": "Authority resolution payload is invalid."} + if phase == &"validate": + var existing_transaction := economy_service.get_transaction_id_for_idempotency_key(idempotency_key) + if not existing_transaction.is_empty(): + if not _authority_fine_transaction_matches( + economy_service, existing_transaction, inspection_id, fine_amount + ): + return {&"ok": false, &"error": "Authority fine idempotency key conflicts with its ledger transaction."} + return {&"ok": true, &"token": { + &"economy": economy_service.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"already_applied": true, + &"transaction_id": existing_transaction, + }} + if fine_amount > 0 \ + and not economy_service.account_allows_negative(PLAYER_CASH_ACCOUNT) \ + and economy_service.get_balance(PLAYER_CASH_ACCOUNT) < fine_amount: + return {&"ok": false, &"error": "PLAYER_CASH cannot fund the Authority fine."} + return {&"ok": true, &"token": { + &"economy": economy_service.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"already_applied": false, + }} + if phase != &"apply" or not rollback_token is Dictionary: + return {&"ok": false, &"token": rollback_token, &"error": "Unknown Authority resolution phase or token."} + if bool(rollback_token.get(&"already_applied", false)) or fine_amount == 0: + return { + &"ok": true, + &"transaction_id": StringName(rollback_token.get(&"transaction_id", &"")), + } + var posted := economy_service.transfer( + PLAYER_CASH_ACCOUNT, + AUTHORITY_FINE_ACCOUNT, + fine_amount, + absolute_minute, + &"AUTHORITY_FINE", + inspection_id, + idempotency_key, + &"authority.inspection.fine" + ) + if not posted.is_success(): + return { + &"ok": false, + &"token": rollback_token, + &"error": posted.message, + } + return { + &"ok": true, + &"transaction_id": posted.details.get(&"transaction_id", &""), + } + +func _authority_resolution_idempotency_key(resolution_id: StringName) -> StringName: + return StringName("authority.resolution.%s" % resolution_id) + +func _authority_fine_transaction_matches( + economy: EconomyService, + transaction_id: StringName, + inspection_id: StringName, + fine_amount: int +) -> bool: + if fine_amount == 0: + return transaction_id.is_empty() + var entries := economy.get_transaction_entries(transaction_id) + if entries.size() != 1: + return false + var entry: LedgerEntry = entries[0] + return entry.debit_account == AUTHORITY_FINE_ACCOUNT \ + and entry.credit_account == PLAYER_CASH_ACCOUNT \ + and entry.amount == fine_amount \ + and entry.category == &"AUTHORITY_FINE" \ + and entry.source_entity_id == inspection_id + +func _roll_defense_round(_key: StringName, _context: Dictionary) -> int: + var stream := get_rng_stream(&"defense") + return stream.randi_range(0, 100) if stream != null else 50 + +func _apply_defense_rng_boundary( + _context: Dictionary, + phase: StringName, + token: Variant +) -> Dictionary: + var stream := get_rng_stream(&"defense") + if stream == null: + return {&"ok": false, &"error": "Defense RNG stream is unavailable."} + if phase == &"snapshot": + return {&"ok": true, &"token": stream.snapshot()} + if phase == &"commit": + return {&"ok": true, &"token": token} + if phase == &"rollback": + if not token is Dictionary or not stream.restore(token): + return {&"ok": false, &"error": "Defense RNG rollback token is invalid."} + return {&"ok": true} + return {&"ok": false, &"error": "Unknown Defense RNG boundary phase."} + +func _apply_defense_skill_boundary( + payload: Dictionary, + phase: StringName, + rollback_token: Variant +) -> Dictionary: + if phase == &"rollback": + return _restore_defense_skill_token(rollback_token) + var cost_report := _parse_defense_skill_cost(payload) + if not bool(cost_report[&"ok"]): + return cost_report + var cost: Dictionary = cost_report[&"cost"] + var raid_id := StringName(payload.get(&"raid_id", payload.get("raid_id", ""))) + var action_id := StringName(payload.get(&"action_id", payload.get("action_id", ""))) + if raid_id.is_empty() or action_id.is_empty(): + return {&"ok": false, &"error": "Defense skill boundary IDs are invalid."} + if phase == &"validate": + var token := { + &"economy": economy_service.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"inventory": inventory_service.create_snapshot(), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + &"cost": cost.duplicate(true), + &"reservation_ids": [], + } + var gold := int(cost[&"gold"]) + var mana := int(cost[&"mana"]) + if economy_service.get_balance(PLAYER_CASH_ACCOUNT) < gold \ + or int(_state.get_resource(&"mana")) < mana: + return {&"ok": false, &"error": "Defense skill resources are insufficient."} + var ledger_key := _defense_skill_ledger_key(raid_id, action_id) + if gold > 0 and not economy_service.get_transaction_id_for_idempotency_key(ledger_key).is_empty(): + return {&"ok": false, &"error": "Defense skill ledger key already exists without an applied skill."} + var reservation_ids: Array[StringName] = [] + var items: Array = cost[&"items"] + for item_index: int in range(items.size()): + var item: Dictionary = items[item_index] + var reserved := inventory_service.reserve_item( + StringName(item[&"inventory_id"]), + StringName(item[&"item_id"]), + int(item[&"quantity"]), + action_id, + &"defense_skill", + _defense_operation_minute(), + -1, + StringName("defense.skill.reserve.%s.%s.%d" % [raid_id, action_id, item_index]) + ) + if not bool(reserved.get(&"ok", false)): + token[&"reservation_ids"] = reservation_ids + return { + &"ok": false, + &"token": token, + &"error": "Defense skill item reservation failed.", + &"details": reserved, + } + for reservation_value: Variant in reserved.get(&"reservation_ids", []): + reservation_ids.append(StringName(reservation_value)) + token[&"reservation_ids"] = reservation_ids + return {&"ok": true, &"token": token} + if phase != &"apply" or not _defense_skill_token_is_valid(rollback_token): + return {&"ok": false, &"token": rollback_token, &"error": "Defense skill apply token is invalid."} + var token: Dictionary = rollback_token + var mana := int(cost[&"mana"]) + if mana > 0 and not bool(_state.apply_resource_deltas({&"mana": -mana})): + return {&"ok": false, &"token": token, &"error": "Defense skill Mana application failed."} + var gold := int(cost[&"gold"]) + if gold > 0: + var posted := economy_service.transfer( + PLAYER_CASH_ACCOUNT, + OPERATING_EXPENSE_ACCOUNT, + gold, + _defense_operation_minute(), + &"DEFENSE_SKILL_COST", + action_id, + _defense_skill_ledger_key(raid_id, action_id), + &"defense.skill.cost" + ) + if not posted.is_success(): + return {&"ok": false, &"token": token, &"error": posted.message} + var reservation_ids: Array[StringName] = [] + for reservation_value: Variant in token[&"reservation_ids"]: + reservation_ids.append(StringName(reservation_value)) + if not reservation_ids.is_empty(): + var consumed := inventory_service.consume_reservations( + reservation_ids, + StringName("defense.skill.consume.%s.%s" % [raid_id, action_id]), + _defense_operation_minute() + ) + if not bool(consumed.get(&"ok", false)): + return {&"ok": false, &"token": token, &"error": "Defense skill item consumption failed.", &"details": consumed} + return {&"ok": true, &"token": token} + +func _parse_defense_skill_cost(payload: Dictionary) -> Dictionary: + var metadata_value: Variant = payload.get(&"metadata", payload.get("metadata", {})) + if not metadata_value is Dictionary: + return {&"ok": false, &"error": "Defense skill metadata must be a Dictionary."} + var metadata: Dictionary = metadata_value + var cost_value: Variant = metadata.get(&"cost", metadata.get("cost", {})) + if not cost_value is Dictionary: + return {&"ok": false, &"error": "Defense skill metadata.cost must be a Dictionary."} + var cost_data: Dictionary = cost_value + var gold_value: Variant = cost_data.get(&"gold", cost_data.get("gold", 0)) + var mana_value: Variant = cost_data.get(&"mana", cost_data.get("mana", 0)) + if not gold_value is int or not mana_value is int: + return {&"ok": false, &"error": "Defense skill Gold and Mana costs must be integers."} + var gold := int(gold_value) + var mana := int(mana_value) + if gold < 0 or mana < 0: + return {&"ok": false, &"error": "Defense skill costs cannot be negative."} + var items_value: Variant = cost_data.get(&"items", cost_data.get("items", [])) + if not items_value is Array: + return {&"ok": false, &"error": "Defense skill item costs must be an Array."} + var items: Array[Dictionary] = [] + for raw_item: Variant in items_value: + if not raw_item is Dictionary: + return {&"ok": false, &"error": "Defense skill item cost is malformed."} + var inventory_value: Variant = raw_item.get(&"inventory_id", raw_item.get("inventory_id", "")) + var item_value: Variant = raw_item.get(&"item_id", raw_item.get("item_id", "")) + var quantity_value: Variant = raw_item.get(&"quantity", raw_item.get("quantity", 0)) + if not (inventory_value is String or inventory_value is StringName) \ + or not (item_value is String or item_value is StringName) \ + or not quantity_value is int: + return {&"ok": false, &"error": "Defense skill item cost types are invalid."} + var inventory_id := StringName(inventory_value) + var item_id := StringName(item_value) + var quantity := int(quantity_value) + if inventory_id.is_empty() or item_id.is_empty() or quantity <= 0: + return {&"ok": false, &"error": "Defense skill item cost requires inventory_id, item_id and positive quantity."} + items.append({&"inventory_id": inventory_id, &"item_id": item_id, &"quantity": quantity}) + items.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + var left_key := "%s:%s" % [left[&"inventory_id"], left[&"item_id"]] + var right_key := "%s:%s" % [right[&"inventory_id"], right[&"item_id"]] + return left_key < right_key + ) + return {&"ok": true, &"cost": {&"gold": gold, &"mana": mana, &"items": items}} + +func _defense_skill_ledger_key(raid_id: StringName, action_id: StringName) -> StringName: + return StringName("defense.skill.%s.%s" % [raid_id, action_id]) + +func _defense_skill_token_is_valid(token: Variant) -> bool: + return token is Dictionary \ + and token.get(&"economy", null) is Dictionary \ + and token.get(&"game_state", null) is Dictionary \ + and token.get(&"inventory", null) is Dictionary \ + and token.get(&"domain_events", null) is Dictionary \ + and token.get(&"id_generator", null) is Dictionary \ + and token.get(&"reservation_ids", null) is Array + +func _restore_defense_skill_token(token: Variant) -> Dictionary: + if not _defense_skill_token_is_valid(token): + return {&"ok": false, &"error": "Defense skill rollback token is malformed."} + var economy_ok: bool = economy_service.restore_snapshot(token[&"economy"]) + var state_ok: bool = _state.restore_snapshot(token[&"game_state"]) + var inventory_ok: bool = inventory_service.restore_snapshot(token[&"inventory"]) + var events_ok: bool = domain_events.restore_snapshot(token[&"domain_events"]) + var ids_ok: bool = id_generator.restore_snapshot(token[&"id_generator"]) + return { + &"ok": economy_ok and state_ok and inventory_ok and events_ok and ids_ok, + &"error": "Defense skill rollback failed." if ( + not economy_ok or not state_ok or not inventory_ok or not events_ok or not ids_ok + ) else "", + } + +func _apply_defense_outcome_boundary( + payload: Dictionary, + phase: StringName, + rollback_token: Variant +) -> Dictionary: + if phase == &"rollback": + return _restore_defense_outcome_token(rollback_token) + var validation := _validate_defense_outcome_payload(payload) + if not bool(validation[&"ok"]): + return validation + if phase == &"validate": + return {&"ok": true, &"token": { + &"staff": staff_service.create_snapshot(), + &"facilities": facility_service.create_snapshot(), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + }} + if phase != &"apply" or not _defense_outcome_token_is_valid(rollback_token): + return {&"ok": false, &"token": rollback_token, &"error": "Defense outcome apply token is invalid."} + var token: Dictionary = rollback_token + var combatant_results: Array = payload[&"combatant_results"] + for result_value: Variant in combatant_results: + var result: Dictionary = result_value + if StringName(result[&"side"]) != &"DEFENDER": + continue + var staff := staff_service.get_staff(StringName(result[&"actor_id"])) + staff.health = int(result[&"health"]) + staff.fatigue = int(result[&"fatigue"]) + var defense_state := StringName(result[&"state"]) + if defense_state == &"ACTIVE": + staff.transition_to(StaffRuntime.State.AVAILABLE, &"DEFENSE_RELEASED", _defense_operation_minute()) + elif defense_state == &"INCAPACITATED": + staff.transition_to(StaffRuntime.State.INCAPACITATED, &"DEFENSE_INCAPACITATED", _defense_operation_minute()) + elif defense_state == &"RETREATED": + staff.transition_to(StaffRuntime.State.FLEEING, &"DEFENSE_RETREATED", _defense_operation_minute()) + var asset_results: Array = payload[&"asset_results"] + for result_value: Variant in asset_results: + var result: Dictionary = result_value + var updated := facility_service.set_operational_status( + StringName(result[&"asset_id"]), + {&"durability": int(result[&"integrity"])}, + _defense_operation_minute() + ) + if not updated.is_success(): + return {&"ok": false, &"token": token, &"error": updated.message} + var staff_report := staff_service.validate_invariants() + if not bool(staff_report.get(&"ok", false)): + return {&"ok": false, &"token": token, &"error": "Defense outcome violates Staff invariants.", &"details": staff_report} + return {&"ok": true, &"token": token} + +func _validate_defense_outcome_payload(payload: Dictionary) -> Dictionary: + var raid_id := StringName(payload.get(&"raid_id", payload.get("raid_id", ""))) + var resolution_id := StringName(payload.get(&"resolution_id", payload.get("resolution_id", ""))) + var operation_kind := StringName(payload.get(&"operation_kind", payload.get("operation_kind", ""))) + var absolute_minute := int(payload.get(&"absolute_minute", payload.get("absolute_minute", -1))) + var combatants_value: Variant = payload.get(&"combatant_results", payload.get("combatant_results", null)) + var assets_value: Variant = payload.get(&"asset_results", payload.get("asset_results", null)) + var raid := defense_service.get_raid(raid_id) + if raid_id.is_empty() or resolution_id.is_empty() \ + or operation_kind not in DefenseService.VALID_OPERATION_KINDS \ + or absolute_minute != _defense_operation_minute() \ + or not combatants_value is Array or not assets_value is Array \ + or raid.is_empty(): + return {&"ok": false, &"error": "Defense outcome payload root is invalid."} + var expected_combatants: Dictionary = raid[&"combatants"] + var expected_assets: Dictionary = raid[&"assets"] + if combatants_value.size() != expected_combatants.size() \ + or assets_value.size() != expected_assets.size(): + return {&"ok": false, &"error": "Defense outcome does not cover the complete Raid roster."} + var seen_combatants: Dictionary = {} + for value: Variant in combatants_value: + if not value is Dictionary: + return {&"ok": false, &"error": "Defense combatant outcome is malformed."} + var actor_id := StringName(value.get(&"actor_id", value.get("actor_id", ""))) + var side := StringName(value.get(&"side", value.get("side", ""))) + var state := StringName(value.get(&"state", value.get("state", ""))) + var health := int(value.get(&"health", value.get("health", -1))) + var maximum := int(value.get(&"max_health", value.get("max_health", -1))) + var fatigue := int(value.get(&"fatigue", value.get("fatigue", -1))) + if actor_id.is_empty() or seen_combatants.has(actor_id) or not expected_combatants.has(actor_id) \ + or side not in [&"DEFENDER", &"ATTACKER"] \ + or state not in [&"ACTIVE", &"INCAPACITATED", &"RETREATED"] \ + or health < 0 or health > maximum or fatigue < 0 or fatigue > 100 \ + or (health == 0 and state == &"ACTIVE"): + return {&"ok": false, &"error": "Defense combatant outcome values are invalid."} + var expected_combatant: Dictionary = expected_combatants[actor_id] + if side != StringName(expected_combatant[&"side"]) \ + or maximum != int(expected_combatant[&"max_health"]): + return {&"ok": false, &"error": "Defense combatant outcome differs from the Raid roster."} + seen_combatants[actor_id] = true + if side == &"DEFENDER" and (maximum != StaffRuntime.MAX_PSYCHOLOGY_VALUE or not staff_service.has_staff(actor_id)): + return {&"ok": false, &"error": "Defense outcome references invalid Staff."} + var seen_assets: Dictionary = {} + for value: Variant in assets_value: + if not value is Dictionary: + return {&"ok": false, &"error": "Defense asset outcome is malformed."} + var asset_id := StringName(value.get(&"asset_id", value.get("asset_id", ""))) + var integrity := int(value.get(&"integrity", value.get("integrity", -1))) + var maximum := int(value.get(&"max_integrity", value.get("max_integrity", -1))) + if asset_id.is_empty() or seen_assets.has(asset_id) or not expected_assets.has(asset_id) \ + or maximum != FacilityInstance.DEFAULT_DURABILITY \ + or integrity < 0 or integrity > maximum \ + or not facility_service.has_instance(asset_id): + return {&"ok": false, &"error": "Defense outcome references invalid Facility data."} + var expected_asset: Dictionary = expected_assets[asset_id] + if maximum != int(expected_asset[&"max_integrity"]): + return {&"ok": false, &"error": "Defense asset outcome differs from the Raid roster."} + seen_assets[asset_id] = true + return {&"ok": true} + +func _defense_outcome_token_is_valid(token: Variant) -> bool: + return token is Dictionary \ + and token.get(&"staff", null) is Dictionary \ + and token.get(&"facilities", null) is Dictionary \ + and token.get(&"domain_events", null) is Dictionary \ + and token.get(&"id_generator", null) is Dictionary + +func _restore_defense_outcome_token(token: Variant) -> Dictionary: + if not _defense_outcome_token_is_valid(token): + return {&"ok": false, &"error": "Defense outcome rollback token is malformed."} + var staff_ok: bool = staff_service.restore_snapshot(token[&"staff"]) + var facilities_ok: bool = facility_service.restore_snapshot(token[&"facilities"]) + var events_ok: bool = domain_events.restore_snapshot(token[&"domain_events"]) + var ids_ok: bool = id_generator.restore_snapshot(token[&"id_generator"]) + return { + &"ok": staff_ok and facilities_ok and events_ok and ids_ok, + &"error": "Defense outcome rollback failed." if not staff_ok or not facilities_ok or not events_ok or not ids_ok else "", + } + +func _execute_event_action_boundary( + action: EventAction, + phase: StringName, + context: Dictionary, + rollback_token: Variant +) -> Dictionary: + if action.type not in [ + EventAction.Type.ADD_LEDGER_ENTRY, + EventAction.Type.ADD_OR_REMOVE_ITEM, + EventAction.Type.CREATE_TASK, + EventAction.Type.SET_FACILITY_STATE, + EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY, + ]: + return {&"not_handled": true} + if phase == &"rollback": + return _restore_event_action_token(rollback_token) + var validation := _validate_event_action(action) + if not bool(validation.get(&"ok", false)) or phase == &"validate": + return validation + var token := _capture_event_action_token() + var result := _apply_event_action(action, context) + if not bool(result.get(&"ok", false)): + _restore_event_action_token(token) + return result + result[&"token"] = token + return result + +func _validate_event_action(action: EventAction) -> Dictionary: + match action.type: + EventAction.Type.ADD_LEDGER_ENTRY: + var from_account := StringName(action.payload.get(&"from_account", action.payload.get("from_account", PLAYER_CASH_ACCOUNT))) + var to_account := StringName(action.payload.get(&"to_account", action.payload.get("to_account", OPERATING_EXPENSE_ACCOUNT))) + var amount := int(action.payload.get(&"amount", action.payload.get("amount", 0))) + if amount <= 0 or not economy_service.has_account(from_account) or not economy_service.has_account(to_account): + return {&"ok": false, &"error": "Ledger Action has invalid accounts or amount."} + if not economy_service.account_allows_negative(from_account) and economy_service.get_balance(from_account) < amount: + return {&"ok": false, &"error": "Ledger Action source cannot fund the amount."} + EventAction.Type.ADD_OR_REMOVE_ITEM: + var inventory_id := StringName(action.payload.get(&"inventory_id", action.payload.get("inventory_id", ""))) + var item_id := StringName(action.payload.get(&"item_id", action.payload.get("item_id", ""))) + var delta := int(action.payload.get(&"quantity_delta", action.payload.get("quantity_delta", 0))) + if inventory_id.is_empty() or item_id.is_empty() or delta == 0: + return {&"ok": false, &"error": "Inventory Action requires inventory, item and non-zero delta."} + if delta < 0 and inventory_service.available_quantity(inventory_id, item_id) < -delta: + return {&"ok": false, &"error": "Inventory Action stock is unavailable."} + EventAction.Type.CREATE_TASK: + if StringName(action.payload.get(&"task_type", action.payload.get("task_type", ""))).is_empty(): + return {&"ok": false, &"error": "CREATE_TASK requires task_type."} + EventAction.Type.SET_FACILITY_STATE: + var facility_id := StringName(action.payload.get(&"facility_id", action.payload.get("facility_id", ""))) + var conditions_value: Variant = action.payload.get(&"conditions", action.payload.get("conditions", null)) + if not facility_service.has_instance(facility_id) or not conditions_value is Dictionary: + return {&"ok": false, &"error": "Facility Action target or conditions are invalid."} + var defense_owner: StringName = _defense_lock_owner(facility_id, &"ASSET") + if not defense_owner.is_empty() \ + and (conditions_value.has(&"durability") or conditions_value.has("durability")): + return {&"ok": false, &"error": "Event Action cannot mutate Defense-owned Facility durability."} + EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY: + var entity_id := StringName(action.payload.get(&"entity_id", action.payload.get("entity_id", ""))) + var cell_value: Variant = _parse_world_cell( + action.payload.get(&"cell", action.payload.get("cell", null)) + ) + if not _event_actor_exists(entity_id) or cell_value == null: + return {&"ok": false, &"error": "Recovery teleport target is invalid."} + var target_cell: Vector3i = cell_value + var traffic := path_service.get_cell_traffic(target_cell) + var actor_id := ( + _guest_path_actor_id(entity_id) + if guest_service.has_guest(entity_id) + else _staff_path_actor_id(entity_id) + ) + if traffic.is_empty() or bool(traffic.get(&"temporary_block", false)) \ + or not path_service.has_actor_projection(actor_id): + return {&"ok": false, &"error": "Recovery teleport requires a projected actor and an unblocked walkable target."} + return {&"ok": true} + +func _apply_event_action(action: EventAction, context: Dictionary) -> Dictionary: + var instance_id := StringName(context.get(&"event_instance_id", "")) + match action.type: + EventAction.Type.ADD_LEDGER_ENTRY: + var from_account := StringName(action.payload.get(&"from_account", action.payload.get("from_account", PLAYER_CASH_ACCOUNT))) + var to_account := StringName(action.payload.get(&"to_account", action.payload.get("to_account", OPERATING_EXPENSE_ACCOUNT))) + var amount := int(action.payload.get(&"amount", action.payload.get("amount", 0))) + var result := economy_service.transfer( + from_account, to_account, amount, _runtime_event_minute(), + &"EVENT_ACTION", instance_id, + StringName("event.action.%s.%s" % [instance_id, action.action_id]) + ) + return {&"ok": result.is_success(), &"error": result.message} + EventAction.Type.ADD_OR_REMOVE_ITEM: + var inventory_id := StringName(action.payload.get(&"inventory_id", action.payload.get("inventory_id", ""))) + var item_id := StringName(action.payload.get(&"item_id", action.payload.get("item_id", ""))) + var delta := int(action.payload.get(&"quantity_delta", action.payload.get("quantity_delta", 0))) + if delta > 0: + var added := inventory_service.add_stack(inventory_id, item_id, delta, 100, -1, _runtime_event_minute()) + return {&"ok": bool(added.get(&"ok", false)), &"error": added.get(&"message", "")} + var reserved := inventory_service.reserve_item( + inventory_id, item_id, -delta, instance_id, &"event_action", + _runtime_event_minute(), _runtime_event_minute() + 1, + StringName("event.item.reserve.%s.%s" % [instance_id, action.action_id]) + ) + if not bool(reserved.get(&"ok", false)): + return {&"ok": false, &"error": reserved.get(&"message", "Item reservation failed.")} + var consumed := inventory_service.consume_reservations( + _name_array(reserved.get(&"reservation_ids", [])), + StringName("event.item.consume.%s.%s" % [instance_id, action.action_id]), + _runtime_event_minute() + ) + return {&"ok": bool(consumed.get(&"ok", false)), &"error": consumed.get(&"message", "")} + EventAction.Type.CREATE_TASK: + var raw_options: Variant = action.payload.get(&"options", action.payload.get("options", {})) + var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {} + var created := staff_service.create_task( + StringName(action.payload.get(&"task_type", action.payload.get("task_type", ""))), + int(action.payload.get(&"priority", action.payload.get("priority", 50))), + _runtime_event_minute(), + options, + StringName(action.payload.get(&"task_id", action.payload.get("task_id", ""))) + ) + return {&"ok": created.is_success(), &"error": created.message} + EventAction.Type.SET_FACILITY_STATE: + var updated := facility_service.set_operational_status( + StringName(action.payload.get(&"facility_id", action.payload.get("facility_id", ""))), + action.payload.get(&"conditions", action.payload.get("conditions", {})), + _runtime_event_minute() + ) + return {&"ok": updated.is_success(), &"error": updated.message} + EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY: + var entity_id := StringName(action.payload.get(&"entity_id", action.payload.get("entity_id", ""))) + var cell: Vector3i = _parse_world_cell(action.payload.get(&"cell", action.payload.get("cell", null))) + var guest := guest_service.get_guest(entity_id) + var actor_id: StringName + var moved := false + if guest != null: + actor_id = _guest_path_actor_id(entity_id) + moved = guest_service.record_path_success( + entity_id, cell, _runtime_event_minute() + ) + else: + var staff := staff_service.get_staff(entity_id) + actor_id = _staff_path_actor_id(entity_id) + staff.current_cell = cell + moved = true + if not moved or not path_service.project_actor_position( + actor_id, cell, _runtime_event_minute() + ): + return {&"ok": false, &"error": "Recovery teleport could not commit its Path projection."} + _movement_credits[actor_id] = { + &"credit": 0, + &"updated_at": _runtime_event_minute(), + } + _publish_actor_event(&"ENTITY_RECOVERY_TELEPORTED", &"event_service", [entity_id, actor_id], { + &"cell": cell, + &"path_actor_id": actor_id, + }) + return {&"ok": true} + return {&"ok": false, &"error": "Unsupported Event Action."} + +func _capture_event_action_token() -> Dictionary: + return { + &"economy": economy_service.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"inventory": inventory_service.create_snapshot(), + &"staff": staff_service.create_snapshot(), + &"facilities": facility_service.create_snapshot(), + &"guests": guest_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"movement_credits": _movement_credits.duplicate(true), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), + } + +func _restore_event_action_token(token: Variant) -> Dictionary: + if not token is Dictionary: + return {&"ok": false, &"error": "Event rollback token is malformed."} + # Every participant must be asked to restore even if an earlier participant + # reports an error; short-circuiting here would leave a half-rolled-back world. + var economy_ok: bool = economy_service.restore_snapshot(token.get(&"economy", {})) + var state_ok: bool = _state.restore_snapshot(token.get(&"game_state", {})) + var inventory_ok: bool = inventory_service.restore_snapshot(token.get(&"inventory", {})) + var staff_ok: bool = staff_service.restore_snapshot(token.get(&"staff", {})) + var facilities_ok: bool = facility_service.restore_snapshot(token.get(&"facilities", {})) + var guests_ok: bool = guest_service.restore_snapshot(token.get(&"guests", {})) + var path_ok: bool = path_service.restore_snapshot(token.get(&"path", {})) + var credits_value: Variant = token.get(&"movement_credits", null) + var credits_ok := credits_value is Dictionary + if credits_ok: + _movement_credits = (credits_value as Dictionary).duplicate(true) + var events_ok: bool = domain_events.restore_snapshot(token.get(&"domain_events", {})) + var ids_ok: bool = id_generator.restore_snapshot(token.get(&"id_generator", {})) + var ok: bool = economy_ok and state_ok and inventory_ok and staff_ok \ + and facilities_ok and guests_ok and path_ok and credits_ok and events_ok and ids_ok + return {&"ok": ok, &"error": "Event Action rollback failed." if not ok else ""} + +func _reserve_event_cost(cost: Dictionary, context: Dictionary) -> Dictionary: + var normalized: Dictionary = {} + for raw_resource: Variant in cost: + var resource_id := StringName(raw_resource) + var amount := int(cost[raw_resource]) + if resource_id.is_empty() or amount < 0: + return {&"ok": false, &"error": "Event Choice cost is invalid."} + normalized[resource_id] = amount + var economy_before := economy_service.create_snapshot() + var state_before: Dictionary = _state.create_snapshot() + var instance_id := StringName(context.get(&"event_instance_id", "")) + var escrow_account := StringName("EVENT_ESCROW:%s" % instance_id) + var gold := int(normalized.get(&"gold", 0)) + if gold > 0: + if not economy_service.has_account(escrow_account): + economy_service.register_account(escrow_account) + var transfer := economy_service.transfer( + PLAYER_CASH_ACCOUNT, escrow_account, gold, _runtime_event_minute(), + &"EVENT_COST_RESERVED", instance_id, + StringName("event.cost.reserve.%s" % instance_id) + ) + if not transfer.is_success(): + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + return {&"ok": false, &"error": transfer.message} + var other_costs := normalized.duplicate(true) + other_costs.erase(&"gold") + if not other_costs.is_empty() and not _state.spend(other_costs): + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + return {&"ok": false, &"error": "Event Choice resources are unavailable."} + return {&"ok": true, &"token": { + &"event_instance_id": instance_id, + &"cost": normalized, + &"escrow_account": escrow_account, + }} + +func _release_event_cost(token: Variant, _context: Dictionary) -> Dictionary: + if token is Dictionary and token.is_empty(): + return {&"ok": true} + if not token is Dictionary or not token.get(&"cost", null) is Dictionary: + return {&"ok": false, &"error": "Event Cost release token is malformed."} + var cost: Dictionary = token[&"cost"] + var instance_id := StringName(token.get(&"event_instance_id", "")) + var escrow_account := StringName(token.get(&"escrow_account", "")) + var gold := int(cost.get(&"gold", 0)) + if gold > 0: + var released := economy_service.transfer( + escrow_account, PLAYER_CASH_ACCOUNT, gold, _runtime_event_minute(), + &"EVENT_COST_RELEASED", instance_id, + StringName("event.cost.release.%s" % instance_id) + ) + if not released.is_success(): + return {&"ok": false, &"error": released.message} + for raw_resource: Variant in cost: + var resource_id := StringName(raw_resource) + if resource_id != &"gold": + _state.add_resource(resource_id, int(cost[raw_resource])) + return {&"ok": true} + +func _commit_event_cost(token: Variant, _context: Dictionary) -> Dictionary: + if token is Dictionary and token.is_empty(): + return {&"ok": true} + if not token is Dictionary or not token.get(&"cost", null) is Dictionary: + return {&"ok": false, &"error": "Event Cost commit token is malformed."} + var cost: Dictionary = token[&"cost"] + var gold := int(cost.get(&"gold", 0)) + if gold <= 0: + return {&"ok": true} + var instance_id := StringName(token.get(&"event_instance_id", "")) + var committed := economy_service.transfer( + StringName(token.get(&"escrow_account", "")), OPERATING_EXPENSE_ACCOUNT, + gold, _runtime_event_minute(), &"EVENT_COST_COMMITTED", instance_id, + StringName("event.cost.commit.%s" % instance_id) + ) + return {&"ok": committed.is_success(), &"error": committed.message} + +func _on_relationship_threshold_candidate(candidate: Dictionary) -> void: + var candidate_id := StringName(candidate.get(&"candidate_id", "")) + if candidate_id.is_empty() or event_service.get_instance(candidate_id) != null: + return + event_service.enqueue_candidate( + &"relationship.threshold", + candidate.get(&"actor_ids", []), + int(candidate.get(&"created_at", _runtime_event_minute())), + { + &"event_instance_id": candidate_id, + &"deduplication_key": StringName(candidate.get(&"deduplication_key", candidate_id)), + &"context": {&"relationship_candidate": candidate.duplicate(true)}, + } + ) + +func _route_lock_index(value: Variant) -> int: + if value is int: + return value if value >= 0 and value < RelationshipState.RouteLock.keys().size() else -1 + return RelationshipState.RouteLock.keys().find(String(value).to_upper()) + + +func _validate_construction_facility_crosslinks( + world: WorldService, + construction: ConstructionService, + facilities: FacilityService, + projector: FacilityInfrastructureProjector = null +) -> bool: + for construction_facility: Dictionary in construction.get_facilities(): + var instance_id := StringName(construction_facility.get(&"instance_id", &"")) + var definition_id := StringName(construction_facility.get(&"definition_id", &"")) + var definition := definition_registry.get_facility(definition_id) + if instance_id.is_empty() or definition == null: + return false + if _facility_definition_is_schema_proxy(definition): + if facilities.has_instance(instance_id): + return false + if _facility_definition_is_supported_infrastructure_proxy(definition): + if projector == null or not projector.has_projection(instance_id): + return false + elif projector != null and projector.has_projection(instance_id): + return false + for cell: Vector3i in construction_facility.get(&"occupied_cells", []): + if world.get_occupant(cell) != instance_id \ + or world.get_occupant_kind(cell) != &"facility": + return false + continue + var operational := facilities.get_instance(instance_id) + if operational == null or (projector != null and projector.has_projection(instance_id)): + return false + if operational.definition_id != StringName( + construction_facility.get(&"definition_id", &"") + ) or operational.origin != construction_facility.get(&"origin", Vector3i.ZERO): + return false + var expected_cells: Dictionary = {} + for cell: Vector3i in construction_facility.get(&"occupied_cells", []): + expected_cells[cell] = true + var actual_cells: Dictionary = {} + for cell: Vector3i in operational.occupied_cells: + actual_cells[cell] = true + if expected_cells != actual_cells: + return false + for cell: Vector3i in expected_cells: + if world.get_occupant(cell) != instance_id \ + or world.get_occupant_kind(cell) != &"facility": + return false + return true + + +func _facility_definition_is_schema_proxy(definition: Variant) -> bool: + return definition is FacilityDefinition and &"schema_facility_proxy" in definition.tags + + +func _facility_definition_is_supported_infrastructure_proxy(definition: Variant) -> bool: + return _facility_definition_is_schema_proxy(definition) \ + and definition.id in FacilityInfrastructureProjector.SUPPORTED_KINDS + + +func _facility_definition_is_unsupported_vertical_proxy(definition: Variant) -> bool: + return _facility_definition_is_schema_proxy(definition) \ + and definition.id in FacilityInfrastructureProjector.UNSUPPORTED_VERTICAL_KINDS + + +func _migrate_legacy_infrastructure_proxies( + construction: ConstructionService, + facilities: FacilityService, + projector: FacilityInfrastructureProjector +) -> bool: + if construction == null or facilities == null or projector == null: + return false + for instance: Dictionary in construction.get_facilities(): + var instance_id := StringName(instance.get(&"instance_id", &"")) + var definition := definition_registry.get_facility( + StringName(instance.get(&"definition_id", &"")) + ) + if instance_id.is_empty() or definition == null: + return false + if not _facility_definition_is_schema_proxy(definition): + continue + if facilities.has_instance(instance_id) \ + and not facilities.unregister_instance(instance_id).is_success(): + return false + if _facility_definition_is_unsupported_vertical_proxy(definition): + continue + if not _facility_definition_is_supported_infrastructure_proxy(definition): + return false + var projected := projector.project_completed_facility( + StringName("runtime.migrate.v6:%s" % instance_id), + definition, + instance + ) + if not projected.is_success(): + return false + return true + + +func _validate_infrastructure_crosslinks( + world: WorldService, + path: PathService, + construction: ConstructionService, + _resource_state: Variant, + excavation: ExcavationService, + spatial: SpatialSubslotService, + adapter: RouteSubslotPathAdapter, + projector: FacilityInfrastructureProjector, + require_minute_alignment := true +) -> bool: + if world == null or path == null or construction == null or excavation == null \ + or spatial == null or adapter == null or projector == null \ + or not adapter.is_bound_to(spatial, path) \ + or not bool(spatial.validate_invariants().get(&"ok", false)) \ + or not bool(adapter.validate_invariants().get(&"ok", false)) \ + or not bool(projector.validate_invariants().get(&"ok", false)) \ + or not bool(excavation.validate_invariants().get(&"ok", false)) \ + or (require_minute_alignment \ + and excavation.current_minute != construction.current_minute) \ + or not _same_vector3i_set( + adapter.get_base_walkable_cells(), world.get_walkable_cells() + ) \ + or not _same_vector3i_set( + adapter.get_projected_route_cells(), spatial.get_route_cells() + ) \ + or not _path_snapshot_cells_match_world( + path.create_snapshot(), adapter.get_projected_walkable_cells() + ): + return false + var expected_projection_ids: Dictionary = {} + for instance: Dictionary in construction.get_facilities(): + var instance_id := StringName(instance.get(&"instance_id", &"")) + var definition_id := StringName(instance.get(&"definition_id", &"")) + var definition := definition_registry.get_facility(definition_id) + if instance_id.is_empty() or definition == null: + return false + if not _facility_definition_is_schema_proxy(definition): + if projector.has_projection(instance_id): + return false + continue + if _facility_definition_is_unsupported_vertical_proxy(definition): + if projector.has_projection(instance_id): + return false + continue + if not _facility_definition_is_supported_infrastructure_proxy(definition): + return false + expected_projection_ids[instance_id] = true + var projection := projector.get_projection(instance_id) + if projection.is_empty() \ + or StringName(projection.get(&"definition_id", &"")) != definition_id \ + or StringName(projection.get(&"source_job_id", &"")) \ + != StringName(instance.get(&"source_job_id", &"")) \ + or projection.get(&"origin", null) != instance.get(&"origin", null) \ + or int(projection.get(&"rotation", -1)) != int(instance.get(&"rotation", -2)) \ + or int(projection.get(&"layer", -1)) != int(instance.get(&"layer", -2)) \ + or not _same_vector3i_set( + projection.get(&"cells", []), instance.get(&"occupied_cells", []) + ): + return false + var actual_projection_ids: Dictionary = {} + for instance_id: StringName in projector.get_projection_ids(): + if actual_projection_ids.has(instance_id): + return false + actual_projection_ids[instance_id] = true + return actual_projection_ids == expected_projection_ids + + +func _same_vector3i_set(first: Variant, second: Variant) -> bool: + if not first is Array or not second is Array or first.size() != second.size(): + return false + var expected: Dictionary = {} + for value: Variant in first: + if not value is Vector3i or expected.has(value): + return false + expected[value] = true + for value: Variant in second: + if not value is Vector3i or not expected.has(value): + return false + return true + + +func _validate_runtime_crosslinks( + facilities: FacilityService, + guests: GuestService, + economy: EconomyService, + inventory: InventoryService, + staff: StaffService, + absolute_minute: int, + staffing_projection_enabled: bool +) -> bool: + if absolute_minute < 0 \ + or facilities.current_minute != absolute_minute \ + or guests.current_minute != absolute_minute \ + or inventory.current_minute != absolute_minute \ + or staff.current_minute != absolute_minute: + return false + var queued_links: Dictionary = {} + var service_links: Dictionary = {} + for facility: FacilityInstance in facilities.get_instances(): + if staffing_projection_enabled: + for staff_id: StringName in facility.active_staff_ids: + var active_staff := staff.get_staff(staff_id) + if active_staff == null \ + or active_staff.assigned_facility_id != facility.instance_id \ + or active_staff.state != StaffRuntime.State.AVAILABLE: + return false + for request: ServiceRequest in facilities.get_queue(facility.instance_id): + if not _reservation_references_are_valid( + request.reserved_inventory_ids, request.request_id, inventory + ): + return false + for guest_id: StringName in request.guest_ids: + if not guests.has_guest(guest_id): + continue + var guest := guests.get_guest(guest_id) + if guest.state != GuestRuntime.State.QUEUING \ + or guest.queue_request_id != request.request_id \ + or guest.target_facility_id != facility.instance_id: + return false + queued_links[guest_id] = request.request_id + for service: ServiceInstance in facilities.get_active_services(facility.instance_id): + if not _reservation_references_are_valid( + service.inventory_reservations, service.request_id, inventory + ): + return false + for guest_id: StringName in service.guest_ids: + if not guests.has_guest(guest_id): + continue + var guest := guests.get_guest(guest_id) + if guest.state != GuestRuntime.State.RECEIVING_SERVICE \ + or guest.current_service_id != service.service_id \ + or guest.target_facility_id != facility.instance_id: + return false + service_links[guest_id] = service.service_id + for guest: GuestRuntime in guests.get_guests(): + if not guest.queue_request_id.is_empty() \ + and queued_links.get(guest.guest_id, &"") != guest.queue_request_id: + return false + if not guest.current_service_id.is_empty() \ + and service_links.get(guest.guest_id, &"") != guest.current_service_id: + return false + var account_id := _guest_wallet_account(guest.guest_id) + if not economy.has_account(account_id) \ + or economy.get_balance(account_id) != guest.wallet_gold: + return false + for task: StaffTask in staff.get_tasks(): + if not _reservation_references_are_valid( + task.reservation_ids, task.task_id, inventory + ): + return false + return true + +func _validate_authority_economy_crosslinks( + authority: AuthorityService, + economy: EconomyService, + absolute_minute: int +) -> bool: + if authority == null or authority.current_minute != absolute_minute \ + or not bool(authority.validate_invariants().get(&"ok", false)) \ + or not economy.has_account(AUTHORITY_FINE_ACCOUNT): + return false + var authority_snapshot := authority.create_snapshot() + var raw_resolutions: Variant = authority_snapshot.get(&"applied_resolutions", null) + if not raw_resolutions is Array: + return false + var expected_fine_balance := 0 + for raw_resolution: Variant in raw_resolutions: + if not raw_resolution is Dictionary \ + or not raw_resolution.get(&"payload", null) is Dictionary: + return false + var resolution_id := StringName(raw_resolution.get(&"resolution_id", "")) + var payload: Dictionary = raw_resolution[&"payload"] + var inspection_id := StringName(payload.get(&"inspection_id", "")) + var fine_amount := int(payload.get(&"fine_amount", -1)) + if resolution_id.is_empty() or inspection_id.is_empty() or fine_amount < 0: + return false + expected_fine_balance += fine_amount + if fine_amount == 0: + continue + var transaction_id := economy.get_transaction_id_for_idempotency_key( + _authority_resolution_idempotency_key(resolution_id) + ) + if transaction_id.is_empty() or not _authority_fine_transaction_matches( + economy, + transaction_id, + inspection_id, + fine_amount + ): + return false + return economy.get_balance(AUTHORITY_FINE_ACCOUNT) == expected_fine_balance + +func _validate_live_defense_crosslinks() -> bool: + return _validate_defense_crosslinks( + defense_service, + staff_service, + facility_service, + _defense_operation_minute() + ) + +func _validate_defense_crosslinks( + defense: DefenseService, + staff: StaffService, + facilities: FacilityService, + absolute_minute: int +) -> bool: + if defense == null or staff == null or facilities == null \ + or absolute_minute < 0 or defense.current_minute != absolute_minute \ + or not bool(defense.validate_invariants().get(&"ok", false)): + return false + var defender_owners: Dictionary = {} + var asset_owners: Dictionary = {} + for raid_id: StringName in defense.get_raid_ids(): + var raid := defense.get_raid(raid_id) + if raid.is_empty(): + return false + var state := int(raid.get(&"state", -1)) + var terminal := state in DefenseService.TERMINAL_STATES + var preparing := state == DefenseService.RaidState.PREPARING + var combatants_value: Variant = raid.get(&"combatants", null) + var assets_value: Variant = raid.get(&"assets", null) + if not combatants_value is Dictionary or not assets_value is Dictionary: + return false + var combatants: Dictionary = combatants_value + for actor_id: StringName in _sorted_name_keys(combatants): + var combatant_value: Variant = combatants[actor_id] + if not combatant_value is Dictionary: + return false + var combatant: Dictionary = combatant_value + if StringName(combatant.get(&"side", &"")) != &"DEFENDER": + continue + if not terminal: + if defender_owners.has(actor_id): + return false + defender_owners[actor_id] = raid_id + if int(combatant.get(&"max_health", -1)) != StaffRuntime.MAX_PSYCHOLOGY_VALUE \ + or int(combatant.get(&"initial_health", -1)) != StaffRuntime.MAX_PSYCHOLOGY_VALUE: + return false + # A terminal Raid is historical. Its reconciled Staff may later heal, + # work elsewhere, leave the roster, or enter another Raid; requiring the + # old outcome to remain a permanent live mirror would make future saves + # unrestorable. Capacity baselines remain self-contained above. + if terminal: + continue + var linked_staff := staff.get_staff(actor_id) + if linked_staff == null or linked_staff.state != StaffRuntime.State.UNAVAILABLE \ + or not linked_staff.current_task_id.is_empty(): + return false + if preparing and ( + int(combatant.get(&"health", -1)) != linked_staff.health + or int(combatant.get(&"fatigue", -1)) != linked_staff.fatigue + ): + return false + var assets: Dictionary = assets_value + for asset_id: StringName in _sorted_name_keys(assets): + var asset_value: Variant = assets[asset_id] + if not asset_value is Dictionary: + return false + var asset: Dictionary = asset_value + if not terminal: + if asset_owners.has(asset_id): + return false + asset_owners[asset_id] = raid_id + if int(asset.get(&"max_integrity", -1)) != FacilityInstance.DEFAULT_DURABILITY \ + or int(asset.get(&"initial_integrity", -1)) != FacilityInstance.DEFAULT_DURABILITY: + return false + # Terminal assets are historical for the same reason as terminal Staff: + # the live Facility can subsequently be repaired, reused, or demolished. + if terminal: + continue + var facility := facilities.get_instance(asset_id) + if facility == null: + return false + if preparing and int(asset.get(&"integrity", -1)) != facility.durability: + return false + return true + +func _defense_lock_owner( + external_id: StringName, + lock_kind: StringName, + excluded_raid_id: StringName = &"" +) -> StringName: + if defense_service == null or external_id.is_empty() \ + or lock_kind not in [&"DEFENDER", &"ASSET"]: + return &"" + for raid_id: StringName in defense_service.get_raid_ids(): + if raid_id == excluded_raid_id: + continue + var raid := defense_service.get_raid(raid_id) + if raid.is_empty() or int(raid.get(&"state", -1)) in DefenseService.TERMINAL_STATES: + continue + if lock_kind == &"DEFENDER": + var combatants_value: Variant = raid.get(&"combatants", {}) + if not combatants_value is Dictionary or not combatants_value.has(external_id): + continue + var combatant_value: Variant = combatants_value[external_id] + if combatant_value is Dictionary \ + and StringName(combatant_value.get(&"side", &"")) == &"DEFENDER": + return raid_id + else: + var assets_value: Variant = raid.get(&"assets", {}) + if assets_value is Dictionary and assets_value.has(external_id): + return raid_id + return &"" + +func _reservation_references_are_valid( + reservation_ids: Array[StringName], + requester_id: StringName, + inventory: InventoryService +) -> bool: + for reservation_id: StringName in reservation_ids: + var reservation := inventory.get_reservation(reservation_id) + if reservation == null or reservation.requester_id != requester_id: + return false + return true + +func _advance_mana(absolute_minute: int) -> void: + if _operations_tick_blocked: + return + var infrastructure_preflight := _preflight_proxy_completions(absolute_minute) + if not bool(infrastructure_preflight.get(&"ok", false)): + _latch_infrastructure_failure( + StringName(infrastructure_preflight.get( + &"code", &"INFRASTRUCTURE_PREFLIGHT_FAILED" + )), + absolute_minute, + String(infrastructure_preflight.get( + &"reason", "Infrastructure completion preflight failed." + )), + infrastructure_preflight.get(&"details", {}) + ) + return + var report := mana_network_service.advance_one_minute(absolute_minute) + if not bool(report.get(&"evaluated", false)): + return + for change: Dictionary in report.get(&"state_changes", []): + var consumer_id := StringName(change.get(&"consumer_id", "")) + var facility := facility_service.get_instance(consumer_id) + if facility != null: + var mana_state := int(change.get(&"state", ManaNetworkService.ConsumerState.NO_POWER)) + var degraded_reasons: Array[StringName] = facility.degraded_reasons.duplicate() + degraded_reasons.erase(&"MANA_BROWNOUT") + if mana_state == ManaNetworkService.ConsumerState.DEGRADED: + degraded_reasons.append(&"MANA_BROWNOUT") + facility_service.set_operational_status(consumer_id, { + &"power_available": mana_state in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED], + &"degraded_reasons": degraded_reasons, + }, absolute_minute) + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"MANA_STATE_CHANGED", + absolute_minute, + &"mana_network_service", + [consumer_id], + change, + 0 + )) + +func _on_facility_completed(instance: Dictionary) -> void: + # The staged Runtime boundary has already projected/registered this Facility + # and committed its DomainEvents before Construction releases raw signals. + if _construction_committing_notifications: + return + var instance_id := StringName(instance.get(&"instance_id", &"")) + var definition := definition_registry.get_facility( + StringName(instance.get(&"definition_id", &"")) + ) + if instance_id.is_empty() or definition == null: + _latch_infrastructure_failure( + &"INVALID_COMPLETED_FACILITY", + _runtime_event_minute(), + "Construction emitted an invalid completed Facility.", + {&"instance_id": instance_id} + ) + return + if _facility_definition_is_schema_proxy(definition): + if not _facility_definition_is_supported_infrastructure_proxy(definition): + _latch_infrastructure_failure( + &"UNSUPPORTED_VERTICAL_TOPOLOGY", + _runtime_event_minute(), + "An unsupported vertical proxy reached Construction completion.", + {&"instance_id": instance_id, &"definition_id": definition.id} + ) + return + var projection_request_id := StringName( + "runtime.complete:%s" % String(instance.get(&"source_job_id", instance_id)) + ) + var projected := facility_infrastructure_projector.project_completed_facility( + projection_request_id, + definition, + instance + ) + if not projected.is_success(): + _latch_infrastructure_failure( + &"FACILITY_INFRASTRUCTURE_PROJECTION_FAILED", + _runtime_event_minute(), + projected.message, + projected.details + ) + return + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"FACILITY_INFRASTRUCTURE_PROJECTED", + _runtime_event_minute(), + &"facility_infrastructure_projector", + [instance_id], + projected.details.duplicate(true), + 0 + )) + else: + _register_facility_mana_consumer(instance) + _register_operational_facility(instance) + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"FACILITY_COMPLETED", + _runtime_event_minute(), + &"construction_service", + [instance_id], + instance, + 0 + )) + + +func _on_excavation_completed(report: Dictionary) -> void: + var job_id := StringName(report.get(&"job_id", &"")) + if job_id.is_empty(): + return + var synchronized := route_subslot_path_adapter.synchronize_projection( + StringName("runtime.excavation:%s" % job_id), + world_service.get_walkable_cells() + ) + if not synchronized.is_success(): + _latch_infrastructure_failure( + &"EXCAVATION_ROUTE_SYNCHRONIZATION_FAILED", + int(report.get(&"completed_at", _runtime_event_minute())), + synchronized.message, + synchronized.details + ) + return + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"EXCAVATION_COMPLETED", + int(report.get(&"completed_at", _runtime_event_minute())), + &"excavation_service", + [job_id], + report.duplicate(true), + 0 + )) + +func _on_facility_demolished(instance_id: StringName, refund: Dictionary) -> void: + if not mana_network_service.get_node(instance_id).is_empty(): + mana_network_service.unregister_node(instance_id) + if facility_service.has_instance(instance_id): + facility_service.unregister_instance(instance_id) + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + &"FACILITY_DEMOLISHED", + _runtime_event_minute(), + &"construction_service", + [instance_id], + {&"refund": refund}, + 0 + )) + +func _on_ledger_transaction_posted(transaction_id: StringName, _entries: Array) -> void: + if _operations_flushing_economy_notifications.has(transaction_id): + return + if _state != null: + _state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT)) + + +func get_mvp_content_report() -> Dictionary: + return mvp_content_report.duplicate(true) + + +func get_mvp_guest_definition_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for definition: GuestDefinition in mvp_content_catalog.build_all_guest_definitions(): + result.append(definition.guest_type_id) + return result + + +func get_mvp_staff_candidate_ids() -> Array[StringName]: + return mvp_content_catalog.get_staff_candidate_ids() + + +func get_mvp_staff_candidate_payload(candidate_id: StringName) -> Dictionary: + return mvp_content_catalog.staff_hire_payload(candidate_id).duplicate(true) + + +## Read-only presentation data for the hiring screen. The validated catalog +## remains authoritative and the UI never receives a mutable reference to it. +func get_mvp_staff_candidate_summaries() -> Array[Dictionary]: + var summaries: Array[Dictionary] = [] + for candidate_id: StringName in mvp_content_catalog.get_staff_candidate_ids(): + var entry := mvp_content_catalog.get_entry(&"staff_candidates", candidate_id) + var payload := mvp_content_catalog.staff_hire_payload(candidate_id) + if entry.is_empty() or payload.is_empty(): + continue + var options_value: Variant = payload.get(&"options", {}) + var options: Dictionary = options_value if options_value is Dictionary else {} + var skills_value: Variant = options.get(&"skills", {}) + var skills: Dictionary = skills_value if skills_value is Dictionary else {} + summaries.append({ + &"candidate_id": candidate_id, + &"display_name": String(entry.get(&"display_name", candidate_id)), + &"age": int(payload.get(&"age", 0)), + &"role_id": StringName(payload.get(&"role_id", &"")), + &"daily_wage": int(payload.get(&"daily_wage", 0)), + &"signing_cost": int(payload.get(&"signing_cost", 0)), + &"preferred_shift": StringName(payload.get(&"preferred_shift", &"any")), + &"skills": skills.duplicate(true), + &"hired": _hired_staff_candidate_ids.has(candidate_id), + }) + return summaries + + +func get_hired_staff_candidate_ids() -> Array[StringName]: + return _sorted_name_keys(_hired_staff_candidate_ids) + + +func get_progression_report(target_rank := -1) -> Dictionary: + return { + &"snapshot": progression_service.create_snapshot(), + &"evaluation": progression_service.evaluate_rank(target_rank).duplicate(true), + } + + +func _create_mvp_content_manifest(catalog: MvpContentCatalog) -> Dictionary: + var guest_definition_ids: Array[String] = [] + for definition: GuestDefinition in catalog.build_all_guest_definitions(): + guest_definition_ids.append(String(definition.guest_type_id)) + return { + &"manifest_version": MVP_CONTENT_MANIFEST_VERSION, + &"schema_version": MvpContentCatalog.SCHEMA_VERSION, + &"guest_class_ids": _names_to_strings_for_manifest(catalog.get_guest_class_ids()), + &"species_ids": _names_to_strings_for_manifest(catalog.get_species_ids()), + &"staff_candidate_ids": _names_to_strings_for_manifest(catalog.get_staff_candidate_ids()), + &"unique_character_ids": _names_to_strings_for_manifest(catalog.get_unique_character_ids()), + &"guest_definition_ids": guest_definition_ids, + } + + +func _names_to_strings_for_manifest(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +func _catalog_definitions_are_registered(service: GuestService) -> bool: + if not bool(mvp_content_report.get(&"valid", false)): + return false + return _saved_catalog_guest_definitions_match( + service.create_snapshot(), + mvp_content_catalog + ) + + +func _saved_catalog_guest_definitions_match( + guest_snapshot: Dictionary, + catalog: MvpContentCatalog +) -> bool: + var raw_definitions: Variant = guest_snapshot.get(&"definitions", null) + if not raw_definitions is Array: + return false + var saved_by_id: Dictionary = {} + for raw_definition: Variant in raw_definitions: + if not raw_definition is Dictionary: + return false + var raw_id: Variant = raw_definition.get(&"guest_type_id", null) + if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var definition_id := StringName(raw_id) + if definition_id.is_empty() or saved_by_id.has(definition_id): + return false + saved_by_id[definition_id] = raw_definition + var expected_definitions := catalog.build_all_guest_definitions() + if expected_definitions.size() != 32: + return false + for definition: GuestDefinition in expected_definitions: + if not saved_by_id.has(definition.guest_type_id) \ + or not _strict_variants_equal( + definition.create_snapshot(), + saved_by_id[definition.guest_type_id] + ): + return false + return true + + +func _register_or_validate_legacy_catalog_definitions( + service: GuestService, + catalog: MvpContentCatalog +) -> bool: + var definitions := catalog.build_all_guest_definitions() + if definitions.size() != 32: + return false + for definition: GuestDefinition in definitions: + var existing := service.get_definition(definition.guest_type_id) + if existing == null: + if not service.register_definition(definition): + return false + elif not _strict_variants_equal( + existing.create_snapshot(), + definition.create_snapshot() + ): + return false + return true + + +func _serialize_hired_staff_candidate_ids() -> Array[String]: + var result: Array[String] = [] + for candidate_id: StringName in _sorted_name_keys(_hired_staff_candidate_ids): + result.append(String(candidate_id)) + return result + + +func _is_mvp_staff_candidate_id(staff_id: StringName) -> bool: + return mvp_content_catalog.get_staff_candidate_ids().has(staff_id) + + +func _deserialize_hired_staff_candidate_ids( + raw_ids: Array, + catalog: MvpContentCatalog +) -> Variant: + var allowed: Dictionary = {} + for candidate_id: StringName in catalog.get_staff_candidate_ids(): + allowed[candidate_id] = true + var result: Dictionary = {} + var previous := "" + for raw_id: Variant in raw_ids: + if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return null + var candidate_id := StringName(raw_id) + var candidate_text := String(candidate_id) + if not allowed.has(candidate_id) or result.has(candidate_id) \ + or (not previous.is_empty() and candidate_text <= previous): + return null + result[candidate_id] = true + previous = candidate_text + return result + + +func _validate_catalog_staff_crosslinks( + staff: StaffService, + hired_ids: Dictionary, + catalog: MvpContentCatalog +) -> bool: + var candidate_ids: Dictionary = {} + for candidate_id: StringName in catalog.get_staff_candidate_ids(): + candidate_ids[candidate_id] = true + for member: StaffRuntime in staff.get_staff_members(): + if not candidate_ids.has(member.staff_id): + continue + if not hired_ids.has(member.staff_id): + return false + var canonical := catalog.staff_hire_payload(member.staff_id) + if canonical.is_empty() \ + or member.role_id != StringName(canonical[&"role_id"]) \ + or member.daily_wage != int(canonical[&"daily_wage"]) \ + or member.get_employment_kind_name() != &"FREE_HIRE" \ + or not _strict_variants_equal( + member.permitted_task_tags, + (canonical[&"options"] as Dictionary)[&"permitted_task_tags"] + ) \ + or not _strict_variants_equal( + member.skills, + (canonical[&"options"] as Dictionary)[&"skills"] + ): + return false + return true + + +func _derive_legacy_hired_staff_candidate_ids( + staff: StaffService, + staff_snapshot: Dictionary, + catalog: MvpContentCatalog +) -> Dictionary: + var allowed: Dictionary = {} + for candidate_id: StringName in catalog.get_staff_candidate_ids(): + allowed[candidate_id] = true + var result: Dictionary = {} + for member: StaffRuntime in staff.get_staff_members(): + if allowed.has(member.staff_id): + result[member.staff_id] = true + var raw_departed: Variant = staff_snapshot.get(&"departed_payroll", []) + if raw_departed is Array: + for raw_record: Variant in raw_departed: + if raw_record is Dictionary: + var candidate_id := StringName(raw_record.get(&"staff_id", "")) + if allowed.has(candidate_id): + result[candidate_id] = true + return result + + +func _strict_variants_equal(expected: Variant, actual: Variant) -> bool: + if typeof(expected) != typeof(actual): + return false + if expected is Dictionary: + var expected_dictionary := expected as Dictionary + var actual_dictionary := actual as Dictionary + if expected_dictionary.size() != actual_dictionary.size(): + return false + var unmatched_actual_keys: Array = actual_dictionary.keys() + for expected_key: Variant in expected_dictionary: + var matched_index := -1 + for index in range(unmatched_actual_keys.size()): + var actual_key: Variant = unmatched_actual_keys[index] + if typeof(actual_key) == typeof(expected_key) and actual_key == expected_key: + matched_index = index + break + if matched_index < 0: + return false + var matched_key: Variant = unmatched_actual_keys[matched_index] + if not _strict_variants_equal( + expected_dictionary[expected_key], + actual_dictionary[matched_key] + ): + return false + unmatched_actual_keys.remove_at(matched_index) + return unmatched_actual_keys.is_empty() + if expected is Array: + var expected_array := expected as Array + var actual_array := actual as Array + if expected_array.size() != actual_array.size(): + return false + for index in range(expected_array.size()): + if not _strict_variants_equal(expected_array[index], actual_array[index]): + return false + return true + return expected == actual + + +func _operations_snapshot_comparison_value(value: Variant) -> Variant: + if typeof(value) == TYPE_STRING_NAME: + return String(value) + if value is Array: + var normalized_array: Array = [] + for member: Variant in value: + normalized_array.append(_operations_snapshot_comparison_value(member)) + return normalized_array + if value is Dictionary: + var normalized_dictionary: Dictionary = {} + for raw_key: Variant in value: + var normalized_key: Variant = raw_key + if typeof(raw_key) == TYPE_STRING_NAME: + normalized_key = String(raw_key) + if normalized_dictionary.has(normalized_key): + # A String/StringName alias collision is not a canonical snapshot. + return null + normalized_dictionary[normalized_key] = _operations_snapshot_comparison_value( + value[raw_key] + ) + return normalized_dictionary + return value + + +func get_actor_movement_credit(actor_id: StringName) -> Dictionary: + if not _movement_credits.has(actor_id): + return {} + return (_movement_credits[actor_id] as Dictionary).duplicate(true) + + +func _guest_path_actor_id(guest_id: StringName) -> StringName: + return StringName("guest:%s" % guest_id) + + +func _staff_path_actor_id(staff_id: StringName) -> StringName: + return StringName("staff:%s" % staff_id) + + +func _expected_runtime_actor_cells( + guests: GuestService, + staff: StaffService +) -> Dictionary: + var expected: Dictionary = {} + for guest: GuestRuntime in guests.get_guests(): + var guest_actor_id := _guest_path_actor_id(guest.guest_id) + if guest_actor_id.is_empty() or expected.has(guest_actor_id): + return {} + expected[guest_actor_id] = guest.current_cell + for member: StaffRuntime in staff.get_staff_members(): + var staff_actor_id := _staff_path_actor_id(member.staff_id) + if staff_actor_id.is_empty() or expected.has(staff_actor_id): + return {} + expected[staff_actor_id] = member.current_cell + return expected + + +func _serialize_movement_credits() -> Array[Dictionary]: + var saved: Array[Dictionary] = [] + for actor_id: StringName in _sorted_name_keys(_movement_credits): + var entry: Dictionary = _movement_credits[actor_id] + saved.append({ + &"actor_id": String(actor_id), + &"credit": int(entry[&"credit"]), + &"updated_at": int(entry[&"updated_at"]), + }) + return saved + + +func _deserialize_movement_credits(raw_entries: Array, absolute_minute: int) -> Variant: + var restored: Dictionary = {} + for raw_entry: Variant in raw_entries: + if not raw_entry is Dictionary: + return null + var actor_id_value: Variant = raw_entry.get(&"actor_id", null) + var credit_value: Variant = raw_entry.get(&"credit", null) + var updated_at_value: Variant = raw_entry.get(&"updated_at", null) + if not (actor_id_value is String or actor_id_value is StringName) \ + or not credit_value is int or not updated_at_value is int: + return null + var actor_id := StringName(actor_id_value) + var credit := int(credit_value) + var updated_at := int(updated_at_value) + if actor_id.is_empty() or restored.has(actor_id) \ + or credit < 0 or credit >= MOVEMENT_CREDIT_SCALE \ + or updated_at < 0 or updated_at > absolute_minute: + return null + restored[actor_id] = { + &"credit": credit, + &"updated_at": updated_at, + } + return restored + + +func _rebuild_legacy_path_actor_projection( + path: PathService, + guests: GuestService, + staff: StaffService, + absolute_minute: int +) -> Variant: + var expected := _expected_runtime_actor_cells(guests, staff) + var entries: Array[Dictionary] = [] + var progress_entries: Array[Dictionary] = [] + var expected_density: Dictionary = {} + var rebuilt: Dictionary = {} + for actor_id: StringName in _sorted_name_keys(expected): + var cell: Vector3i = expected[actor_id] + if not path.has_cell(cell): + return null + entries.append({&"actor_id": actor_id, &"cell": cell}) + progress_entries.append({ + &"actor_id": actor_id, + &"last_cell": cell, + &"last_progress_at": absolute_minute, + &"last_observed_at": absolute_minute, + &"stuck_reported_at": -1, + }) + expected_density[cell] = int(expected_density.get(cell, 0)) + 1 + rebuilt[actor_id] = {&"credit": 0, &"updated_at": absolute_minute} + var migrated_path := path.create_snapshot() + migrated_path[&"actor_cells"] = entries + migrated_path[&"actor_progress"] = progress_entries + migrated_path[&"traffic_revision"] = int(migrated_path[&"traffic_revision"]) + 1 + for traffic: Dictionary in migrated_path[&"cell_traffic"]: + traffic[&"current_density"] = int(expected_density.get(traffic[&"cell"], 0)) + if not path.restore_snapshot(migrated_path): + return null + return rebuilt + + +func _validate_runtime_path_crosslinks( + path: PathService, + guests: GuestService, + staff: StaffService, + credits: Dictionary, + absolute_minute: int +) -> bool: + if path == null or guests == null or staff == null or absolute_minute < 0: + return false + var expected := _expected_runtime_actor_cells(guests, staff) + if expected.size() != guests.guest_count() + staff.get_staff_members().size() \ + or credits.size() != expected.size(): + return false + var path_snapshot := path.create_snapshot() + var raw_actor_cells: Variant = path_snapshot.get(&"actor_cells", null) + var raw_progress: Variant = path_snapshot.get(&"actor_progress", null) + var raw_traffic: Variant = path_snapshot.get(&"cell_traffic", null) + if not raw_actor_cells is Array or not raw_progress is Array or not raw_traffic is Array: + return false + var projected: Dictionary = {} + for raw_actor: Variant in raw_actor_cells: + if not raw_actor is Dictionary: + return false + var actor_id := StringName(raw_actor.get(&"actor_id", &"")) + var cell: Variant = raw_actor.get(&"cell", null) + if actor_id.is_empty() or not cell is Vector3i or projected.has(actor_id): + return false + projected[actor_id] = cell + if projected.size() != expected.size(): + return false + var progress_by_actor: Dictionary = {} + for raw_entry: Variant in raw_progress: + if not raw_entry is Dictionary: + return false + var actor_id := StringName(raw_entry.get(&"actor_id", &"")) + if actor_id.is_empty() or progress_by_actor.has(actor_id): + return false + progress_by_actor[actor_id] = raw_entry + if progress_by_actor.size() != expected.size(): + return false + var expected_density: Dictionary = {} + for actor_id: StringName in _sorted_name_keys(expected): + var cell: Vector3i = expected[actor_id] + if projected.get(actor_id, null) != cell or not credits.has(actor_id): + return false + var credit_entry: Variant = credits[actor_id] + var progress_entry: Variant = progress_by_actor.get(actor_id, null) + if not credit_entry is Dictionary or not progress_entry is Dictionary: + return false + var credit_value: Variant = credit_entry.get(&"credit", null) + var credit_updated_value: Variant = credit_entry.get(&"updated_at", null) + var last_progress_value: Variant = progress_entry.get(&"last_progress_at", null) + var last_observed_value: Variant = progress_entry.get(&"last_observed_at", null) + var stuck_reported_value: Variant = progress_entry.get(&"stuck_reported_at", null) + if not credit_value is int or not credit_updated_value is int \ + or not last_progress_value is int or not last_observed_value is int \ + or not stuck_reported_value is int: + return false + var credit := int(credit_value) + var updated_at := int(credit_updated_value) + var last_progress_at := int(last_progress_value) + var last_observed_at := int(last_observed_value) + var stuck_reported_at := int(stuck_reported_value) + if credit < 0 or credit >= MOVEMENT_CREDIT_SCALE \ + or updated_at < 0 or updated_at > absolute_minute \ + or progress_entry.get(&"last_cell", null) != cell \ + or last_progress_at < 0 or last_progress_at > last_observed_at \ + or last_observed_at != updated_at \ + or last_observed_at > absolute_minute \ + or (stuck_reported_at != -1 and ( + stuck_reported_at < last_progress_at or stuck_reported_at > last_observed_at + )): + return false + expected_density[cell] = int(expected_density.get(cell, 0)) + 1 + for raw_cell: Variant in raw_traffic: + if not raw_cell is Dictionary: + return false + var cell: Variant = raw_cell.get(&"cell", null) + var density_value: Variant = raw_cell.get(&"current_density", null) + if not cell is Vector3i or not density_value is int \ + or int(density_value) != int(expected_density.get(cell, 0)): + return false + return true + + +func _path_snapshot_cells_match_world( + path_snapshot: Dictionary, + walkable_cells: Array[Vector3i] +) -> bool: + var schema_value: Variant = path_snapshot.get( + &"schema_version", + path_snapshot.get("schema_version", null) + ) + if not schema_value is int or int(schema_value) != PathService.SNAPSHOT_VERSION: + return false + var raw_traffic: Variant = path_snapshot.get( + &"cell_traffic", + path_snapshot.get("cell_traffic", null) + ) + if not raw_traffic is Array or raw_traffic.size() != walkable_cells.size(): + return false + var expected_cells: Dictionary = {} + for cell: Vector3i in walkable_cells: + if expected_cells.has(cell): + return false + expected_cells[cell] = true + var actual_cells: Dictionary = {} + for raw_entry: Variant in raw_traffic: + if not raw_entry is Dictionary: + return false + var cell_value: Variant = raw_entry.get(&"cell", raw_entry.get("cell", null)) + if not cell_value is Vector3i \ + or actual_cells.has(cell_value) \ + or not expected_cells.has(cell_value): + return false + actual_cells[cell_value] = true + return actual_cells.size() == expected_cells.size() + + +func _serialize_logistics_requests() -> Array[Dictionary]: + var saved: Array[Dictionary] = [] + for request_id: StringName in _sorted_name_keys(logistics_requests): + saved.append((logistics_requests[request_id] as LogisticsRequest).create_snapshot()) + return saved + + +func _derive_legacy_logistics_ownership( + dispatcher: LogisticsDispatcher, + requests: Dictionary, + absolute_minute: int +) -> Dictionary: + var latest_history: Dictionary = {} + for assignment: Dictionary in dispatcher.get_assignment_history(): + var request_id := StringName(assignment.get(&"request_id", &"")) + var assigned_at := int(assignment.get(&"assigned_at", -1)) + if request_id.is_empty() or assigned_at < 0 or assigned_at > absolute_minute: + return {} + latest_history[request_id] = assignment + var expected: Dictionary = {} + for request_id: StringName in _sorted_name_keys(requests): + var request: LogisticsRequest = requests[request_id] + if request.state not in [ + LogisticsRequest.State.ASSIGNED, + LogisticsRequest.State.PICKING_UP, + LogisticsRequest.State.DELIVERING, + ]: + continue + if request.assigned_carrier_id.is_empty() or request.total_weight <= 0 \ + or not latest_history.has(request_id): + return {} + var latest: Dictionary = latest_history[request_id] + if StringName(latest.get(&"carrier_id", &"")) != request.assigned_carrier_id \ + or int(latest.get(&"weight", -1)) != request.total_weight: + return {} + expected[request_id] = { + &"carrier_id": request.assigned_carrier_id, + &"weight": request.total_weight, + &"assigned_at": int(latest[&"assigned_at"]), + } + return expected + + +func _validate_logistics_dispatcher_crosslinks( + dispatcher: LogisticsDispatcher, + requests: Dictionary, + absolute_minute: int +) -> bool: + if dispatcher == null or absolute_minute < 0: + return false + var ownership := dispatcher.get_assignment_ownership() + var expected_ids: Dictionary = {} + for request_id: StringName in _sorted_name_keys(requests): + var request: LogisticsRequest = requests[request_id] + if request.created_at < 0 or request.state_changed_at < request.created_at \ + or request.state_changed_at > absolute_minute: + return false + var active := request.state in [ + LogisticsRequest.State.ASSIGNED, + LogisticsRequest.State.PICKING_UP, + LogisticsRequest.State.DELIVERING, + ] + if not active: + if not request.assigned_carrier_id.is_empty(): + return false + continue + if request.assigned_carrier_id.is_empty() or request.total_weight <= 0: + return false + expected_ids[request_id] = true + var entry: Variant = ownership.get(request_id, null) + if not entry is Dictionary \ + or StringName(entry.get(&"carrier_id", &"")) != request.assigned_carrier_id \ + or int(entry.get(&"weight", -1)) != request.total_weight: + return false + var assigned_at := int(entry.get(&"assigned_at", -1)) + if assigned_at < request.created_at or assigned_at > request.state_changed_at: + return false + if ownership.size() != expected_ids.size(): + return false + for request_id: StringName in _sorted_name_keys(ownership): + if not expected_ids.has(request_id): + return false + var latest_history: Dictionary = {} + var history_fingerprints: Dictionary = {} + for assignment: Dictionary in dispatcher.get_assignment_history(): + var request_id := StringName(assignment.get(&"request_id", &"")) + if request_id.is_empty() or not requests.has(request_id): + return false + var request: LogisticsRequest = requests[request_id] + var carrier_id := StringName(assignment.get(&"carrier_id", &"")) + var weight := int(assignment.get(&"weight", -1)) + var assigned_at := int(assignment.get(&"assigned_at", -1)) + var fingerprint := "%s|%s|%d|%d" % [request_id, carrier_id, weight, assigned_at] + if carrier_id.is_empty() or weight != request.total_weight \ + or assigned_at < request.created_at or assigned_at > absolute_minute \ + or history_fingerprints.has(fingerprint): + return false + history_fingerprints[fingerprint] = true + latest_history[request_id] = assignment + for request_id: StringName in _sorted_name_keys(ownership): + if not latest_history.has(request_id): + return false + var entry: Dictionary = ownership[request_id] + var latest: Dictionary = latest_history[request_id] + if StringName(latest[&"carrier_id"]) != StringName(entry[&"carrier_id"]) \ + or int(latest[&"weight"]) != int(entry[&"weight"]) \ + or int(latest[&"assigned_at"]) != int(entry[&"assigned_at"]): + return false + return true + +func _serialize_event_definitions() -> Array[Dictionary]: + var saved: Array[Dictionary] = [] + for definition: EventDefinition in event_service.get_definitions(): + saved.append(definition.create_snapshot()) + return saved + +func _deserialize_logistics_requests(raw_requests: Array) -> Variant: + var restored: Dictionary = {} + for raw_request: Variant in raw_requests: + if not raw_request is Dictionary: + return null + var request := LogisticsRequest.from_snapshot(raw_request) + if request == null or restored.has(request.request_id): + return null + restored[request.request_id] = request + return restored + +func _sorted_logistics_requests() -> Array: + var requests: Array = [] + for request_id: StringName in _sorted_name_keys(logistics_requests): + requests.append(logistics_requests[request_id]) + return requests + +func _sorted_name_keys(source: Dictionary) -> Array[StringName]: + var keys: Array[StringName] = [] + for raw_key: Variant in source: + keys.append(StringName(raw_key)) + keys.sort_custom(func(first: StringName, second: StringName) -> bool: + return String(first) < String(second) + ) + return keys + +func _name_array(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for value: Variant in source: + result.append(StringName(value)) + return result + +func _guest_wallet_account(guest_id: StringName) -> StringName: + return StringName("GUEST_WALLET:%s" % guest_id) + +func _outstanding_service_commitment(guest_id: StringName) -> int: + var total := 0 + var counted_requests: Dictionary = {} + for facility: FacilityInstance in facility_service.get_instances(): + for request: ServiceRequest in facility_service.get_queue(facility.instance_id): + if not request.guest_ids.is_empty() and request.guest_ids[0] == guest_id: + total += request.quoted_price + counted_requests[request.request_id] = true + for service: ServiceInstance in facility_service.get_active_services(facility.instance_id): + if counted_requests.has(service.request_id): + continue + var request := facility_service.get_request(service.request_id) + if request != null and not request.guest_ids.is_empty() \ + and request.guest_ids[0] == guest_id: + total += request.quoted_price + counted_requests[request.request_id] = true + return total + +func _facility_revenue_account(facility_id: StringName) -> StringName: + return StringName("FACILITY_REVENUE:%s" % facility_id) + +func _command_result_from_dictionary( + command_id: int, + result: Dictionary, + fallback_message: String +) -> CommandResult: + if bool(result.get(&"ok", result.get("ok", false))): + return CommandResult.succeeded(command_id, result.duplicate(true)) + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + command_id, + String(result.get(&"message", result.get("message", fallback_message))), + result.duplicate(true) + ) + +func _register_facility_mana_consumer(instance: Dictionary) -> void: + var instance_id := StringName(instance.get(&"instance_id", &"")) + var definition_id := StringName(instance.get(&"definition_id", &"")) + var definition := definition_registry.get_facility(definition_id) + if instance_id.is_empty() or definition == null or definition.maintenance_mana <= 0: + return + var priority := ManaNetworkService.ConsumerPriority.NORMAL + if &"entrance" in definition.tags or &"medical" in definition.tags: + priority = ManaNetworkService.ConsumerPriority.ESSENTIAL + if mana_network_service.register_consumer(instance_id, definition.maintenance_mana, priority): + mana_network_service.connect_nodes(instance_id, STARTER_MANA_BUS) + +func _register_operational_facility(construction_instance: Dictionary) -> void: + var instance_id := StringName(construction_instance.get(&"instance_id", &"")) + var definition_id := StringName(construction_instance.get(&"definition_id", &"")) + var definition := definition_registry.get_facility(definition_id) + if instance_id.is_empty() or definition == null or facility_service.has_instance(instance_id): + return + economy_service.register_account(_facility_revenue_account(instance_id)) + facility_service.create_instance({ + &"instance_id": instance_id, + &"definition_id": definition_id, + &"origin": construction_instance.get(&"origin", Vector3i.ZERO), + &"rotation": construction_instance.get(&"rotation", 0), + &"occupied_cells": construction_instance.get(&"occupied_cells", []), + &"local_inventory_id": StringName("facility.inventory:%s" % instance_id), + &"capacity": maxi(definition.capacity, 1), + &"queue_capacity": definition.queue_capacity, + &"base_service_minutes": definition.base_service_minutes, + &"base_quality": definition.base_quality, + &"base_price": definition.base_price, + &"staff_min": definition.staff_min, + &"staff_max": definition.staff_max, + &"created_at": int(construction_instance.get(&"completed_at", _clock.absolute_minute)), + }) + +func _validate_service_start(request: ServiceRequest, _facility: FacilityInstance, absolute_minute: int) -> Dictionary: + for guest_id: StringName in request.guest_ids: + var guest := guest_service.get_guest(guest_id) + if guest == null: + continue + if guest.state != GuestRuntime.State.QUEUING \ + or guest.queue_request_id != request.request_id \ + or guest.target_facility_id != request.facility_id: + return {&"ok": false, &"message": "Guest and Facility queue ownership do not match."} + if guest.transition_tick == absolute_minute \ + and guest.transitions_this_tick >= GuestService.MAX_TRANSITIONS_PER_TICK: + return {&"ok": false, &"message": "Guest transition budget is exhausted for this minute."} + for reservation_id: StringName in request.reserved_inventory_ids: + var reservation := inventory_service.get_reservation(reservation_id) + if reservation == null or not reservation.is_active() or reservation.is_expired(absolute_minute) \ + or inventory_service.is_stack_expired(reservation.stack_id, absolute_minute): + return {&"ok": false, &"message": "A required inventory reservation is unavailable or expired."} + var payer_account := _guest_wallet_account(request.guest_ids[0]) + if not economy_service.has_account(payer_account) \ + or economy_service.get_balance(payer_account) < request.quoted_price: + return {&"ok": false, &"message": "The guest cannot pay the quoted service price."} + return {&"ok": true} + +func _complete_service_transaction( + service: ServiceInstance, + request: ServiceRequest, + facility: FacilityInstance, + absolute_minute: int +) -> Variant: + for guest_id: StringName in service.guest_ids: + var guest := guest_service.get_guest(guest_id) + if guest == null: + continue + if guest.state != GuestRuntime.State.RECEIVING_SERVICE \ + or guest.current_service_id != service.service_id \ + or guest.target_facility_id != facility.instance_id: + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, + "Guest and Facility service ownership do not match." + ) + if guest.transition_tick == absolute_minute \ + and guest.transitions_this_tick >= GuestService.MAX_TRANSITIONS_PER_TICK: + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, + "Guest transition budget is exhausted for this minute." + ) + var inventory_before := inventory_service.create_snapshot() + var economy_before := economy_service.create_snapshot() + var state_before: Dictionary = _state.create_snapshot() + if not service.inventory_reservations.is_empty(): + var consumed := inventory_service.consume_reservations( + service.inventory_reservations, + StringName("service.consume.%s" % service.service_id), + absolute_minute + ) + if not bool(consumed.get(&"ok", false)): + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Reserved service inventory could not be consumed.", consumed) + + if service.price_snapshot > 0: + var payer_account := _guest_wallet_account(request.guest_ids[0]) + var revenue_account := _facility_revenue_account(facility.instance_id) + if not economy_service.has_account(payer_account) or not economy_service.has_account(revenue_account): + inventory_service.restore_snapshot(inventory_before) + return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "A service settlement account is missing.") + var sale_entries: Array = [ + LedgerEntry.new().configure( + &"", absolute_minute, revenue_account, payer_account, + service.price_snapshot, &"SERVICE_SALE", facility.instance_id + ), + LedgerEntry.new().configure( + &"", absolute_minute, PLAYER_CASH_ACCOUNT, revenue_account, + service.price_snapshot, &"SERVICE_CASH_SWEEP", facility.instance_id + ), + ] + var payment := economy_service.post( + sale_entries, + StringName("service.payment.%s" % service.service_id) + ) + if not payment.is_success(): + inventory_service.restore_snapshot(inventory_before) + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + return payment + + var definition := definition_registry.get_facility(facility.definition_id) + if definition != null: + for item_id: StringName in _sorted_name_keys(definition.inventory_outputs): + var output := inventory_service.add_stack( + facility.local_inventory_id, + item_id, + int(definition.inventory_outputs[item_id]), + service.quality_snapshot, + -1, + absolute_minute + ) + if not bool(output.get(&"ok", false)): + inventory_service.restore_snapshot(inventory_before) + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Facility outputs could not be committed.", output) + return true + +func _on_facility_state_changed(instance_id: StringName, old_state: int, new_state: int, reasons: Array[StringName]) -> void: + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), &"FACILITY_STATE_CHANGED", _runtime_event_minute(), + &"facility_service", [instance_id], + {&"old_state": old_state, &"state": new_state, &"reasons": reasons}, 0 + )) + +func _on_service_started(service: ServiceInstance) -> void: + var effective_minute := _runtime_event_minute() + for guest_id: StringName in service.guest_ids: + var guest := guest_service.get_guest(guest_id) + if guest == null: + continue + var wallet_account := _guest_wallet_account(guest_id) + if economy_service.has_account(wallet_account): + guest.wallet_gold = economy_service.get_balance(wallet_account) + var synchronized := guest_service.begin_service( + guest_id, service.facility_id, service.service_id, effective_minute + ) + if not bool(synchronized.get(&"ok", false)): + _publish_actor_event( + &"GUEST_SERVICE_SYNC_FAILED", &"simulation_runtime", + [guest_id, service.service_id], synchronized + ) + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), &"SERVICE_STARTED", effective_minute, + &"facility_service", [service.facility_id, service.service_id], service.create_snapshot(), 0 + )) + +func _on_service_interrupted(service: ServiceInstance, reason: StringName) -> void: + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), &"SERVICE_INTERRUPTED", _runtime_event_minute(), + &"facility_service", [service.facility_id, service.service_id], + {&"reason": reason, &"service": service.create_snapshot()}, 0 + )) + +func _on_service_completed(service: ServiceInstance) -> void: + var effective_minute := _runtime_event_minute() + for guest_id: StringName in service.guest_ids: + var guest := guest_service.get_guest(guest_id) + if guest == null: + continue + var wallet_account := _guest_wallet_account(guest_id) + if economy_service.has_account(wallet_account): + guest.wallet_gold = economy_service.get_balance(wallet_account) + var synchronized := guest_service.complete_service( + guest_id, + service.service_id, + effective_minute, + clampi(service.quality_snapshot, 0, 100), + _service_need_effects(service.facility_id) + ) + if not bool(synchronized.get(&"ok", false)): + _publish_actor_event( + &"GUEST_SERVICE_SYNC_FAILED", &"simulation_runtime", + [guest_id, service.service_id], synchronized + ) + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), &"SERVICE_COMPLETED", effective_minute, + &"facility_service", [service.facility_id, service.service_id], service.create_snapshot(), 0 + )) + +func _on_service_failed(service: ServiceInstance, reason: StringName) -> void: + var effective_minute := _runtime_event_minute() + for reservation_id: StringName in service.inventory_reservations: + inventory_service.release_reservation( + reservation_id, + StringName("service.failed.%s.%s" % [service.service_id, reservation_id]), + effective_minute + ) + for guest_id: StringName in service.guest_ids: + var guest := guest_service.get_guest(guest_id) + if guest != null and guest.current_service_id == service.service_id: + guest_service.interrupt_service( + guest_id, service.service_id, effective_minute + ) + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), &"SERVICE_FAILED", effective_minute, + &"facility_service", [service.facility_id, service.service_id], + {&"reason": reason, &"service": service.create_snapshot()}, 0 + )) + +func _on_guest_spawned(guest: GuestRuntime) -> void: + if _operations_flushing_guest_spawn_notifications.has(guest.guest_id): + return + var actor_id := _guest_path_actor_id(guest.guest_id) + var projected := path_service.actor_enter_cell( + actor_id, + guest.current_cell, + _runtime_event_minute() + ) + if projected: + _movement_credits[actor_id] = { + &"credit": 0, + &"updated_at": _runtime_event_minute(), + } + var payload := guest.create_snapshot() + payload[&"path_actor_id"] = actor_id + payload[&"path_projected"] = projected + payload[&"path_error"] = path_service.last_error if not projected else "" + _publish_actor_event(&"GUEST_SPAWNED", &"guest_service", [guest.guest_id], payload) + +func _on_guest_state_changed( + guest_id: StringName, + old_state: int, + new_state: int, + reason: StringName +) -> void: + _publish_actor_event(&"GUEST_STATE_CHANGED", &"guest_service", [guest_id], { + &"old_state": old_state, + &"state": new_state, + &"reason": reason, + }) + +func _on_guest_queue_abandoned( + guest_id: StringName, + facility_id: StringName, + request_id: StringName +) -> void: + _publish_actor_event( + &"GUEST_QUEUE_ABANDONED", &"guest_service", + [guest_id, facility_id, request_id], {} + ) + +func _on_guest_recovery_started( + guest_id: StringName, + recovery_kind: StringName, + target_cell: Vector3i +) -> void: + _publish_actor_event(&"GUEST_RECOVERY_STARTED", &"guest_service", [guest_id], { + &"recovery_kind": recovery_kind, + &"target_cell": target_cell, + }) + +func _on_guest_despawned(guest_id: StringName, persistent_record: Dictionary) -> void: + var actor_id := _guest_path_actor_id(guest_id) + var path_removed := path_service.despawn_actor(actor_id) + _movement_credits.erase(actor_id) + _synchronize_operations_population() + var payload := persistent_record.duplicate(true) + payload[&"path_actor_id"] = actor_id + payload[&"path_projection_removed"] = path_removed + _publish_actor_event(&"GUEST_DESPAWNED", &"guest_service", [guest_id], payload) + +func _on_staff_registered(staff: StaffRuntime) -> void: + _staffing_projection_enabled = true + var actor_id := _staff_path_actor_id(staff.staff_id) + var projected := path_service.actor_enter_cell( + actor_id, + staff.current_cell, + _runtime_event_minute() + ) + if projected: + _movement_credits[actor_id] = { + &"credit": 0, + &"updated_at": _runtime_event_minute(), + } + var payload := staff.create_snapshot() + payload[&"path_actor_id"] = actor_id + payload[&"path_projected"] = projected + payload[&"path_error"] = path_service.last_error if not projected else "" + _publish_actor_event(&"STAFF_REGISTERED", &"staff_service", [staff.staff_id], payload) + +func _on_staff_removed(staff_id: StringName, reason: StringName) -> void: + var actor_id := _staff_path_actor_id(staff_id) + var path_removed := path_service.despawn_actor(actor_id) + _movement_credits.erase(actor_id) + _publish_actor_event(&"STAFF_REMOVED", &"staff_service", [staff_id], { + &"reason": reason, + &"path_actor_id": actor_id, + &"path_projection_removed": path_removed, + }) + +func _on_shift_registered(shift: ShiftInstance) -> void: + _publish_actor_event( + &"STAFF_SHIFT_REGISTERED", &"staff_service", + [shift.staff_id, shift.shift_instance_id], shift.create_snapshot() + ) + +func _on_shift_state_changed(staff_id: StringName, shift_id: StringName, state: int) -> void: + _publish_actor_event( + &"STAFF_SHIFT_STATE_CHANGED", &"staff_service", + [staff_id, shift_id], {&"state": state} + ) + +func _on_staff_task_registered(task: StaffTask) -> void: + _publish_actor_event(&"STAFF_TASK_REGISTERED", &"staff_service", [task.task_id], task.create_snapshot()) + +func _on_staff_task_assigned(task_id: StringName, staff_id: StringName) -> void: + var task := staff_service.get_task(task_id) + if task != null and task.state == StaffTask.State.RESERVED: + task.begin_moving(_runtime_event_minute()) + _publish_actor_event(&"STAFF_TASK_ASSIGNED", &"staff_service", [task_id, staff_id], {}) + +func _on_staff_task_reopened(task_id: StringName, reason: StringName) -> void: + _publish_actor_event(&"STAFF_TASK_REOPENED", &"staff_service", [task_id], {&"reason": reason}) + +func _on_staff_task_completed(task_id: StringName, staff_id: StringName) -> void: + _publish_actor_event(&"STAFF_TASK_COMPLETED", &"staff_service", [task_id, staff_id], {}) + +func _on_authority_heat_changed( + old_value: int, + new_value: int, + source_id: StringName +) -> void: + var entity_ids: Array[StringName] = [] + if not source_id.is_empty(): + entity_ids.append(source_id) + _publish_actor_event(&"AUTHORITY_HEAT_CHANGED", &"authority_service", entity_ids, { + &"old_value": old_value, + &"new_value": new_value, + &"source_id": source_id, + }) + +func _on_authority_incident_created(incident: AuthorityIncident) -> void: + _publish_actor_event( + &"INCIDENT_CREATED", + &"authority_service", + [incident.incident_id], + incident.create_snapshot() + ) + +func _on_authority_incident_detector_added( + incident_id: StringName, + detector_id: StringName +) -> void: + var incident := authority_service.get_incident(incident_id) + _publish_actor_event( + &"INCIDENT_DETECTOR_ADDED", + &"authority_service", + [incident_id, detector_id], + {&"incident": incident.create_snapshot() if incident != null else {}} + ) + +func _on_authority_evidence_registered(evidence: AuthorityEvidence) -> void: + _publish_actor_event( + &"AUTHORITY_EVIDENCE_REGISTERED", + &"authority_service", + [evidence.evidence_id, evidence.incident_id, evidence.facility_id], + evidence.create_snapshot() + ) + +func _on_authority_evidence_discovered( + evidence: AuthorityEvidence, + method: StringName +) -> void: + _publish_actor_event( + &"AUTHORITY_EVIDENCE_DISCOVERED", + &"authority_service", + [evidence.evidence_id, evidence.incident_id, evidence.facility_id], + { + &"method": method, + &"evidence": evidence.create_snapshot(), + &"authority_evidence_points": authority_service.get_authority_state().evidence_points, + } + ) + +func _on_authority_inspection_scheduled(inspection: AuthorityInspection) -> void: + _publish_actor_event( + &"INSPECTION_SCHEDULED", + &"authority_service", + [inspection.inspection_id, inspection.inspector_id], + inspection.create_snapshot() + ) + +func _on_authority_inspection_state_changed( + inspection_id: StringName, + old_state: int, + new_state: int, + reason: StringName +) -> void: + var inspection := authority_service.get_inspection(inspection_id) + var entity_ids: Array[StringName] = [inspection_id] + if inspection != null: + entity_ids.append(inspection.inspector_id) + var payload := { + &"old_state": StringName(AuthorityInspection.State.keys()[old_state]), + &"new_state": StringName(AuthorityInspection.State.keys()[new_state]), + &"reason": reason, + } + _publish_actor_event( + &"INSPECTION_STATE_CHANGED", + &"authority_service", + entity_ids, + payload + ) + if new_state == AuthorityInspection.State.ARRIVING: + _publish_actor_event( + &"INSPECTION_STARTED", + &"authority_service", + entity_ids, + payload + ) + +func _on_authority_resolution_applied( + inspection_id: StringName, + resolution: Dictionary +) -> void: + _publish_actor_event( + &"INSPECTION_RESOLVED", + &"authority_service", + [inspection_id, StringName(resolution.get(&"resolution_id", ""))], + resolution.duplicate(true) + ) + +func _on_defense_raid_created(raid_id: StringName) -> void: + _publish_actor_event( + &"DEFENSE_RAID_CREATED", + &"defense_service", + [raid_id], + defense_service.get_raid(raid_id) + ) + +func _on_defense_raid_state_changed( + raid_id: StringName, + old_state: int, + new_state: int, + reason: StringName +) -> void: + _publish_actor_event( + &"DEFENSE_RAID_STATE_CHANGED", + &"defense_service", + [raid_id], + { + &"old_state": StringName(DefenseService.RaidState.keys()[old_state]), + &"new_state": StringName(DefenseService.RaidState.keys()[new_state]), + &"reason": reason, + } + ) + +func _on_defense_active_skill_applied( + raid_id: StringName, + action_id: StringName, + round_number: int +) -> void: + _publish_actor_event( + &"DEFENSE_SKILL_APPLIED", + &"defense_service", + [raid_id, action_id], + {&"round_number": round_number} + ) + +func _on_defense_round_resolved(raid_id: StringName, resolution: Dictionary) -> void: + var entity_ids: Array[StringName] = [raid_id] + var resolution_id := StringName(resolution.get(&"resolution_id", "")) + var wave_id := StringName(resolution.get(&"wave_id", "")) + if not resolution_id.is_empty(): + entity_ids.append(resolution_id) + if not wave_id.is_empty(): + entity_ids.append(wave_id) + _publish_actor_event( + &"DEFENSE_ROUND_RESOLVED", + &"defense_service", + entity_ids, + resolution.duplicate(true) + ) + +func _on_defense_raid_resolved(raid_id: StringName, outcome: Dictionary) -> void: + var entity_ids: Array[StringName] = [raid_id] + var resolution_id := StringName(outcome.get(&"resolution_id", "")) + if not resolution_id.is_empty(): + entity_ids.append(resolution_id) + _publish_actor_event( + &"DEFENSE_RAID_RESOLVED", + &"defense_service", + entity_ids, + outcome.duplicate(true) + ) + +func _publish_authority_evaluation(details: Dictionary) -> void: + var entity_ids: Array[StringName] = [] + var inspection_id := StringName(details.get(&"inspection_id", "")) + if not inspection_id.is_empty(): + entity_ids.append(inspection_id) + _publish_actor_event( + &"AUTHORITY_INSPECTION_EVALUATED", + &"authority_service", + entity_ids, + details.duplicate(true) + ) + +func _on_relationship_registered(state: RelationshipState) -> void: + _publish_actor_event( + &"RELATIONSHIP_REGISTERED", + &"relationship_service", + [state.actor_a_id, state.actor_b_id, state.relationship_id], + state.create_snapshot() + ) + +func _on_relationship_value_changed( + relationship_id: StringName, + metric_id: StringName, + old_value: int, + new_value: int +) -> void: + var state := relationship_service.get_relationship(relationship_id) + var actor_ids: Array[StringName] = [relationship_id] + if state != null: + actor_ids = [state.actor_a_id, state.actor_b_id, relationship_id] + _publish_actor_event(&"RELATIONSHIP_VALUE_CHANGED", &"relationship_service", actor_ids, { + &"metric_id": metric_id, + &"old_value": old_value, + &"new_value": new_value, + }) + +func _on_relationship_shield_changed( + relationship_id: StringName, + old_value: int, + new_value: int +) -> void: + var state := relationship_service.get_relationship(relationship_id) + var actor_ids: Array[StringName] = [relationship_id] + if state != null: + actor_ids = [state.actor_a_id, state.actor_b_id, relationship_id] + _publish_actor_event(&"RELATIONSHIP_SHIELD_CHANGED", &"relationship_service", actor_ids, { + &"old_value": old_value, + &"new_value": new_value, + }) + +func _on_relationship_route_lock_changed( + relationship_id: StringName, + old_lock: int, + new_lock: int, + route_id: StringName +) -> void: + var state := relationship_service.get_relationship(relationship_id) + var actor_ids: Array[StringName] = [relationship_id] + if state != null: + actor_ids = [state.actor_a_id, state.actor_b_id, relationship_id] + _publish_actor_event(&"RELATIONSHIP_ROUTE_LOCK_CHANGED", &"relationship_service", actor_ids, { + &"old_lock": old_lock, + &"new_lock": new_lock, + &"route_id": route_id, + }) + +func _on_event_enqueued(instance: EventInstance) -> void: + _publish_actor_event( + &"EVENT_ENQUEUED", &"event_service", instance.actor_ids, + instance.create_snapshot() + ) + +func _on_event_started(instance: EventInstance) -> void: + _publish_actor_event( + &"EVENT_STARTED", &"event_service", instance.actor_ids, + instance.create_snapshot() + ) + +func _on_event_finished(instance: EventInstance) -> void: + _resolve_relationship_threshold_event(instance) + _publish_actor_event( + &"EVENT_FINISHED", &"event_service", instance.actor_ids, + instance.create_snapshot() + ) + +func _on_event_cancelled(instance: EventInstance, reason: StringName) -> void: + _consume_relationship_candidate_from_event(instance) + if event_service.get_foreground_instance() == null and not event_service.pause_requested: + _clock.set_paused(false) + _publish_actor_event(&"EVENT_CANCELLED", &"event_service", instance.actor_ids, { + &"reason": reason, + &"event": instance.create_snapshot(), + }) + +func _on_event_choice_resolved( + instance_id: StringName, + choice_id: StringName, + result: Dictionary +) -> void: + var instance := event_service.get_instance(instance_id) + var actor_ids: Array[StringName] = [] + if instance != null: + actor_ids = instance.actor_ids.duplicate() + _publish_actor_event(&"EVENT_CHOICE_RESOLVED", &"event_service", actor_ids, { + &"event_instance_id": instance_id, + &"choice_id": choice_id, + &"result": result.duplicate(true), + }) + +func _resolve_relationship_threshold_event(instance: EventInstance) -> void: + if instance.source_event_id != &"relationship.threshold": + return + var raw_candidate: Variant = instance.context.get(&"relationship_candidate", null) + if not raw_candidate is Dictionary: + return + var candidate: Dictionary = raw_candidate + if instance.selected_choice_id == &"acknowledge": + var relationship_id := StringName(candidate.get(&"relationship_id", "")) + var metric_id := StringName(candidate.get(&"metric_id", "")) + var state := relationship_service.get_relationship(relationship_id) + if state != null and RelationshipState.METRIC_IDS.has(metric_id): + var threshold := int(candidate.get(&"threshold", state.get_metric(metric_id))) + var delta := maxi(threshold - state.get_metric(metric_id), 0) + if delta > 0: + relationship_service.apply_value_change( + relationship_id, + metric_id, + delta, + _runtime_event_minute(), + {&"important": true, &"allow_threshold_crossing": true} + ) + _consume_relationship_candidate_from_event(instance) + +func _consume_relationship_candidate_from_event(instance: EventInstance) -> void: + var raw_candidate: Variant = instance.context.get(&"relationship_candidate", null) + if not raw_candidate is Dictionary: + return + var candidate_id := StringName(raw_candidate.get(&"candidate_id", "")) + if not candidate_id.is_empty(): + relationship_service.consume_threshold_candidate(candidate_id) + +func _publish_actor_event( + event_type: StringName, + source_system: StringName, + entity_ids: Array[StringName], + payload: Dictionary +) -> void: + _publish_domain_event(SimulationDomainEvent.new( + id_generator.next_id(&"domain_event"), + event_type, + _runtime_event_minute(), + source_system, + entity_ids, + payload, + 0 + )) + +func _service_need_effects(facility_id: StringName) -> Dictionary: + var effects := { + &"hunger": 0, &"sleep": 0, &"hygiene": 0, + &"medical": 0, &"leisure": 0, &"intimacy": 0, + } + var facility := facility_service.get_instance(facility_id) + if facility == null: + return effects + var definition := definition_registry.get_facility(facility.definition_id) + if definition == null: + return effects + if &"dining" in definition.tags: + effects[&"hunger"] = -70 + effects[&"leisure"] = -10 + if &"lodging" in definition.tags: + effects[&"sleep"] = -80 + effects[&"hygiene"] = mini(int(effects[&"hygiene"]), -10) + if &"social" in definition.tags or &"leisure" in definition.tags: + effects[&"leisure"] = mini(int(effects[&"leisure"]), -40) + if &"hygiene" in definition.tags or &"bath" in definition.tags: + effects[&"hygiene"] = -75 + if &"medical" in definition.tags: + effects[&"medical"] = -85 + elif &"wellness" in definition.tags: + effects[&"medical"] = -20 + if &"adult_only" in definition.tags or &"restricted_service" in definition.tags: + effects[&"intimacy"] = -75 + return effects + +func _parse_world_cell(value: Variant) -> Variant: + if value is Vector3i: + return value + if value is Array and value.size() == 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + return null + +func _guest_state_index(value: Variant) -> int: + if value is int: + return value if value >= 0 and value < GuestRuntime.State.size() else -1 + var normalized := String(value).to_upper() + return GuestRuntime.State.keys().find(normalized) + +func _publish_snapshot(_absolute_minute: int) -> void: + latest_snapshot = create_snapshot() + latest_digest = SimulationDigest.compute(latest_snapshot) + snapshot_published.emit(latest_snapshot, latest_digest) diff --git a/autoload/simulation_runtime.gd.uid b/autoload/simulation_runtime.gd.uid new file mode 100644 index 0000000..a397652 --- /dev/null +++ b/autoload/simulation_runtime.gd.uid @@ -0,0 +1 @@ +uid://6bgumrdalh8i diff --git a/data/characters/mvp_guest_classes.json b/data/characters/mvp_guest_classes.json new file mode 100644 index 0000000..3404743 --- /dev/null +++ b/data/characters/mvp_guest_classes.json @@ -0,0 +1,142 @@ +{ + "schema_version": 1, + "content_type": "guest_classes", + "entries": [ + { + "id": "warrior", + "display_name": "戦士", + "base_patience": 65, + "risk_tolerance": 75, + "legal_sensitivity": 35, + "quality_sensitivity": 35, + "wealth_min": 300, + "wealth_max": 900, + "credit_limit_gold": 100, + "minimum_quality": 25, + "schedule_profile": "evening", + "tags": ["combatant"], + "preferences": {"food": 18, "security": 4, "tavern": 15}, + "need_rates": {"hunger": 10, "hygiene": 0, "intimacy": 0, "leisure": 5, "medical": 0, "sleep": 6}, + "group_size_weights": {"1": 35, "2": 45, "3": 20} + }, + { + "id": "mage", + "display_name": "魔法使い", + "base_patience": 45, + "risk_tolerance": 45, + "legal_sensitivity": 50, + "quality_sensitivity": 60, + "wealth_min": 450, + "wealth_max": 1200, + "credit_limit_gold": 200, + "minimum_quality": 40, + "schedule_profile": "day", + "tags": ["mana_sensitive", "noise_sensitive"], + "preferences": {"mana": 20, "quiet": 14, "tavern": -10}, + "need_rates": {"hunger": 7, "hygiene": 0, "intimacy": 0, "leisure": 4, "medical": 0, "sleep": 7}, + "group_size_weights": {"1": 60, "2": 35, "3": 5} + }, + { + "id": "rogue", + "display_name": "盗賊", + "base_patience": 50, + "risk_tolerance": 85, + "legal_sensitivity": 15, + "quality_sensitivity": 30, + "wealth_min": 250, + "wealth_max": 1000, + "credit_limit_gold": 80, + "minimum_quality": 20, + "schedule_profile": "night", + "tags": ["stealthy", "underworld"], + "preferences": {"security": -12, "tavern": 10, "underground": 24}, + "need_rates": {"hunger": 7, "hygiene": 0, "intimacy": 0, "leisure": 7, "medical": 0, "sleep": 5}, + "group_size_weights": {"1": 70, "2": 25, "3": 5} + }, + { + "id": "cleric", + "display_name": "聖職者", + "base_patience": 55, + "risk_tolerance": 20, + "legal_sensitivity": 90, + "quality_sensitivity": 55, + "wealth_min": 220, + "wealth_max": 750, + "credit_limit_gold": 50, + "minimum_quality": 45, + "schedule_profile": "day", + "tags": ["faith_aligned"], + "preferences": {"faith": 24, "quiet": 8, "underground": -25}, + "need_rates": {"hunger": 7, "hygiene": 0, "intimacy": 0, "leisure": 3, "medical": 0, "sleep": 6}, + "group_size_weights": {"1": 45, "2": 35, "3": 20} + }, + { + "id": "noble", + "display_name": "貴族", + "base_patience": 30, + "risk_tolerance": 35, + "legal_sensitivity": 65, + "quality_sensitivity": 95, + "wealth_min": 1800, + "wealth_max": 5000, + "credit_limit_gold": 1500, + "minimum_quality": 70, + "schedule_profile": "evening", + "tags": ["vip"], + "preferences": {"luxury": 28, "quiet": 10, "vip": 25}, + "need_rates": {"hunger": 6, "hygiene": 0, "intimacy": 0, "leisure": 6, "medical": 0, "sleep": 6}, + "group_size_weights": {"1": 20, "2": 50, "3": 30} + }, + { + "id": "merchant", + "display_name": "商人", + "base_patience": 45, + "risk_tolerance": 50, + "legal_sensitivity": 55, + "quality_sensitivity": 60, + "wealth_min": 900, + "wealth_max": 3200, + "credit_limit_gold": 900, + "minimum_quality": 50, + "schedule_profile": "day", + "tags": ["trade_focused"], + "preferences": {"food": 7, "trade": 25, "underground": 3}, + "need_rates": {"hunger": 8, "hygiene": 0, "intimacy": 0, "leisure": 4, "medical": 0, "sleep": 7}, + "group_size_weights": {"1": 35, "2": 45, "3": 20} + }, + { + "id": "outsider", + "display_name": "亜人・魔族客", + "base_patience": 55, + "risk_tolerance": 65, + "legal_sensitivity": 35, + "quality_sensitivity": 45, + "wealth_min": 350, + "wealth_max": 1400, + "credit_limit_gold": 250, + "minimum_quality": 35, + "schedule_profile": "night", + "tags": ["nocturnal"], + "preferences": {"nightlife": 18, "service": 7, "underground": 12}, + "need_rates": {"hunger": 7, "hygiene": 0, "intimacy": 0, "leisure": 7, "medical": 0, "sleep": 5}, + "group_size_weights": {"1": 40, "2": 40, "3": 20} + }, + { + "id": "diplomat", + "display_name": "外交官", + "base_patience": 35, + "risk_tolerance": 30, + "legal_sensitivity": 80, + "quality_sensitivity": 90, + "wealth_min": 1500, + "wealth_max": 4200, + "credit_limit_gold": 1800, + "minimum_quality": 75, + "schedule_profile": "day", + "tags": ["diplomatic", "vip"], + "preferences": {"luxury": 20, "quiet": 12, "security": 15, "vip": 30}, + "need_rates": {"hunger": 6, "hygiene": 0, "intimacy": 0, "leisure": 4, "medical": 0, "sleep": 7}, + "group_size_weights": {"1": 15, "2": 55, "3": 30} + } + ] +} diff --git a/data/characters/mvp_species.json b/data/characters/mvp_species.json new file mode 100644 index 0000000..79426b0 --- /dev/null +++ b/data/characters/mvp_species.json @@ -0,0 +1,50 @@ +{ + "schema_version": 1, + "content_type": "species", + "entries": [ + { + "id": "human", + "display_name": "人間", + "adult_age": 18, + "wealth_multiplier_bp": 10000, + "patience_bonus": 0, + "risk_bonus": 0, + "tags": ["human"], + "preference_modifiers": {}, + "need_rate_modifiers": {} + }, + { + "id": "elf", + "display_name": "エルフ", + "adult_age": 20, + "wealth_multiplier_bp": 11000, + "patience_bonus": 5, + "risk_bonus": -5, + "tags": ["elf", "mana_affinity", "noise_sensitive"], + "preference_modifiers": {"mana": 10, "quiet": 8}, + "need_rate_modifiers": {"sleep": -1} + }, + { + "id": "beastfolk", + "display_name": "獣人", + "adult_age": 18, + "wealth_multiplier_bp": 9500, + "patience_bonus": 10, + "risk_bonus": 10, + "tags": ["beastfolk"], + "preference_modifiers": {"food": 8, "security": 3}, + "need_rate_modifiers": {"hunger": 1} + }, + { + "id": "demonfolk", + "display_name": "魔族", + "adult_age": 20, + "wealth_multiplier_bp": 10500, + "patience_bonus": 0, + "risk_bonus": 15, + "tags": ["demonfolk", "night_affinity"], + "preference_modifiers": {"nightlife": 10, "underground": 8}, + "need_rate_modifiers": {"leisure": 1} + } + ] +} diff --git a/data/characters/mvp_staff_candidates.json b/data/characters/mvp_staff_candidates.json new file mode 100644 index 0000000..7010629 --- /dev/null +++ b/data/characters/mvp_staff_candidates.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "content_type": "staff_candidates", + "entries": [ + {"id":"staff_akane","display_name":"アカネ","age":24,"adult_verified":true,"role_id":"receptionist","daily_wage":120,"signing_cost":180,"preferred_shift":"day","traits":["friendly"],"skills":{"combat":15,"operation":42,"service":68,"underworld":5},"permitted_task_tags":["reception","service"]}, + {"id":"staff_bardo","display_name":"バルド","age":38,"adult_verified":true,"role_id":"receptionist","daily_wage":135,"signing_cost":220,"preferred_shift":"night","traits":["calm"],"skills":{"combat":22,"operation":55,"service":62,"underworld":18},"permitted_task_tags":["reception","service"]}, + {"id":"staff_chika","display_name":"チカ","age":27,"adult_verified":true,"role_id":"cook","daily_wage":145,"signing_cost":250,"preferred_shift":"day","traits":["cleanly"],"skills":{"combat":8,"operation":76,"service":32,"underworld":4},"permitted_task_tags":["cooking","supply"]}, + {"id":"staff_doran","display_name":"ドラン","age":44,"adult_verified":true,"role_id":"cook","daily_wage":160,"signing_cost":300,"preferred_shift":"night","traits":["mentor"],"skills":{"combat":20,"operation":82,"service":28,"underworld":12},"permitted_task_tags":["cooking","supply"]}, + {"id":"staff_eri","display_name":"エリ","age":22,"adult_verified":true,"role_id":"cleaner","daily_wage":90,"signing_cost":100,"preferred_shift":"day","traits":["cleanly"],"skills":{"combat":6,"operation":61,"service":35,"underworld":3},"permitted_task_tags":["cleaning","maintenance"]}, + {"id":"staff_fugo","display_name":"フゴ","age":31,"adult_verified":true,"role_id":"cleaner","daily_wage":100,"signing_cost":130,"preferred_shift":"night","traits":["night_owl"],"skills":{"combat":18,"operation":65,"service":22,"underworld":20},"permitted_task_tags":["cleaning","maintenance"]}, + {"id":"staff_gina","display_name":"ジーナ","age":29,"adult_verified":true,"role_id":"porter","daily_wage":110,"signing_cost":150,"preferred_shift":"day","traits":["careful"],"skills":{"combat":28,"operation":70,"service":20,"underworld":10},"permitted_task_tags":["logistics","rescue","supply"]}, + {"id":"staff_haru","display_name":"ハル","age":21,"adult_verified":true,"role_id":"porter","daily_wage":95,"signing_cost":100,"preferred_shift":"night","traits":["night_owl"],"skills":{"combat":24,"operation":58,"service":25,"underworld":28},"permitted_task_tags":["logistics","rescue","supply"]}, + {"id":"staff_iris","display_name":"イリス","age":26,"adult_verified":true,"role_id":"attendant","daily_wage":130,"signing_cost":210,"preferred_shift":"day","traits":["friendly"],"skills":{"combat":10,"operation":38,"service":74,"underworld":8},"permitted_task_tags":["bath","room_service","service"]}, + {"id":"staff_juno","display_name":"ユノ","age":33,"adult_verified":true,"role_id":"attendant","daily_wage":140,"signing_cost":240,"preferred_shift":"night","traits":["calm"],"skills":{"combat":12,"operation":45,"service":78,"underworld":22},"permitted_task_tags":["bath","room_service","service"]}, + {"id":"staff_klaus","display_name":"クラウス","age":36,"adult_verified":true,"role_id":"guard","daily_wage":155,"signing_cost":280,"preferred_shift":"day","traits":["short_tempered"],"skills":{"combat":82,"operation":34,"service":18,"underworld":16},"permitted_task_tags":["defense","escort","security"]}, + {"id":"staff_lina","display_name":"リナ","age":30,"adult_verified":true,"role_id":"guard","daily_wage":150,"signing_cost":260,"preferred_shift":"night","traits":["night_owl"],"skills":{"combat":77,"operation":38,"service":25,"underworld":30},"permitted_task_tags":["defense","escort","security"]}, + {"id":"staff_marc","display_name":"マルク","age":41,"adult_verified":true,"role_id":"guard","daily_wage":170,"signing_cost":330,"preferred_shift":"any","traits":["mentor"],"skills":{"combat":88,"operation":46,"service":20,"underworld":25},"permitted_task_tags":["defense","escort","security"]}, + {"id":"staff_nadia","display_name":"ナディア","age":28,"adult_verified":true,"role_id":"bartender","daily_wage":145,"signing_cost":240,"preferred_shift":"night","traits":["merchant_minded"],"skills":{"combat":20,"operation":48,"service":76,"underworld":35},"permitted_task_tags":["bar","service","supply"]}, + {"id":"staff_otto","display_name":"オットー","age":35,"adult_verified":true,"role_id":"bartender","daily_wage":150,"signing_cost":270,"preferred_shift":"night","traits":["friendly"],"skills":{"combat":26,"operation":52,"service":79,"underworld":32},"permitted_task_tags":["bar","service","supply"]}, + {"id":"staff_petra","display_name":"ペトラ","age":25,"adult_verified":true,"role_id":"masseur","daily_wage":145,"signing_cost":260,"preferred_shift":"day","traits":["careful"],"skills":{"combat":8,"operation":35,"service":81,"underworld":18},"permitted_task_tags":["adult_service","massage","service"]}, + {"id":"staff_quinn","display_name":"クイン","age":32,"adult_verified":true,"role_id":"masseur","daily_wage":160,"signing_cost":320,"preferred_shift":"night","traits":["underworld_savvy"],"skills":{"combat":18,"operation":40,"service":84,"underworld":55},"permitted_task_tags":["adult_service","massage","service"]}, + {"id":"staff_rika","display_name":"リカ","age":23,"adult_verified":true,"role_id":"clerk","daily_wage":105,"signing_cost":140,"preferred_shift":"day","traits":["merchant_minded"],"skills":{"combat":5,"operation":67,"service":58,"underworld":7},"permitted_task_tags":["accounting","reception","trade"]}, + {"id":"staff_sven","display_name":"スヴェン","age":39,"adult_verified":true,"role_id":"maintenance","daily_wage":125,"signing_cost":190,"preferred_shift":"any","traits":["careful"],"skills":{"combat":32,"operation":74,"service":16,"underworld":15},"permitted_task_tags":["construction","maintenance","repair"]}, + {"id":"staff_tessa","display_name":"テッサ","age":34,"adult_verified":true,"role_id":"medic","daily_wage":165,"signing_cost":340,"preferred_shift":"any","traits":["calm"],"skills":{"combat":14,"operation":69,"service":63,"underworld":12},"permitted_task_tags":["medical","rescue","service"]} + ] +} diff --git a/data/characters/mvp_unique_characters.json b/data/characters/mvp_unique_characters.json new file mode 100644 index 0000000..e67998b --- /dev/null +++ b/data/characters/mvp_unique_characters.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "content_type": "unique_characters", + "entries": [ + { + "id": "heroine_anri", + "display_name": "アンリ", + "character_kind": "heroine", + "age": 25, + "adult_verified": true, + "role_id": "innkeeper", + "counterpart_id": "rival_rai", + "preferred_shift": "day", + "traits": ["friendly", "unique", "unsellable"], + "skills": {"combat": 24, "operation": 66, "service": 82, "underworld": 18}, + "mechanical_effects": {"assigned_floor_patience_loss_multiplier_bp": 9000} + }, + { + "id": "rival_rai", + "display_name": "ライ", + "character_kind": "rival", + "age": 29, + "adult_verified": true, + "role_id": "influencer", + "counterpart_id": "heroine_anri", + "preferred_shift": "night", + "traits": ["diplomatic", "unique", "unsellable"], + "skills": {"combat": 45, "operation": 38, "service": 72, "underworld": 68}, + "mechanical_effects": {"base_shield_attack": 8, "night_shield_attack_bonus": 4} + } + ] +} diff --git a/data/facilities/adult_private_room.tres b/data/facilities/adult_private_room.tres new file mode 100644 index 0000000..eadaa55 --- /dev/null +++ b/data/facilities/adult_private_room.tres @@ -0,0 +1,52 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +definition_schema_version = 2 +id = &"adult_private_room" +display_name = "成人向け個室" +description = "年齢確認済みの成人だけが利用・担当できる予約制の限定サービス区画。" +category = &"restricted_service" +service_types = Array[StringName]([&"private_adult_service"]) +default_service_type = &"private_adult_service" +service_profiles = {&"private_adult_service": {&"duration_minutes": 90, &"base_quality": 60, &"base_price": 250, &"capacity_units": 1, &"need_effects": {&"intimacy": -70, &"leisure": -20}}} +zone = 3 +layer = 0 +footprint = Vector2i(2, 1) +guest_access_offsets = Array[Vector2i]([Vector2i(0, 0)]) +staff_access_offsets = Array[Vector2i]([Vector2i(1, 0)]) +required_connection_tags = Array[StringName]([&"guest_access"]) +preferred_adjacency_tags = Array[StringName]([&"concealment", &"security"]) +forbidden_adjacency_tags = Array[StringName]([&"entrance"]) +construction_cost = {&"gold": 1500, &"materials": 14} +build_minutes = 240 +weight_per_cell = 2 +upgrade_costs = Array[Dictionary]([{&"gold": 750, &"materials": 8}, {&"gold": 1500, &"materials": 14}]) +maintenance_mana = 3 +power_priority = 45 +staff_min = 1 +staff_max = 1 +required_roles = Array[StringName]([&"service_attendant"]) +primary_skill = &"service" +capacity = 2 +queue_capacity = 4 +base_service_minutes = 90 +base_quality = 60 +base_price = 250 +opening_schedule = {&"morning": false, &"day": false, &"night": true} +policy_schema = {&"age_verification": {&"type": &"bool", &"default": true}, &"price_mode": {&"type": &"enum", &"default": &"standard"}, &"reservation_only": {&"type": &"bool", &"default": true}} +cleanliness_decay_per_service = 4 +noise_per_service = 2 +concealment_effect = 8 +heat_per_service = 3 +incident_weights = {&"audit_discovery": 4, &"privacy_breach": 3, &"underage_access": 100} +allowed_guest_tags = Array[StringName]([&"adult_verified"]) +forbidden_guest_tags = Array[StringName]([&"underage"]) +adult_content = true +content_replacement_id = &"massage_room" +unlock_rank = 3 +research_requirements = Array[StringName]([&"discreet_hospitality"]) +statistics_keys = Array[StringName]([&"adult_verification_failures", &"average_wait_minutes", &"revenue", &"services_completed"]) +tags = Array[StringName]([&"adult_only", &"restricted_service", &"service", &"underground"]) diff --git a/data/facilities/cafeteria.tres b/data/facilities/cafeteria.tres new file mode 100644 index 0000000..195f4b0 --- /dev/null +++ b/data/facilities/cafeteria.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"cafeteria" +display_name = "大衆食堂" +description = "低価格で多くの客の食欲を満たす。裏層の厨房から料理を受け取る。" +zone = 0 +layer = 0 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 800, &"materials": 12} +build_minutes = 180 +weight_per_cell = 2 +maintenance_mana = 2 +staff_min = 2 +staff_max = 4 +required_roles = Array[StringName]([&"cook", &"server"]) +primary_skill = &"cooking" +capacity = 12 +queue_capacity = 24 +base_service_minutes = 15 +base_quality = 55 +base_price = 35 +inventory_inputs = {&"ingredient/basic_meal": 1} +tags = Array[StringName]([&"dining", &"social"]) diff --git a/data/facilities/expansion/black_market.tres b/data/facilities/expansion/black_market.tres new file mode 100644 index 0000000..d08a7b9 --- /dev/null +++ b/data/facilities/expansion/black_market.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"black_market" +display_name = "闇市取引所" +description = "希少品を高値で扱う地下取引施設。収益と引き換えに警戒と監査リスクを持つ。" +zone = 3 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 1600, &"materials": 12} +build_minutes = 270 +weight_per_cell = 2 +maintenance_mana = 2 +power_priority = 40 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"clerk"]) +primary_skill = &"underworld" +capacity = 8 +queue_capacity = 12 +base_service_minutes = 20 +base_quality = 60 +base_price = 50 +tags = Array[StringName]([&"authority_risk", &"expansion", &"restricted_service", &"trade", &"underground", &"underworld"]) diff --git a/data/facilities/expansion/clinic.tres b/data/facilities/expansion/clinic.tres new file mode 100644 index 0000000..8bf1b50 --- /dev/null +++ b/data/facilities/expansion/clinic.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"clinic" +display_name = "診療所" +description = "客と従業員の軽傷や体調不良を治療する、一般利用可能な医療施設。" +zone = 0 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 1000, &"materials": 12} +build_minutes = 180 +weight_per_cell = 2 +maintenance_mana = 3 +power_priority = 90 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"medic"]) +primary_skill = &"service" +capacity = 4 +queue_capacity = 8 +base_service_minutes = 45 +base_quality = 65 +base_price = 80 +inventory_inputs = {&"medical/basic_supply": 1} +tags = Array[StringName]([&"expansion", &"medical", &"recovery", &"service"]) diff --git a/data/facilities/expansion/cold_storage.tres b/data/facilities/expansion/cold_storage.tres new file mode 100644 index 0000000..8217fca --- /dev/null +++ b/data/facilities/expansion/cold_storage.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"cold_storage" +display_name = "魔法冷蔵庫" +description = "食材の劣化を抑え、厨房と酒場の在庫損失を減らすBACK保管施設。" +zone = 0 +layer = 1 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 900, &"materials": 12} +build_minutes = 180 +weight_per_cell = 2 +maintenance_mana = 4 +power_priority = 75 +staff_min = 0 +staff_max = 1 +capacity = 160 +queue_capacity = 4 +base_service_minutes = 10 +base_quality = 65 +base_price = 0 +tags = Array[StringName]([&"back_of_house", &"expansion", &"food", &"preservation", &"storage"]) diff --git a/data/facilities/expansion/general_store.tres b/data/facilities/expansion/general_store.tres new file mode 100644 index 0000000..908e018 --- /dev/null +++ b/data/facilities/expansion/general_store.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"general_store" +display_name = "売店" +description = "旅用品、薬、土産を販売し、宿泊以外の客単価を高める一般向け店舗。" +zone = 2 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 750, &"materials": 10} +build_minutes = 150 +weight_per_cell = 1 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"clerk"]) +primary_skill = &"operation" +capacity = 6 +queue_capacity = 10 +base_service_minutes = 10 +base_quality = 55 +base_price = 25 +tags = Array[StringName]([&"expansion", &"retail", &"service", &"trade"]) diff --git a/data/facilities/expansion/guild_counter.tres b/data/facilities/expansion/guild_counter.tres new file mode 100644 index 0000000..615254d --- /dev/null +++ b/data/facilities/expansion/guild_counter.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"guild_counter" +display_name = "冒険者窓口" +description = "依頼、探索情報、護衛紹介を扱い、冒険者客と探索Systemを接続する窓口。" +zone = 2 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 800, &"materials": 8} +build_minutes = 150 +weight_per_cell = 1 +maintenance_mana = 1 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"clerk"]) +primary_skill = &"operation" +capacity = 8 +queue_capacity = 16 +base_service_minutes = 15 +base_quality = 60 +base_price = 20 +tags = Array[StringName]([&"adventure", &"entrance", &"expansion", &"information", &"service"]) diff --git a/data/facilities/expansion/laundry.tres b/data/facilities/expansion/laundry.tres new file mode 100644 index 0000000..9fc4b5c --- /dev/null +++ b/data/facilities/expansion/laundry.tres @@ -0,0 +1,29 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"laundry" +display_name = "洗濯室" +description = "客室と浴場から回収した布類を洗浄し、清潔なリネンを供給するBACK施設。" +zone = 0 +layer = 1 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 700, &"materials": 10} +build_minutes = 150 +weight_per_cell = 2 +maintenance_mana = 3 +power_priority = 65 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"cleaner"]) +primary_skill = &"operation" +capacity = 20 +queue_capacity = 8 +base_service_minutes = 20 +base_quality = 58 +base_price = 0 +inventory_inputs = {&"linen/used": 4} +inventory_outputs = {&"linen/clean": 4} +tags = Array[StringName]([&"back_of_house", &"cleaning", &"expansion", &"production"]) diff --git a/data/facilities/expansion/maintenance_workshop.tres b/data/facilities/expansion/maintenance_workshop.tres new file mode 100644 index 0000000..7691a85 --- /dev/null +++ b/data/facilities/expansion/maintenance_workshop.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"maintenance_workshop" +display_name = "整備工房" +description = "設備修理、建築補助、交換部品の準備を行うBACK作業施設。" +zone = 0 +layer = 1 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 1000, &"materials": 14} +build_minutes = 210 +weight_per_cell = 2 +maintenance_mana = 2 +power_priority = 85 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"maintenance"]) +primary_skill = &"operation" +capacity = 6 +queue_capacity = 8 +base_service_minutes = 30 +base_quality = 62 +base_price = 0 +inventory_inputs = {&"material/repair_part": 1} +tags = Array[StringName]([&"back_of_house", &"construction", &"expansion", &"maintenance", &"repair"]) diff --git a/data/facilities/expansion/mana_control_room.tres b/data/facilities/expansion/mana_control_room.tres new file mode 100644 index 0000000..5373ba7 --- /dev/null +++ b/data/facilities/expansion/mana_control_room.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"mana_control_room" +display_name = "魔力制御室" +description = "地下の魔力供給、蓄積、優先配電を管理する高重要度のBACK設備。" +zone = 3 +layer = 1 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 1800, &"materials": 18} +build_minutes = 300 +weight_per_cell = 3 +maintenance_mana = 1 +power_priority = 100 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"maintenance"]) +primary_skill = &"operation" +capacity = 40 +queue_capacity = 2 +base_service_minutes = 1 +base_quality = 65 +base_price = 0 +tags = Array[StringName]([&"back_of_house", &"expansion", &"mana_infrastructure", &"staff_only", &"underground"]) diff --git a/data/facilities/expansion/premium_room.tres b/data/facilities/expansion/premium_room.tres new file mode 100644 index 0000000..e154af1 --- /dev/null +++ b/data/facilities/expansion/premium_room.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"premium_room" +display_name = "上級客室" +description = "富裕客と長期滞在客向けの静かな地上客室。一般客室より高い品質と料金を持つ。" +zone = 1 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 900, &"materials": 10} +build_minutes = 150 +weight_per_cell = 2 +maintenance_mana = 1 +staff_min = 0 +staff_max = 0 +capacity = 2 +queue_capacity = 2 +base_service_minutes = 720 +base_quality = 70 +base_price = 220 +tags = Array[StringName]([&"expansion", &"lodging", &"premium", &"quiet"]) diff --git a/data/facilities/expansion/private_bath.tres b/data/facilities/expansion/private_bath.tres new file mode 100644 index 0000000..bbca8bd --- /dev/null +++ b/data/facilities/expansion/private_bath.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"private_bath" +display_name = "地下貸切浴室" +description = "少人数で利用できる予約制の貸切浴室。静けさと高品質を重視する。" +zone = 3 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 1100, &"materials": 14} +build_minutes = 210 +weight_per_cell = 2 +maintenance_mana = 4 +power_priority = 70 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"attendant"]) +primary_skill = &"service" +capacity = 4 +queue_capacity = 8 +base_service_minutes = 60 +base_quality = 70 +base_price = 80 +tags = Array[StringName]([&"expansion", &"hygiene", &"premium", &"service", &"underground", &"wellness"]) diff --git a/data/facilities/expansion/secret_tavern.tres b/data/facilities/expansion/secret_tavern.tres new file mode 100644 index 0000000..5b5deb7 --- /dev/null +++ b/data/facilities/expansion/secret_tavern.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"secret_tavern" +display_name = "地下酒場" +description = "夜間客と裏社会に通じた客が集う、地下FRONTの高単価な交流施設。" +zone = 3 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 1300, &"materials": 14} +build_minutes = 240 +weight_per_cell = 2 +maintenance_mana = 3 +power_priority = 60 +staff_min = 2 +staff_max = 4 +required_roles = Array[StringName]([&"bartender", &"attendant"]) +primary_skill = &"service" +capacity = 10 +queue_capacity = 18 +base_service_minutes = 60 +base_quality = 62 +base_price = 90 +inventory_inputs = {&"beverage/rare": 1} +tags = Array[StringName]([&"expansion", &"leisure", &"night_service", &"social", &"underground", &"underworld"]) diff --git a/data/facilities/expansion/staff_dormitory.tres b/data/facilities/expansion/staff_dormitory.tres new file mode 100644 index 0000000..975c00b --- /dev/null +++ b/data/facilities/expansion/staff_dormitory.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"staff_dormitory" +display_name = "従業員宿舎" +description = "勤務の合間に従業員が睡眠と長時間休息を取るBACK居住区画。" +zone = 1 +layer = 1 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 650, &"materials": 8} +build_minutes = 150 +weight_per_cell = 1 +maintenance_mana = 1 +power_priority = 35 +staff_min = 0 +staff_max = 0 +capacity = 12 +queue_capacity = 0 +base_service_minutes = 360 +base_quality = 58 +base_price = 0 +tags = Array[StringName]([&"back_of_house", &"expansion", &"recovery", &"staff_only", &"staff_support"]) diff --git a/data/facilities/expansion/tea_lounge.tres b/data/facilities/expansion/tea_lounge.tres new file mode 100644 index 0000000..a21a6a9 --- /dev/null +++ b/data/facilities/expansion/tea_lounge.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"tea_lounge" +display_name = "喫茶ラウンジ" +description = "昼の休息と軽い交流を提供する、一般客向けの落ち着いた接客施設。" +zone = 1 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 650, &"materials": 8} +build_minutes = 120 +weight_per_cell = 1 +maintenance_mana = 1 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"attendant"]) +primary_skill = &"service" +capacity = 8 +queue_capacity = 12 +base_service_minutes = 30 +base_quality = 60 +base_price = 55 +inventory_inputs = {&"beverage/tea": 1} +tags = Array[StringName]([&"day_service", &"dining", &"expansion", &"leisure", &"social"]) diff --git a/data/facilities/expansion/underground_guest_room.tres b/data/facilities/expansion/underground_guest_room.tres new file mode 100644 index 0000000..4aedf48 --- /dev/null +++ b/data/facilities/expansion/underground_guest_room.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"underground_guest_room" +display_name = "地下客室" +description = "静けさと遮光を好む客や夜行性種族向けの、地下FRONT宿泊区画。" +zone = 3 +layer = 0 +footprint = Vector2i(2, 1) +construction_cost = {&"gold": 600, &"materials": 7} +build_minutes = 90 +weight_per_cell = 1 +maintenance_mana = 1 +staff_min = 0 +staff_max = 0 +capacity = 2 +queue_capacity = 4 +base_service_minutes = 720 +base_quality = 58 +base_price = 140 +tags = Array[StringName]([&"expansion", &"lodging", &"night_service", &"quiet", &"underground"]) diff --git a/data/facilities/expansion/vip_private_salon.tres b/data/facilities/expansion/vip_private_salon.tres new file mode 100644 index 0000000..983c3db --- /dev/null +++ b/data/facilities/expansion/vip_private_salon.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"vip_private_salon" +display_name = "地下VIPサロン" +description = "年齢確認済みの富裕な成人客へ、夜間の限定サービスを提供する高級区画。" +zone = 3 +layer = 0 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 2200, &"materials": 20} +build_minutes = 360 +weight_per_cell = 3 +maintenance_mana = 5 +power_priority = 50 +staff_min = 2 +staff_max = 4 +required_roles = Array[StringName]([&"attendant", &"masseur"]) +primary_skill = &"service" +capacity = 6 +queue_capacity = 8 +base_service_minutes = 120 +base_quality = 78 +base_price = 400 +tags = Array[StringName]([&"adult_only", &"expansion", &"night_service", &"premium", &"restricted_service", &"underground"]) diff --git a/data/facilities/expansion/waste_processing.tres b/data/facilities/expansion/waste_processing.tres new file mode 100644 index 0000000..e24d7c0 --- /dev/null +++ b/data/facilities/expansion/waste_processing.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"waste_processing" +display_name = "廃棄物処理室" +description = "厨房、浴場、客室から出る廃棄物を処理し、汚れと事故リスクを抑える地下BACK施設。" +zone = 3 +layer = 1 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 800, &"materials": 10} +build_minutes = 180 +weight_per_cell = 2 +maintenance_mana = 2 +power_priority = 55 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"cleaner"]) +primary_skill = &"operation" +capacity = 20 +queue_capacity = 8 +base_service_minutes = 15 +base_quality = 55 +base_price = 0 +tags = Array[StringName]([&"back_of_house", &"cleaning", &"expansion", &"risk_control", &"underground"]) diff --git a/data/facilities/guest_corridor.tres b/data/facilities/guest_corridor.tres new file mode 100644 index 0000000..f58a566 --- /dev/null +++ b/data/facilities/guest_corridor.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"guest_corridor" +display_name = "客用通路" +description = "客と接客中の従業員が利用する、表層の移動用サブスロット。" +zone = 0 +layer = 0 +footprint = Vector2i(1, 1) +construction_cost = {&"gold": 20, &"materials": 0} +build_minutes = 10 +weight_per_cell = 1 +pass_through = true +maintenance_mana = 0 +power_priority = 10 +staff_min = 0 +staff_max = 0 +capacity = 3 +queue_capacity = 0 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"corridor", &"guest_access", &"movement", &"route_subslot", &"schema_facility_proxy"]) diff --git a/data/facilities/guest_elevator.tres b/data/facilities/guest_elevator.tres new file mode 100644 index 0000000..38417e2 --- /dev/null +++ b/data/facilities/guest_elevator.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"guest_elevator" +display_name = "客用昇降機" +description = "客と接客中の従業員を上下階へ運ぶ、低速の表層用昇降設備。" +zone = 0 +layer = 0 +footprint = Vector2i(2, 2) +construction_cost = {&"gold": 1500, &"materials": 15} +build_minutes = 300 +weight_per_cell = 3 +pass_through = true +maintenance_mana = 5 +power_priority = 85 +staff_min = 0 +staff_max = 0 +capacity = 4 +queue_capacity = 8 +base_service_minutes = 10 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"guest_access", &"movement", &"schema_facility_proxy", &"vertical_connector"]) diff --git a/data/facilities/kitchen.tres b/data/facilities/kitchen.tres new file mode 100644 index 0000000..bde04dc --- /dev/null +++ b/data/facilities/kitchen.tres @@ -0,0 +1,46 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +definition_schema_version = 2 +id = &"kitchen" +display_name = "厨房" +description = "食材を調理し、食堂や酒場へ料理を供給する裏方施設。" +category = &"production" +service_types = Array[StringName]([&"meal_batch"]) +default_service_type = &"meal_batch" +service_profiles = {&"meal_batch": {&"duration_minutes": 15, &"base_quality": 55, &"base_price": 0, &"capacity_units": 4, &"need_effects": {&"hunger": 0}}} +zone = 0 +layer = 1 +footprint = Vector2i(4, 2) +staff_access_offsets = Array[Vector2i]([Vector2i(0, 1), Vector2i(3, 1)]) +required_connection_tags = Array[StringName]([&"staff_only"]) +preferred_adjacency_tags = Array[StringName]([&"dining", &"storage"]) +forbidden_adjacency_tags = Array[StringName]([&"quiet"]) +construction_cost = {&"gold": 700, &"materials": 10} +build_minutes = 180 +weight_per_cell = 2 +upgrade_costs = Array[Dictionary]([{&"gold": 350, &"materials": 6}, {&"gold": 700, &"materials": 12}]) +maintenance_mana = 2 +power_priority = 80 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"cook"]) +primary_skill = &"cooking" +capacity = 20 +queue_capacity = 4 +base_service_minutes = 15 +base_quality = 55 +base_price = 0 +inventory_inputs = {&"ingredient/raw_food": 4} +inventory_outputs = {&"ingredient/basic_meal": 4} +opening_schedule = {&"morning": true, &"day": true, &"night": true} +policy_schema = {&"batch_priority": {&"type": &"enum", &"default": &"fifo"}, &"quality_target": {&"type": &"int", &"default": 55}} +cleanliness_decay_per_service = 3 +noise_per_service = 4 +incident_weights = {&"contamination": 2, &"fire": 2} +unlock_rank = 1 +statistics_keys = Array[StringName]([&"batches_completed", &"downtime_minutes", &"output_quantity", &"spoilage_quantity"]) +tags = Array[StringName]([&"back_of_house", &"food", &"production"]) diff --git a/data/facilities/massage_room.tres b/data/facilities/massage_room.tres new file mode 100644 index 0000000..7cd2c38 --- /dev/null +++ b/data/facilities/massage_room.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"massage_room" +display_name = "マッサージ室" +description = "予約客へ施術を提供し、疲労とストレスの回復を支援する個室。" +zone = 0 +layer = 0 +footprint = Vector2i(2, 1) +construction_cost = {&"gold": 800, &"materials": 8} +build_minutes = 120 +weight_per_cell = 1 +maintenance_mana = 2 +power_priority = 50 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"therapist"]) +primary_skill = &"service" +capacity = 2 +queue_capacity = 6 +base_service_minutes = 60 +base_quality = 60 +base_price = 100 +tags = Array[StringName]([&"leisure", &"service", &"wellness"]) diff --git a/data/facilities/public_bath.tres b/data/facilities/public_bath.tres new file mode 100644 index 0000000..217a040 --- /dev/null +++ b/data/facilities/public_bath.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"public_bath" +display_name = "大浴場" +description = "多人数を受け入れ、清潔と休息の欲求を満たす共同浴場。" +zone = 0 +layer = 0 +footprint = Vector2i(6, 2) +construction_cost = {&"gold": 1200, &"materials": 16} +build_minutes = 240 +weight_per_cell = 2 +maintenance_mana = 4 +power_priority = 65 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"bath_attendant"]) +primary_skill = &"service" +capacity = 12 +queue_capacity = 24 +base_service_minutes = 60 +base_quality = 55 +base_price = 20 +tags = Array[StringName]([&"hygiene", &"service", &"wellness"]) diff --git a/data/facilities/reception.tres b/data/facilities/reception.tres new file mode 100644 index 0000000..a7c1dd1 --- /dev/null +++ b/data/facilities/reception.tres @@ -0,0 +1,45 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +definition_schema_version = 2 +id = &"reception" +display_name = "受付" +description = "全ての客のチェックインと精算を処理する、境界階の中核施設。" +category = &"guest_service" +service_types = Array[StringName]([&"check_in", &"check_out"]) +default_service_type = &"check_in" +service_profiles = {&"check_in": {&"duration_minutes": 8, &"base_quality": 60, &"base_price": 20, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}, &"check_out": {&"duration_minutes": 6, &"base_quality": 60, &"base_price": 0, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}} +zone = 2 +layer = 0 +footprint = Vector2i(4, 2) +guest_access_offsets = Array[Vector2i]([Vector2i(0, 1), Vector2i(1, 1)]) +staff_access_offsets = Array[Vector2i]([Vector2i(3, 1)]) +required_connection_tags = Array[StringName]([&"guest_access"]) +preferred_adjacency_tags = Array[StringName]([&"entrance", &"lodging"]) +construction_cost = {&"gold": 500, &"materials": 8} +build_minutes = 120 +weight_per_cell = 2 +upgrade_costs = Array[Dictionary]([{&"gold": 250, &"materials": 4}, {&"gold": 500, &"materials": 8}]) +maintenance_mana = 1 +power_priority = 100 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"receptionist"]) +capacity = 8 +queue_capacity = 16 +base_service_minutes = 8 +base_quality = 60 +base_price = 20 +opening_schedule = {&"morning": true, &"day": true, &"night": true} +policy_schema = {&"price_mode": {&"type": &"enum", &"default": &"standard"}, &"vip_priority": {&"type": &"bool", &"default": false}} +cleanliness_decay_per_service = 1 +noise_per_service = 1 +beauty_effect = 4 +security_effect = 5 +incident_weights = {&"queue_dispute": 1, &"theft": 1} +unlock_rank = 1 +statistics_keys = Array[StringName]([&"average_wait_minutes", &"guest_processed", &"revenue", &"satisfaction_delta"]) +tags = Array[StringName]([&"service", &"entrance"]) diff --git a/data/facilities/security_station.tres b/data/facilities/security_station.tres new file mode 100644 index 0000000..5d5ff65 --- /dev/null +++ b/data/facilities/security_station.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"security_station" +display_name = "警備詰所" +description = "警備員が待機し、事故・喧嘩・侵入などの現場へ出動する拠点。" +zone = 0 +layer = 1 +footprint = Vector2i(2, 2) +construction_cost = {&"gold": 900, &"materials": 10} +build_minutes = 180 +weight_per_cell = 2 +maintenance_mana = 2 +power_priority = 90 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"security"]) +primary_skill = &"combat" +capacity = 3 +queue_capacity = 8 +base_service_minutes = 1 +base_quality = 55 +base_price = 0 +tags = Array[StringName]([&"incident_response", &"security", &"staff_support"]) diff --git a/data/facilities/service_corridor.tres b/data/facilities/service_corridor.tres new file mode 100644 index 0000000..f28357b --- /dev/null +++ b/data/facilities/service_corridor.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"service_corridor" +display_name = "裏通路" +description = "従業員・物資・清掃・警備・救護が利用する、裏層の移動用サブスロット。" +zone = 0 +layer = 1 +footprint = Vector2i(1, 1) +construction_cost = {&"gold": 25, &"materials": 0} +build_minutes = 10 +weight_per_cell = 1 +pass_through = true +maintenance_mana = 0 +power_priority = 10 +staff_min = 0 +staff_max = 0 +capacity = 2 +queue_capacity = 0 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"corridor", &"movement", &"route_subslot", &"schema_facility_proxy", &"staff_only"]) diff --git a/data/facilities/service_elevator.tres b/data/facilities/service_elevator.tres new file mode 100644 index 0000000..95f1bf4 --- /dev/null +++ b/data/facilities/service_elevator.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"service_elevator" +display_name = "業務用昇降機" +description = "従業員・物資・救護搬送を上下階へ運ぶ、高速の裏層用昇降設備。" +zone = 0 +layer = 1 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 2200, &"materials": 20} +build_minutes = 360 +weight_per_cell = 3 +pass_through = true +maintenance_mana = 6 +power_priority = 95 +staff_min = 0 +staff_max = 0 +capacity = 8 +queue_capacity = 16 +base_service_minutes = 5 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"movement", &"schema_facility_proxy", &"staff_only", &"vertical_connector"]) diff --git a/data/facilities/staff_door.tres b/data/facilities/staff_door.tres new file mode 100644 index 0000000..f713add --- /dev/null +++ b/data/facilities/staff_door.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"staff_door" +display_name = "従業員ドア" +description = "従業員だけが表層と裏層を行き来できる、管理された接続設備。" +zone = 0 +layer = 1 +footprint = Vector2i(1, 1) +construction_cost = {&"gold": 100, &"materials": 1} +build_minutes = 30 +weight_per_cell = 1 +pass_through = true +maintenance_mana = 0 +power_priority = 95 +staff_min = 0 +staff_max = 0 +capacity = 1 +queue_capacity = 1 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"cross_layer_connector", &"movement", &"schema_facility_proxy", &"staff_only"]) diff --git a/data/facilities/staff_waiting_room.tres b/data/facilities/staff_waiting_room.tres new file mode 100644 index 0000000..b991067 --- /dev/null +++ b/data/facilities/staff_waiting_room.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"staff_waiting_room" +display_name = "待機室" +description = "非番または待機中の従業員が休憩し、呼び出しに備える裏方区画。" +zone = 0 +layer = 1 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 400, &"materials": 6} +build_minutes = 120 +weight_per_cell = 1 +maintenance_mana = 0 +power_priority = 30 +staff_min = 0 +staff_max = 0 +primary_skill = &"operation" +capacity = 8 +queue_capacity = 0 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"continuous_support", &"recovery", &"staff_support"]) diff --git a/data/facilities/standard_room.tres b/data/facilities/standard_room.tres new file mode 100644 index 0000000..546904a --- /dev/null +++ b/data/facilities/standard_room.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"standard_room" +display_name = "一般客室" +description = "冒険者と一般市民向けの基本的な宿泊施設。" +zone = 1 +layer = 0 +footprint = Vector2i(2, 1) +construction_cost = {&"gold": 350, &"materials": 5} +build_minutes = 60 +weight_per_cell = 1 +maintenance_mana = 0 +staff_min = 0 +staff_max = 0 +capacity = 2 +queue_capacity = 4 +base_service_minutes = 720 +base_quality = 50 +base_price = 80 +tags = Array[StringName]([&"lodging", &"quiet"]) diff --git a/data/facilities/tavern.tres b/data/facilities/tavern.tres new file mode 100644 index 0000000..937a5c8 --- /dev/null +++ b/data/facilities/tavern.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"tavern" +display_name = "酒場" +description = "飲食と交流を提供し、夜間の娯楽需要にも応える接客施設。" +zone = 0 +layer = 0 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 1000, &"materials": 12} +build_minutes = 240 +weight_per_cell = 2 +maintenance_mana = 2 +power_priority = 55 +staff_min = 2 +staff_max = 4 +required_roles = Array[StringName]([&"bartender", &"server"]) +primary_skill = &"service" +capacity = 14 +queue_capacity = 28 +base_service_minutes = 50 +base_quality = 55 +base_price = 40 +inventory_inputs = {&"beverage/basic": 1} +tags = Array[StringName]([&"dining", &"leisure", &"service", &"social"]) diff --git a/data/facilities/warehouse.tres b/data/facilities/warehouse.tres new file mode 100644 index 0000000..15ccefb --- /dev/null +++ b/data/facilities/warehouse.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"warehouse" +display_name = "倉庫" +description = "食材、資材、装備を保管し、裏動線を通じて各施設へ供給する。" +zone = 0 +layer = 1 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 450, &"materials": 10} +build_minutes = 180 +weight_per_cell = 2 +maintenance_mana = 0 +staff_min = 1 +staff_max = 3 +required_roles = Array[StringName]([&"logistics"]) +primary_skill = &"operation" +capacity = 100 +queue_capacity = 4 +base_service_minutes = 10 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"logistics", &"storage"]) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..bc6f793 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,56 @@ +# アーキテクチャ + +ゲームルールの正本は [GAME_SPECIFICATION.md](GAME_SPECIFICATION.md)、状態遷移・処理順・不変条件は [SYSTEM_SPECIFICATION.md](SYSTEM_SPECIFICATION.md)、重要な判断理由は [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) を参照する。本書はその仕様を現在のGodotプロジェクトへ配置するための技術構成を扱う。 + +## 責務 + +- `autoload/game_clock.gd`: 1分固定Tick、11段階の処理順、速度変更、一時停止 +- `autoload/game_state.gd`: 非Gold資源とUI向けGoldミラー、世界樹Rankなどの全体状態 +- `autoload/simulation_runtime.gd`: Command、各Service、Domain Event、Snapshotの唯一の実行時配線 +- `autoload/event_bus.gd`: UIとシミュレーション間の疎結合な通知 +- `scripts/core/`: Command Queue、Transaction、決定的RNG、ID、Save、Digest +- `scripts/world/`、`scripts/construction/`: Cell正本、主要占有と独立した副占有、ROUTEからPathへの原子投影、密度・動的Cost付きPath Graph、Connector Queue、掘削・建築Job +- `scripts/facility/`: 稼働状態、Queue、Service Slot、サービス境界 +- `scripts/inventory/`: Stack、Reservation、物流Request、Dispatcher +- `scripts/mana/`: 歩行経路と独立した供給Network、蓄電、停電 +- `scripts/economy/`: Goldの正本となる複式台帳と資源Adapter +- `scripts/guest/`、`scripts/staff/`: Need/Utility、実経路移動、Shift、Task Dispatcher +- `scripts/relationship/`、`scripts/events/`: 関係指標、Shield、Route Lock、データ駆動EventとActor Lock +- `scripts/authority/`: Heat、Incident、Evidence、監査巡回と処分境界 +- `scripts/defense/`: Raid Wave、戦闘参加者、扉・設備・目的物、Active Skill、撤退と結果境界 +- `scripts/operations/`: 06:00営業日締め・朝境界、需要と30分ごとの来客計画、未払・破綻をまとめる14日運営Coordinator +- `scripts/progression/`: 累計条件、逐次Rank昇格、Map Bounds派生値、World拡張の原子境界 +- `scripts/core/save_service.gd`: 原子的Save、Autosave世代、Checksum検証、Migration/Recovery入口 +- `scripts/core/performance_budget.gd`: Simulation、Path、Save/Loadの診断用性能予算 +- `scripts/data/`、`data/facilities/`、`data/characters/`: MVP施設16種、客8クラス×4種族、雇用候補20人、ユニーク人物の厳密な静的定義境界 +- `scenes/`: 実Worldを表示する運営Dashboardと入力。ゲームルールを直接持たせない + +Runtime Snapshot v6はWorldとPathに加え、`excavation`、`spatial_subslots`、`route_projection`、`facility_infrastructure`を独立した正本として保持する。復元時はSpatial、base+ROUTE、Path、Adapter、Projectorの順に候補を組み立て、全crosslinkが成立してからlive stateへ切り替える。旧v5の通路代理施設はこの候補内で実ROUTEへ移行する。 + +建設完成は通知を一時保留し、施設登録または通路投影と全crosslinkの検証後にだけ外部signalを確定する。途中で失敗した場合は保留通知を破棄し、Construction、World、Path、Ledger、関連Serviceを直前Snapshotへ戻してSimulationを理由付きで停止する。 + +## マップ座標 + +施設の位置は `(x, y, layer)` で扱います。 + +- `y > 0`: 地上ゾーン +- `y = 0`: 境界ゾーン +- `y < 0`: 地下ゾーン +- `layer = FRONT`: 客が利用する表側 +- `layer = BACK`: 従業員、物流、管理設備が利用する裏側 + +「地下だがフロントレイヤーにあるカジノ」のような組み合わせは正常です。地下とバックを同義として扱いません。 + +## 実装する単位 + +1. S0: 決定的Simulation基盤とAtomic Save +2. S1: World、A*経路、施設建設 +3. S2: 施設Queue、在庫、物流、Mana、台帳 +4. S3: 欲求ベースの客AI、シフト、Task、疲労 +5. S4: 関係Event、Choice、Cost予約、Content Filter +6. S5: 行政・監査、Evidence、処分の冪等適用、Wave単位の防衛 +7. S6: 保存世代、Migration、Recovery、性能予算 + +実装順はSYSTEM_SPECIFICATION.mdの第22章、ゲームとしてのMVP合格条件はGAME_SPECIFICATION.mdの第18章、System TestはSYSTEM_SPECIFICATION.mdの第21章に従う。 + +各機能は小さなシミュレーションとしてテストを先に用意し、画面から独立して検証できるようにします。 diff --git a/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md b/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md new file mode 100644 index 0000000..7bb1f7c --- /dev/null +++ b/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md @@ -0,0 +1,271 @@ +# 画像アセット制作・差し替え仕様書 + +## 1. 目的 + +この文書は、本作に必要な画像を制作し、安全に差し替えるための正本とする。 + +**2026-09-09更新:現在のマップは『The Tower SP』を参考にしたドット絵の断面表示を採用する。** 施設の内装と小さな人物の動きが主役で、Kenney Tiny Dungeon・Tiny Townの16px素材と専用のドット描画を使う。旧生成背景や人物Bustを現在のマップへ戻すための仕様ではない。操作と実装上の制約は [ドット絵表示と見学ガイド](PIXEL_TOWER_VISUALS.md) を参照する。 + +画像の見た目は差し替え可能だが、ゲームの判定、施設座標、人物ID、保存データへ画像ファイル名やAtlas座標を保存してはならない。ゲーム側は`VisualThemeProfile`の意味IDから画像を取得する。 + +## 2. 現在のアートディレクション + +- 世界樹の正面断面を大きく見せる。受付・客室・食堂などを内装で識別でき、小さな客と従業員の移動・待機・仕事が読める構成とする。 +- 基本素材はKenneyの16×16pxドット絵。Nearestフィルターを使い、通常表示では整数倍率を優先する。高解像度の絵をぼかして縮小した人物をマップの主役にしない。 +- 幹・葉・部屋・家具は同じドット密度と色調で専用描画する。主色は木色・クリーム・濃緑、裏方の補助色は青緑とする。 +- 管理UIは小さく平坦な枠と読みやすい日本語で構成する。建築パレットと運営・雇用の詳細は折りたたみ、初期表示では断面を広く見せる。 +- 素材追加時はまずKenneyを確認する。導入元URL・ライセンスを記録し、同梱Licenseを保存する。導入済み素材は [Kenney素材一覧](../assets/kenney/README.md) を参照する。 +- 参考にするのは断面を眺めて動線を改善する体験と視認性であり、既存作品の画像・キャラクター・ロゴ・固有UIを複製しない。 +- 本変更は表示の変更である。ゲームルール、施設定義、建築判定、経路判定、保存形式を変えない。 + +### 2.1 旧生成テーマの制作参考 + +以下のセル画調指定は旧`japanese_fantasy`テーマの背景・高解像度Icon・会話用画像向けに保持する。現在のマップ用16px素材には適用しない。 + +- 世界観は「世界樹の内部で異世界の宿を営む、日本製ファンタジーRPG風の経営シミュレーション」とする。 +- 海外製ストラテジーの重い写実表現より、輪郭と色面が読みやすい手描きセル画調を優先する。 +- 主色は蜂蜜色の木、クリーム色の漆喰と羊皮紙、深い革色、苔色、古い真鍮とする。 +- 地下は抑えた紫、従業員経路は青緑、危険は赤、注意は琥珀色とする。 +- 成人向け要素は露骨な図像にせず、幕、夜色、宝石、紫照明などで示す。 +- 実在作品の固有キャラクター、ロゴ、UI、装飾パターンを複製しない。 +- 画像内へ文字、数字、ロゴ、透かしを入れない。文字はゲーム側で描画する。 + +## 3. 技術要件 + +### 3.1 現在のマップ用ドット素材 + +| 項目 | 要件 | +|---|---| +| 基本素材 | Kenney Tiny Dungeon 1.0 / Tiny Town 1.1 | +| 保存先 | `assets/kenney/tiny_dungeon/`、`assets/kenney/tiny_town/` | +| 使用Atlas | 各`Tilemap/tilemap_packed.png`、192×176px、12列×11行 | +| 1タイル | 16×16px、間隔なし。間隔あり版の17pxピッチと混同しない | +| 描画 | sRGB・実Alpha PNG、Nearest、Mipmap無効 | +| 倍率 | 通常は整数倍率。広い世界の全体表示では縮小を許容し、ズームで確認する | +| 座標単位 | 表示セルは16×24の比率。素材Atlasの16×16とは別に扱う | +| Theme接続 | `VisualThemeProfile.pixel_dungeon_tiles`、`pixel_town_tiles` | +| アニメーション | 静止元絵の足・上下動・向きから生成。隣接する別人物を歩行フレームとして循環させない | + +Atlasを差し替える場合は`pixel_tower_art.gd`と`world_preview.gd`のタイル割り当ても確認する。世界座標・人物IDには画像座標を混ぜない。 + +### 3.2 旧生成テーマ・高解像度画像 + +以下の2048px・4×4・Mipmap指定は旧テーマ用であり、上記のKenney Atlasには適用しない。 + +| 項目 | 要件 | +|---|---| +| 色空間 | sRGB | +| 形式 | PNG。背景は不透明、Iconと人物は実Alpha透過 | +| 基準表示 | 1440×810 | +| 対応出力 | 1280×720、1920×1080、2560×1440、3840×2160 | +| Atlas推奨寸法 | 2048×2048、4×4、1セル512×512 | +| Icon余白 | 各セル外周12.5%以上を透明にする | +| Mipmap | 旧生成テーマの高解像度画像は有効 | +| 命名 | 小文字kebab-case+版番号。例:`facility-icons-v3.png` | +| 禁止 | JPEG、疑似チェッカー背景、セルを跨ぐ影、画像内文字、非整数セル寸法 | + +既存Atlasは1254×1254である。正式差し替えでは2048×2048を推奨する。寸法を変えた場合は`visual-theme-v1.tres`内の`AtlasTexture.region`も1セル512×512へ更新する。 + +Alpha必須画像は画像編集ソフトで確認する。白やチェッカー模様を描いただけの画像は透明画像として受け入れない。 + +## 4. 旧テーマに保持する生成画像 + +この章の画像・生成指示は既存`visual-theme-v1.tres`の互換性と旧テーマの制作用に保持する。FRONT/BACKの大きな背景、人物Bust、装飾的なUI Skinは現在のドット絵マップと平坦なUIには使用しない。施設・管理・資源・操作の小さなUI Iconは引き続きTheme参照を利用している。ここでいう「現在」のファイル名は旧テーマ内の参照先を示す。 + +### 4.1 FRONT表層背景 + +- 意味ID:`front_world_background` +- 現在:`assets/themes/japanese_fantasy/world-front-v1.png` +- 推奨寸法:3840×2160、Alpha不要 +- 用途:中央マップのFRONT背景 +- 構図:正面視点の世界樹宿横断面。中央70%は施設、Grid、人物が重なるため低密度にする。 +- 必須:複数階の水平な木造床、世界樹の幹と枝、玄関、昼の空気感。 +- 禁止:斜め見下ろし、強い遠近、人物、文字、Grid線、完成済み大型家具。 + +生成指示例: + +> 世界樹の内部に建つ異世界の宿。正面視点の16:9横断面、複数の水平な木造階、蜂蜜色の木材、クリーム色の壁、緑の葉、青空。中央70%は経営ゲームの施設・人物・格子を重ねるため低情報量で、床の高さが明確。日本製ファンタジーRPGの暖かな手描きセル画調。人物、UI、文字、ロゴ、透かし、斜め遠近なし。3840×2160。 + +### 4.2 BACK裏層背景(旧テーマの根域表現) + +この背景の根域表現は旧テーマの美術上の構図であり、BACKを地下と同一視するゲームルールではない。地上/境界/地下のゾーンと、客用/裏方のレイヤーは独立する。 + +- 意味ID:`back_world_background` +- 現在:`assets/themes/japanese_fantasy/world-back-v1.png` +- 推奨寸法:3840×2160、Alpha不要 +- 構図:FRONTと同じカメラ・床高の世界樹根域横断面。 +- 必須:太い根、掘削可能な空洞、業務通路、真鍮配管、暖色ランタン、控えめな紫水晶。 +- 禁止:ホラー表現、過度な暗さ、人物、文字、Grid線。 + +生成指示例: + +> 世界樹の地下根域に作られた異世界宿の業務空間。正面視点の16:9横断面、水平な根の床、掘削可能な空洞、真鍮配管、暖色ランタン、抑えた紫水晶。中央70%は施設・人物・格子用に低情報量。表層背景と同じカメラと床高。日本製ファンタジーRPGの手描きセル画調。人物、UI、文字、ロゴ、透かしなし。3840×2160。 + +### 4.3 施設Icon Atlas + +- 意味辞書:`facility_icons` +- 現在:`facility-icons-v2.png` +- 推奨:2048×2048、4×4、実Alpha透過 +- 表示:主に24~52px。各セルは施設を象徴する物体を1組だけ中央配置する。 + +| 行 | 列1 | 列2 | 列3 | 列4 | +|---|---|---|---|---| +| 1 | 受付 | 一般客室 | 大衆食堂 | 倉庫 | +| 2 | 厨房 | 大浴場 | 従業員待機室 | 警備詰所 | +| 3 | 酒場 | マッサージ室 | 客用昇降機 | 業務用昇降機 | +| 4 | 従業員ドア | 客用通路 | 業務用通路 | 成人向け個室 | + +生成指示例: + +> 2048×2048の正確な4×4透明Atlas。各セル512×512。順序は、受付、一般客室、大衆食堂、倉庫/厨房、大浴場、従業員待機室、警備詰所/酒場、マッサージ室、客用昇降機、業務用昇降機/従業員ドア、客用通路、業務用通路、上品な成人向け個室。日本製ファンタジーRPGの暖かな手描きセル画Icon。外周12.5%以上を透明にし、小サイズでも輪郭が区別できる。文字、枠、人物、露骨な性表現、ロゴ、透かしなし。実Alpha透過。 + +### 4.3.1 拡張施設Icon Atlas + +- 予定ファイル:`facility-expansion-icons-v1.png` +- 推奨:2048×2048、4×4、実Alpha透過 +- ゲーム側状態:16施設の定義は追加済み。正式Iconと専用Systemが揃うまで通常建築パレットへ出さない。 + +| 行 | 列1 | 列2 | 列3 | 列4 | +|---|---|---|---|---| +| 1 | 上級客室 | 喫茶ラウンジ | 売店 | 診療所 | +| 2 | 冒険者窓口 | 地下客室 | 地下貸切浴室 | 地下酒場 | +| 3 | 闇市取引所 | 地下VIPサロン | 洗濯室 | 魔法冷蔵庫 | +| 4 | 魔力制御室 | 整備工房 | 従業員宿舎 | 廃棄物処理室 | + +生成指示例: + +> 2048×2048の正確な4×4透明Atlas。上級客室、喫茶ラウンジ、売店、診療所/冒険者窓口、地下客室、地下貸切浴室、地下酒場/闇市取引所、地下VIPサロン、洗濯室、魔法冷蔵庫/魔力制御室、整備工房、従業員宿舎、廃棄物処理室の順に配置。一般地上施設は蜂蜜色と緑、地下客向け施設は夜色と抑えた紫、BACK施設は青緑と真鍮で区別する。日本製ファンタジーRPGの暖かな手描きセル画Icon。各セル外周12.5%以上を透明にし、16~32pxで識別可能。文字、枠、人物、露骨な性表現、ロゴ、透かしなし。実Alpha透過。 + +### 4.4 管理・資源・操作Icon Atlas + +- 意味辞書:`navigation_icons`、`resource_icons`、`control_icons` +- 現在:`system-icons-v2.png` +- 推奨:2048×2048、4×4、実Alpha透過 + +| 行 | 列1 | 列2 | 列3 | 列4 | +|---|---|---|---|---| +| 1 | 従業員 | 施設・建築 | 商品 | 探索 | +| 2 | 投資 | 帳簿 | FRONT表層 | BACK裏層 | +| 3 | Gold | Mana | 資材 | Faith | +| 4 | 警告 | 回転 | 掘削 | 保存 | + +生成指示例: + +> 2048×2048の正確な4×4透明Atlas。従業員、施設建築、商品、探索/投資、帳簿、FRONT表層、BACK裏層/金貨、マナ結晶、木材資材、信仰の花/警告ベル、回転矢印、つるはし、巻物への保存。暖色の日本製ファンタジーRPG Icon、統一した光源と線幅、16~32pxでも識別可能。各セル外周12.5%以上を透明にする。文字、セル枠、ロゴ、透かしなし。実Alpha透過。 + +### 4.5 客・従業員Marker Atlas + +- 意味辞書:`guest_actor_markers`、`staff_actor_markers` +- 現在:`actor-markers-v2.png` +- 推奨:2048×2048、4×4、実Alpha透過 +- 年齢:全員を外見上も明確な成人として描く。 +- 用途:マップ上の小型Bust。会話Portraitとは分ける。 + +| 行 | 列1 | 列2 | 列3 | 列4 | +|---|---|---|---|---| +| 1 | 戦士客 | 魔術師客 | 盗賊客 | 聖職者客 | +| 2 | 貴族客 | 商人客 | 異種族・異邦客 | 外交客 | +| 3 | 受付・事務 | 料理人 | 清掃・保守 | 運搬人 | +| 4 | 接客・施術 | 警備 | 酒場担当 | 医療担当 | + +生成指示例: + +> 2048×2048の正確な4×4透明Atlas。明確に成人の人物Bustを、戦士客、魔術師客、盗賊客、聖職者客/貴族客、商人客、異種族または異邦客、外交客/受付事務、料理人、清掃保守、運搬人/接客施術、警備、酒場担当、医療担当の順に配置。和製ファンタジーRPGの親しみやすい手描きセル画。16~24pxでも役割が色と輪郭で判別できる。全セルで同じ頭身、Bust範囲、光源。文字、枠、未成年、性的強調、露出、ロゴ、透かしなし。実Alpha透過。 + +### 4.6 UI Skin Atlas + +- 意味辞書:`ui_skin_textures` +- 現在:`ui-skin-v1.png` +- 推奨:2048×2048、4×4 +- 用途:Nine-patch Panel、Button状態、選択枠、待機Badge、工事・経路・層境界。 +- PanelとButtonは四隅と縁を外周15%以内へ収め、中央60%を均一で伸縮可能にする。 +- 選択枠、警告枠、待機Badgeは実Alpha透過を推奨する。 + +| 行 | 列1 | 列2 | 列3 | 列4 | +|---|---|---|---|---| +| 1 | 外枠Panel | 木製Panel | 革Panel | 羊皮紙Panel | +| 2 | Button通常 | Button Hover | Button選択 | Button無効 | +| 3 | 選択枠 | 警告枠 | 待機Badge | 工事中Texture | +| 4 | 客用経路Texture | 従業員経路Texture | FRONT境界飾り | BACK境界飾り | + +生成指示例: + +> 2048×2048の正確な4×4 UI Skin Atlas。外枠Panel、木製Panel、革Panel、羊皮紙Panel/Button通常、明るいHover、緑の選択、彩度を落とした無効/金の選択枠、赤の警告枠、琥珀色の丸い待機Badge、木板の工事中Texture/苔色の客用経路、青緑の従業員経路、木と葉のFRONT境界飾り、紫水晶と根のBACK境界飾り。日本製ファンタジーRPGの手描きUI。各セルは正面視点で独立し、影をセル外へ出さない。PanelとButtonの中央60%は低情報量でNine-patch伸縮可能。文字、数字、人物、施設、ロゴ、透かしなし。 + +## 5. 次段階で制作する画像 + +マップ向けには、同じ人物の歩行・待機・清掃・運搬・施設利用の専用フレームと、横断面に合う家具差分を先に増やす。現在の汎用静止タイルを加工した動きだけで全職種の作業を表現できるとは扱わない。以下のPortrait・一枚絵などの高解像度指定は、それぞれの画面用途に限って使用する。 + +### 5.1 固有人物Portrait + +- 1人物1ファイル、推奨1536×2048、透明PNG。 +- 腰上、正面寄り3/4、視線と光源を統一する。 +- 通常、喜び、困り、怒り、負傷の5表情を同じ構図で制作する。 +- 最初の対象は`heroine_anri`と`rival_rai`。成人であることを明確にする。 +- 表情差分で服、顔、髪型、体格、カメラを変えない。 + +### 5.2 一般人物会話Portrait + +- 客8クラス、従業員8役割群を各1024×1024以上で制作する。 +- Map Markerより情報量を増やすが、固有人物と誤認する固有装飾は避ける。 + +### 5.3 商品・料理・素材Icon + +- 2048×2048の4×4透明Atlasをカテゴリ別に作る。 +- 1セル1品とし、正式な商品IDと順序が確定してから制作する。 + +### 5.4 状態・需要・技能Icon + +- 状態異常、6欲求、4技能、営業状態、清潔、騒音、治安、物流を別Atlasにする。 +- 色だけに依存せず形でも区別する。 + +### 5.5 Event背景・一枚絵 + +- 通常Event背景は2560×1440、重要Eventは3840×2160、不透明PNG。 +- UI文字領域を空け、主要人物の顔へButtonや字幕が重ならない構図にする。 +- 成人向け差分は通常版と分離し、Content Filterで安全に置換できるようにする。 + +### 5.6 Map装飾・Effect + +- ランタン、旗、葉、根、煙、湯気、魔法光、工事火花は個別透明PNGまたは等分Sprite Sheetにする。 +- Loop Animationはフレーム数、FPS、再生順を同名の`.md`へ記録する。 +- 装飾はCell境界と当たり判定へ影響させない。 + +### 5.7 Title・Loading・Save + +- Title背景とLoading背景:3840×2160、文字なし。 +- Logo:横長透明PNG、推奨2048×1024。日本語題字は最終的に人手で確定する。 +- Save Thumbnailは実画面から生成するため固定画像を作らない。 + +## 6. 差し替え手順 + +1. 元ファイルを上書きせず、`-v2`、`-v3`のような新しい名前で置く。 +2. 解像度、Alpha、Atlas行列、余白を確認する。 +3. GodotへImportする。マップ用ドット素材はNearest・Mipmap無効、旧生成テーマの高解像度画像はMipmap有効にする。 +4. `visual-theme-v1.tres`を複製するか、新画像への参照だけを更新する。 +5. Atlas寸法を変更した場合は該当する`AtlasTexture.region`またはドット描画のタイル割り当てを更新する。 +6. 720p、基準解像度、1080p、1440p、4Kで確認する。 +7. 文字の可読性、Button状態、人物と施設の識別、Gridとの重なりを確認する。 +8. `make test-pixel-ui`でズーム・移動後の施設クリックと座標対応を確認し、`make capture-pixel`の時間差画像で人物と室内の重なりを確認する。 +9. Theme画像を一時的に空へした場合の不足表示を確認する。Kenney Atlasを外すと現在の人物は表示されないため、納品Themeでは両Atlas参照を必須とする。 + +## 7. 納品時チェックリスト + +- [ ] 指定寸法とsRGBになっている。 +- [ ] 必要な画像は実Alpha透過である。 +- [ ] Atlasが対象の規格(現在のKenneyは12×11、旧生成テーマは4×4)に合い、セル寸法が整数である。 +- [ ] 行・列の順序がこの文書と一致する。 +- [ ] 影や装飾が隣のセルへ越境していない。 +- [ ] 誤った文字、数字、ロゴ、透かしがない。 +- [ ] 16~24pxでもIconと人物の役割を判別できる。 +- [ ] 旧テーマのNine-patchを制作する場合、Panel中央を伸ばしても不自然にならない。 +- [ ] FRONTとBACKが色だけでなく形と素材でも区別できる。 +- [ ] 成人人物が未成年に見えない。 +- [ ] 720pと4Kの両方で輪郭が破綻しない。 +- [ ] 既存作品の固有意匠を複製していない。 + +## 8. 旧生成テーマ用の画像生成指示 + +以下はセル画調の旧テーマ用である。現在のマップ素材を依頼するときは「16px基準のドット絵、正面断面、統一したピクセル密度、実Alpha透過、文字なし、歩行差分でも同じ人物を維持」と指定し、完成後に16px表示で人手確認する。 + +> Production-ready game asset for a Japanese fantasy inn-management simulation. Warm hand-painted cel-shaded RPG style, strong small-size readability, consistent light source and perspective, no text, no letters, no numerals, no logos, no trademarks, no watermark. Keep all required empty areas low-detail. For transparent assets, output genuine alpha transparency rather than a painted checkerboard. diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md new file mode 100644 index 0000000..a9f0c71 --- /dev/null +++ b/docs/DESIGN_DECISIONS.md @@ -0,0 +1,229 @@ +# 設計判断記録 + +本書はGAME_SPECIFICATION.mdで採用した重要判断と、その理由を記録する。過去案へ戻す場合は、影響範囲を確認して新しい判断を追記する。 + +## D-001 地上/地下とFRONT/BACKを別軸にする + +- 状態: 採用 +- 判断: Y軸のゾーンと、奥行きの業務レイヤーを完全に分離する。 +- 理由: 旧文書では「表」が合法地上と客用空間の両方を指し、地下カジノが表施設なのか裏施設なのか判断できなかった。 +- 影響: 施設はzoneとlayerを必ず別フィールドで持つ。 + +## D-002 主人公を従業員ユニットにしない + +- 状態: 採用 +- 判断: 主人公は一施設へ現場介入できる経営者として扱い、スタッフ枠を占有しない。 +- 理由: 神視点の間接操作と「主人公を同室配置」の記述が矛盾していたため。 +- 影響: 移動時間、個人行動ポイント、主人公能力値が必要になる。 + +## D-003 標準速度の1日を現実12分にする + +- 状態: 採用 +- 判断: 現実30秒をゲーム内1時間とする。 +- 理由: 旧案の1日4分では、夕方ラッシュ、通知、シフト、関係イベントへ対応する時間が不足する。高速化はx2とx4で提供する。 +- 影響: 試作GameClockと操作UIを30秒/時間、x1/x2/x4へ同期済み。 + +## D-004 通貨は一つ、違法性はHeatで表現する + +- 状態: 採用 +- 判断: 表金と裏金を分けない。 +- 理由: 二重残高と資金洗浄はUI負荷が高く、建築と動線の中心体験を薄める。違法収益の代償はHeatで十分に表現できる。 +- 影響: 違法サービスは収益と同時にHeatを発生させる。 + +## D-005 風紀度と全館清潔度を廃止する + +- 状態: 採用 +- 判断: 風紀度をReputation、Heat、局所治安へ分け、清潔度はセル単位にする。 +- 理由: 一つの全体値が、客層、犯罪、衛生、監査を同時に動かしており原因が読めなかった。 +- 影響: レポートでは局所値の集計を表示するが、ゲーム判定は可能な限り現場値を使う。 + +## D-006 破産結果を性別で分けない + +- 状態: 採用 +- 判断: 暴動、逃走、装備精算、契約化を性格、戦闘力、信用、ポリシーで決める。 +- 理由: 性別固定は客AIとして不自然で、プレイヤーが結果を予測・操作できない。 +- 影響: 性別は外見、好み、成人関係、妊娠可否など必要な箇所だけに用いる。 + +## D-007 自由雇用者は売却できない + +- 状態: 採用 +- 判断: 契約移転の対象は、成人隷属契約など明示状態を持つユニットだけとする。 +- 理由: 旧文書内で一般従業員の売却可否が矛盾していた。 +- 影響: 雇用区分と契約状態をデータで明確に分ける。 + +## D-008 愛の盾を好感度と別保存する + +- 状態: 採用 +- 判断: Shield MaxはAffectionから算出し、現在Shieldは別値として攻防に使う。 +- 理由: 好感度そのものを攻撃で減らすと、長期関係と短期防御の意味が混ざる。 +- 影響: Shieldは時間だけでは減らず、接触イベントでのみ損耗する。 + +## D-009 重要ルートは閾値だけで確定させない + +- 状態: 採用 +- 判断: 50と80の境界では明示イベントとプレイヤーへの予告を要求する。 +- 理由: 見ていない場所の自動計算だけで取り返しのつかない結果になると、経営判断ではなく不意打ちになる。 +- 影響: 関係状態は数値とイベントフラグの両方を持つ。 + +## D-010 決闘勝利で関係履歴を全消去しない + +- 状態: 採用 +- 判断: 勝利効果はRival Bond -20、Shield +30、接近禁止とする。 +- 理由: 一回の勝負で長期進行を0へ戻せると、日常の配置管理が無意味になる。 +- 影響: Rival Bond 80以上は通常決闘では解決できない。 + +## D-011 ヒロイン一人の離脱を即時ゲームオーバーにしない + +- 状態: 採用 +- 判断: 離脱は経営能力と物語分岐の損失として扱う。 +- 理由: 20~30時間の経営ゲームで一人の自動進行が全セーブを終わらせると、実験的な経営を妨げる。 +- 影響: ゲームオーバーは経営破綻、世界樹喪失、明示された最終章へ限定する。 + +## D-012 CorruptionとRival Bondを独立させる + +- 状態: 採用 +- 判断: 禁忌への適応と、特定ライバルへの関係を別軸にする。 +- 理由: 地下勤務を選ぶだけで自動的にNTRへ進むと、地下経営と純愛維持を両立できない。 +- 影響: 高Corruptionかつ高Affectionの忠誠型サブ分岐を許可する。 + +## D-013 全登場人物の成人条件をデータで保証する + +- 状態: 採用 +- 判断: 成人向け行為、成人労働、契約取引に関わる人物は成人確認済みフラグを必須とする。 +- 理由: 世代システムと成人向け要素を同一作品で扱うため、境界を実装上も曖昧にしない必要がある。 +- 影響: 未成年期は非性的な人口データのみ。成人化は明示的な時間経過後に行う。 + +## D-014 MVPは14日間の垂直スライスにする + +- 状態: 採用 +- 判断: Rank 1~3、ヒロイン1人、ライバル1人、100客を対象とする。 +- 理由: 全仕様を薄く作るより、本作固有の二層動線と関係リスクが面白いかを先に検証する。 +- 影響: 世代、大規模戦争、7人分のシナリオはMVP後に実装する。 + +## D-015 成人向けルートを設定で無効化可能にする + +- 状態: 採用 +- 判断: NTR、強制性の強い展開、妊娠などを個別設定する。 +- 理由: プレイヤーの嗜好差が大きく、望まないルートを避ける管理負担をゲーム難易度に含めるべきではない。 +- 影響: 無効化時の代替イベントを必ず用意し、進行不能にしない。 + +## D-016 1ゲーム分の固定TickとCommand Queueを採用する + +- 状態: 採用 +- 判断: Simulationの状態変更は1ゲーム分の固定Tick境界で、順序付けしたCommandを通して行う。 +- 理由: 画面更新速度や入力TimingによってAI、収支、イベント結果が変わると、再現TestとSave復旧が成立しない。 +- 影響: UIは状態を直接変更せずCommandを発行し、同一Tick内の処理順を固定する。 + +## D-017 人物移動はSoft Collisionにする + +- 状態: 採用 +- 判断: 通路Cellを一人だけが占有するHard Reservationではなく、密度を移動Costと速度へ反映する。 +- 理由: 多人数が狭い縦動線へ集中する本作では、Hard Reservationは相互待ちと永久停止を起こしやすい。 +- 影響: 混雑は視覚と効率低下で表現し、施設入口、座席、昇降機だけ明示Slotを予約する。 + +## D-018 在庫・料金・Service開始を原子的に処理する + +- 状態: 採用 +- 判断: 在庫Reservation、料金決済、Service Slot確保、状態遷移を一つのTransactionで確定する。 +- 理由: 途中失敗時にItemだけ消える、料金だけ取る、座席だけ埋まる不整合を防ぐため。 +- 影響: 取消は履歴削除ではなく、Reservation解除と逆取引で表現する。 + +## D-019 SaveはSnapshot方式を採用する + +- 状態: 採用 +- 判断: 全状態Snapshotと短い診断Journalを保存し、完全なEvent Sourcingは採用しない。 +- 理由: 任意時点再生より、通常のSave容量、Load速度、Migrationの理解しやすさを優先する。 +- 影響: 決定性確認用のCommand ReplayはTest機能として持つが、製品Saveの復元方式にはしない。 + +## D-020 RNGをSystem別Streamへ分離する + +- 状態: 採用 +- 判断: 客判断、Event、行政、戦闘、生成を別の決定的RNG Streamで処理する。 +- 理由: 無関係な客が一人増えただけで監査や重要Eventの乱数結果まで変わる連鎖を避けるため。 +- 影響: Seedだけでなく各StreamのStateとCounterをSaveする。 + +## D-021 監査は監査官の巡回と視認で判定する + +- 状態: 採用 +- 判断: 監査発生後の発見は全館一括抽選ではなく、監査官の移動、視線、帳簿、証言、既知証拠で判定する。 +- 理由: FRONT/BACK動線と隠蔽設計を監査対策へ接続し、処罰の理由をプレイヤーが追跡できるようにする。 +- 影響: 監査官はPathServiceを使い、証拠ごとに発見元と位置を保存する。 + +## D-022 日次の金融締めを00:00、朝の運用境界を06:00に分ける + +- 状態: D-031により廃止 +- 判断: 売上・原価・賃金・維持費・未払金の日次締めとDaySummary確定は日付境界の00:00に行う。06:00はシフト交代、個人AP回復、監査抽選、朝Event、その後のAutosaveだけを行う。 +- 理由: 日付と財務期間の境界を一致させ、同じ売上や費用がどの日に属するかを一意にする。同時に、06:00の人物・監査処理と保存を重い金融処理から分離する。 +- 影響: 23:59から00:00で保留Service確定、Ledger照合、未払判定、DaySummaryをこの順で行う。05:59から06:00のTestは財務締めを再実行しないことも検証する。 + +## D-023 世界樹Rankは1段階ずつ原子的に昇格する + +- 状態: 採用 +- 判断: Rankは条件を満たした次段階へだけ上げる。World拡張、Path再構築、GameState、進行履歴を一つの原子的境界として扱う。 +- 理由: デバッグ用の直接Rank指定を製品Commandへ残すと、解禁条件を飛ばせるだけでなく、Map Boundsだけが先に広がる部分適用を起こし得るため。 +- 影響: 累計Faithと累計客を現在値から分離し、昇格Requestを冪等化する。Rollback不能時は自動再試行せず隔離し、保存復旧を要求する。 + +## D-024 客の欲求を6軸へ統一し、安全・交流を文脈へ分離する + +- 状態: 採用 +- 判断: 永続する客欲求はHunger、Sleep、Hygiene、Medical、Leisure、Intimacyの6種だけとする。安全は危険評価と強制行動、交流は好み・同行者・接客・関係Eventの文脈として扱う。 +- 理由: ゲーム仕様の6欲求と実装の旧5欲求が不一致で、Safetyの不安値が負傷・病気、Socialの孤独値が成人向けIntimacyと混同され得た。原因と解決施設が異なる値を同じ軸へ載せると、客の選択理由とバランス調整が読めなくなる。 +- 影響: GuestService SnapshotをVersion 2へ上げる。Version 1だけは`rest→sleep`、`fun→leisure`、`social→intimacy`、`safety→medical`、`hygiene=0`として原子的に移行する。旧名は通常の生成・更新・候補入力では拒否し、Intimacyの時間帯Contextは成人確認済みDefinitionに限定する。 + +## D-025 境界施設は配置anchorでZoneを判定する + +- 状態: 採用 +- 判断: BOUNDARY施設は配置anchorを`y = 0`へ固定し、Footprintが地上側へ広がることを許可する。ただし地下側へのはみ出しは拒否する。ABOVE_GROUND/UNDERGROUND施設は全占有Cellを指定Zone内へ固定する。 +- 理由: 仕様上の受付はFRONT/境界かつ4×2であり、全Cellを`y = 0`へ要求すると正規データの施設が原理的に建設不能になる。一方、地下施設の一部だけを未掘削地上へ逃がす配置は許可できない。 +- 影響: Placement Previewと建設確定は同じZone規則を使い、地下CellにはZoneとは別に全Cell掘削済み条件を適用する。 + +## D-026 人物配置リスクPreviewは読み取り専用にする + +- 状態: 採用 +- 判断: 配置確定前の接触時間、Shield攻撃候補、発火可能Event、Route Lock警告、回避案は、既存状態と提案Shiftから生成する純粋なPreviewとして計算する。Previewだけで関係値やLockを変更しない。 +- 理由: 画面を開いた順序や候補の比較回数で関係進行が変わると、配置判断の再現性が失われる。Route Lockは取り返しのつかない境界なので、予測結果から暗黙確定してはならない。 +- 影響: 接触区間は半開区間のunionで二重計上を防ぎ、同一actor・同一shift IDの提案だけを置換する。Content Filterは表示だけを変え、risk、cost、cooldown、Lockの機械的結果は変えない。 + +## D-027 採用費と運営費戻しを日次分類で明示する + +- 状態: 採用 +- 判断: 契約時の採用費は日給と分離して運営費へ計上し、運営費の戻しは同期間費用の相殺として扱う。相殺超過だけをexpense recovery収益へ振り替える。 +- 理由: 採用費を賃金へ入れるとStaffの日次発生額と二重計上になり、運営費戻しを客向け返金へ入れると符号が逆になる。どちらもunknown categoryのままでは正常な雇用後の日次締めが停止する。 +- 影響: `STAFF_SIGNING_COST→cost_of_goods`、`OPERATING_REFUND→cost_of_goods相殺`を固定し、相殺超過はanomaly付きで`revenue`へ入れる。それ以外の未知categoryはfail-closedを維持する。 + +## D-028 通路施設は副占有からPathへ投影し、未完成の垂直Topologyを推測しない + +- 状態: 採用 +- 判断: 客用通路と業務用通路は主要施設占有と共存する`ROUTE` subslotとして実Pathへ投影する。従業員ドアは職員権限付きFRONT↔BACK Connectorにする。昇降機は、同階隣接Edgeを安全に抑制する垂直Topologyが完成するまで明示的に無変更拒否する。 +- 理由: 通路proxyを主要占有だけで完成扱いにすると、地図には存在しても移動不能になる。一方、現在の平面Graphへ昇降機Connectorだけを足すと、通常隣接Edgeが待ち時間や停止状態を迂回し、運行規則が無効になる。 +- 影響: 建設完成・撤去はProjectorを通してSpatial、Path、Connectorを一つの原子的境界として更新する。未対応昇降機は`UNSUPPORTED_VERTICAL_TOPOLOGY`を返し、どの依存状態も変更しない。 + +次の実装契約(未実装): + +- `PathService`の通常WALK Edgeは同じ`y`の`x±1`だけとし、`y`が変わる移動は明示Connectorだけに限定する。実A*、到達不能理由の診断探索、復旧地点検索に同じ規則を適用し、別階への暗黙テレポート候補を作らない。 +- 客用/業務用昇降機は回転0度に固定し、同じ`x`・同じFRONT/BACK内の`origin → origin + (0, 1, 0)`を双方向ELEVATOR Connectorにする。全FootprintはROUTEとして各階から乗降点へ到達可能にする。 +- 客用はFRONT、容量4、待機8、1階10分、一般利用。業務用はBACK、容量8、待機16、1階5分、`staff`権限とする。停止・停電中も通常WALKで迂回できてはならない。 +- 待機列、現在階、CabinState、運行状態は`PathService`所有の可変値とし、ProjectorはID、端点、権限、容量、所要時間など不変部分だけを照合する。乗車中または待機中の撤去は無変更拒否する。 +- 保存意味が変わるため、Path v4、Projector v2、Runtime v7として明示移行する。v6に存在し得る未投影の完成昇降機は、v7移行時に安定IDで投影し、片側だけ生成された状態を許可しない。 +- maintenance Mana 5/6を実Consumerへ登録し、Brownout/OUTAGEをConnector運行状態へ同期する。完成・撤去・停電・Save復元はConstruction、World、Spatial、Path、Mana、通知を一つのrollback境界で検証する。 + +## D-029 境界PauseはTickのhard stopとして扱う + +- 状態: 採用 +- 判断: catch-up中の境界失敗、rollback隔離、日次照合待ち、破綻がPauseを要求したら、同一Frameの残りTickと次の時刻加算を止める。境界分までClockが進んだ失敗は、その分の後続段階だけを再開時に1回実行する。 +- 理由: Pause flagだけを立てて既に算出したTick数を走らせると、Coordinatorの保留分とClockがずれ、保存不能になる。Clockを巻き戻すと、先に確定したCommandと通知の時刻が過去へ取り残される。 +- 影響: running中のCommandは到達予定分をeffective minuteとしてClock加算前に確定する。速度変更と手動stepはhard stopを解除せず、再開は専用の確認境界を通す。 + +## D-030 複数DomainEventは履歴確定後に一括配信する + +- 状態: 採用 +- 判断: Transactionが生成したDomainEventは全件を先に検証・履歴Commitし、その後で固定順にSubscriberへ配る。配信中の再入EventはBatch末尾へ送る。 +- 理由: 1件ずつ検証と通知を交互に行うと、後続ID衝突時に先行通知だけが外へ残り、状態rollback後の再試行で二重通知になる。 +- 影響: UIと不可逆な外部処理はService固有signalではなくCommit済みDomainEventまたはTick Snapshotを使う。Batch拒否は履歴・通知とも無変更となる。 + +## D-031 営業日と日次決算の境界を06:00に統合する + +- 状態: 採用 +- 判断: 暦日は00:00に更新する一方、宿の営業日は06:00から翌日05:59までとする。日次決算は翌06:00に行い、その後に昼番開始、個人AP回復、監査抽選、朝Event、Autosaveを処理する。 +- 理由: 夜間の宿泊、酒場、地下営業を途中の00:00で分断せず、一続きの夜営業として同じDaySummaryへ含める。朝昼営業と夜営業の交代時に会計と運営判断をまとめる方が、宿の経営感覚と一致する。 +- 影響: DaySummaryの期間は`Day N 06:00 <= minute < Day N+1 06:00`となる。05:59から06:00の境界では財務締めを最初に一度だけ確定し、失敗時は昼番開始以降を保留する。旧D-022は廃止する。 diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..d63d254 --- /dev/null +++ b/docs/DEVELOPMENT_PLAN.md @@ -0,0 +1,149 @@ +# The Tree 全体制作計画 + +作成日: 2026-09-09 +基準: The Tower SPを参考にしたドット絵の断面経営ゲーム。ローカル制作版を正本とする。 +状態: 2026-09-09 ユーザー承認済み。P0の基準点確保とP1の通常営業の最小ループから実施する。 + +## 1. 目指す体験と計画の前提 + +プレイヤーが施設と動線を設計し、自律的な客・従業員の営みを観察し、混雑や経営上の問題を改善する。世界樹の地上/地下と、客用FRONT/業務用BACKが、その判断に意味を持つ。小さな人物が動く断面を主画面とし、管理画面は判断に必要なときに開く。 + +直近の目標を「初見で30分遊べる宿」、次を「14ゲーム日を遊び切れるMVP」、その後を「製品版」とする。既存仕様の本編20~30時間、全要素50時間以上は製品版の設計目標であり、現状の達成実績ではない。序盤の試遊で実際の面白さと進行速度を確かめてから、後半の制作量を見積もる。 + +仕様の正本は [GAME_SPECIFICATION.md](GAME_SPECIFICATION.md)、状態・保存の規約は [SYSTEM_SPECIFICATION.md](SYSTEM_SPECIFICATION.md)。仕様との相違を修正する際は [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) に根拠を残す。計画に書いた案を、既存ルールの変更確定として扱わない。 + +締切・制作人数・専用素材の発注範囲は未指定のため、現時点では暦日の納期を約束しない。P1の通常運営確認とP2の縦移動試作を終えた時点で、実績に基づく作業量・日程を更新する。進捗はファイル数やテスト数だけでなく、各段階の完成条件で判定する。 + +## 2. 現在地 + +| 領域 | 確認できていること | 次に埋める差 | +|---|---|---| +| 外観・操作 | ドット断面、家具、Kenney人物の簡易歩行、状態表示、コンパクトUI、ズーム・パン | 専用動作、施設ごとの視認性、混雑・稼働の見せ方、実描画性能 | +| シミュレーション | 時間、建設、在庫・物流、経済、客AI、従業員、イベント、監査、保存の基盤 | 自律厨房生産・補充・配送と通常操作の接続。各Serviceの存在と日常運営の完成を区別 | +| 縦移動 | Path側にConnector・Queue等の部品が存在 | 階間の通常隣接経路抑制、建設との接続、昇降・乗降、保存復元の統合 | +| 見学版 | 実施設7件と通路、従業員4人、自然来客が動く | 見学は食材32個を用意した保存なしの再現場面。通常ゲームの完成とは区別 | +| 運営UI | 建設・掘削・雇用候補・施設情報・イベント選択・手動保存等 | 配属・勤務変更、仕入れ・在庫、価格・営業方針、比較しやすい損益、保存枠等 | +| 長期検証 | 24時間の統合検証、14日の運営境界比較等は実装記録あり | 全AIを含む20,160分の実Tick、各A/B比較、実描画を伴う負荷確認 | +| 制作管理 | ローカル版と元Drive版が存在 | 現状GitにHEADがなく制作ファイルが未追跡。作業基準点と復旧手順を確立 | + +直前の外観改修では起動確認とUI・カメラの359項目が通過した。この計画作成時に全システムテストを再実行したわけではなく、359項目の結果も14日MVPやWindows性能の合格を意味しない。 + +[IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) と [PLAYTEST_GUIDE.md](PLAYTEST_GUIDE.md) には旧画面の説明が残っている。現在の外観・操作は [PIXEL_TOWER_VISUALS.md](PIXEL_TOWER_VISUALS.md) を参照し、P0で資料を揃える。 + +## 3. 段階別の制作計画 + +| 段階 | 目的・主な成果 | 依存 | 完成条件 | +|---|---|---|---| +| P0 制作の基準点 | ローカル正本、Git基準コミット、再現手順、仕様と現状の差分一覧 | なし | 同じ版を復元して起動・試遊でき、優先課題に根拠と確認方法がある | +| P1 最初の宿を経営 | 通常開始、建設、雇用と配属、厨房・仕入れ・配送、価格、日次収支、保存 | P0。単一階部分はP2と並行可 | デバッグ操作や見学用在庫に頼らず、画面操作だけで3営業日を運営・保存再開できる | +| P2 上下移動と混雑 | 客用・業務用の基本昇降機、階間経路、乗降と行列、混雑の可視化 | P0 | 3階以上で実乗降し、設備・動線の改善で待ち時間と離脱が減る | +| P3 暮らしと導入体験 | 歩行・接客・食事・清掃・運搬等の動作、施設美術、音、序盤ガイド、使いやすさ | 素材調査はP0から。完成判定はP1・P2後 | 初見30分で建設・雇用・営業・動線改善を一巡し、人物の行動と問題の理由を理解できる | +| P4 The Tree固有の判断 | 地下掘削と営業、FRONT/BACK改善、昼夜シフト、Heatと監査、人物関係 | P1・P2。P3と反復 | 合法/地下、表/裏、人物配置の選択に観測できる利点と代償がある | +| P5 14日MVP | ランク1~3、16施設、人物・客データ、14日シナリオ、調整と受入 | P1~P4 | 既存MVP合格条件とS14実Tick・A/B比較・保存復元・描画性能を満たす | +| P6 製品版への拡張 | ランク4~7、拡張施設、研究・投資・探索等、物語と結末、正式素材 | P5と試遊評価 | 既存製品仕様の必要機能と本編が通しで遊べ、後半にも新しい判断がある | +| P7 配布・発売準備 | Windows配布物、設定、セーブ枠、互換性、長期QA、製品情報・素材整理 | 配布試験はP1から。出荷判定はP6後 | クリーンな対象PCで導入・起動・継続でき、出荷を妨げる不具合がない | + +最短の依存経路は P0 → P1+P2 → P3の序盤合格 → P4 → P5 → P6 → P7。アート・音・検証シナリオの準備は、依存するルールが固まったものから並行する。 + +## 4. 優先タスクと実装単位 + +### P0:基準点を残す + +- **BASE-01** 現行ローカル版の変更一覧・生成物・外部素材ライセンスを確認し、Git初期コミットとバックアップ復旧手順を用意する。元Drive版は照合が終わるまで復旧候補として残す。 +- **BASE-02** 通常開始・見学・テスト・Windows書き出しのコマンドを整理し、現状記録と試遊ガイドをドット版へ更新する。 +- **BASE-03** 「実装済み」「画面へ未接続」「仕様との相違」「未検証」を分けた課題台帳を作る。UIの準備中項目を製品機能の完成として数えない。 + +### P1:通常プレイで運営を完結させる + +- **PLAY-01** 初期資金・資材で成立する序盤手順を検証する。受入用の初期施設一式と通常初期予算の整合を確認し、配置済み施設・初期支給・建設費の扱いを明記する。受付、客室、食堂、厨房、必要な通路と従業員を、通常の建設・雇用コマンドで整える。 +- **PLAY-02** 雇用後の施設配属、再配属、日勤/夜勤、休憩、人員不足の確認を画面から操作できるようにする。 +- **PLAY-03** 仕入れ→倉庫→厨房→接客施設→消費の一巡を確立する。発注費、欠品、容量超過、期限切れ、配送失敗を同じ経営ループで扱う。 +- **PLAY-04** 食堂の消費物と厨房の生産物を仕様・データ・実装で揃える。客の注文を前提としない厨房生産の実行経路を接続する。現状の食材消費と、仕様の料理ポイント/食器の差を調べ、採用方式と移行を設計判断に残す。 +- **PLAY-05** 価格、営業/休業、施設別売上・費用、給与、未払、前日差を操作・比較できるようにする。受付の直接収入の有無、宿泊の入金時点、各施設の価格・所要時間、維持費等の支出・分類も正本と揃える。「お金が減った理由」を確認できるUIを作る。 +- **PLAY-06** 途中保存と再起動後の続行を通常フローに組み込み、建設中・配送中・行列中の状態を検証する。 + +最初に翌朝までの1営業日を通し、昇降機の実装と並行して仕入れ・生産・配送を広げる。段階全体の完成条件は、見学用コマンドや無料補充を使わず3営業日を続けられ、欠品・人手不足・経路不足に対処できること。黒字にできる基準手順と、改善を怠ると悪化する比較手順を両方残す。 + +### P2:The Tower SPを参考にした縦移動の核 + +- **MOVE-01** 同一階の徒歩、垂直接続、FRONT/BACK接続の経路規則を整理する。設備がない階へ隣接セルだけで通れてしまう経路を抑制する。既存の通路・保存との互換方針を先に決める。 +- **MOVE-02** 昇降路・停止階・車両定員・待機要求を扱う最小運行モデルを作り、既存PathのQueue部品へ接続する。最初は基本運行1方式とし、高度な複数方式・ワープはMVP外とする。 +- **MOVE-03** 施設建設・延伸・撤去・客用/業務用の権限を統合する。建てられるが乗れない状態を残さない。 +- **MOVE-04** 待つ→乗る→昇降→降りるを描画する。乗客が徒歩と車内に同時表示されないよう、運行状態に結び付ける。 +- **MOVE-05** 停止・満員・撤去・経路変更・保存途中の乗客を検証する。永久行列、重複乗車、閉じ込めを解消する。 +- **MOVE-06** 同じ需要・施設数・乱数で、移動設備の改善前後を比較する。人物の数を減らさずに待ち時間が改善することを画面と数値で示す。 + +この段階で、階数を増やすほど移動設計が重要になることを確かめる。移動が面白くなる前に、多数の施設や後半シナリオを量産しない。 + +### P3:眺める楽しさと初見の遊びやすさ + +- **LIFE-01** 歩く/待つ/利用する/眠る/清掃する/運ぶ/休憩するの意味を、実状態と動作一覧で定義する。向き、足元、利用位置、車内表示の基準を固定する。 +- **LIFE-02** 共通人物で専用の歩行・作業フレームを先行制作し、縮小・拡大・混雑で確認してから職種・客層へ展開する。髪色や衣装だけでなく、仕事道具と行動で識別できるようにする。 +- **LIFE-03** MVP26施設を「外形と家具で種類が分かる」「営業・混雑・故障が分かる」という順で仕上げる。世界樹は成長・地下・昼夜による変化を加える。 +- **LIFE-04** Kenneyを優先してUI音・環境音・汎用素材を選び、足りない固有部分を制作する。音量設定を用意し、頻繁な効果音が重ならないよう調整する。 +- **UX-01** 建設可能範囲、費用、接続、必要人員、欠品の案内を揃える。配属・在庫・会計など、日常の判断を少ない操作で行えるようにする。 +- **UX-02** 建築と経営の序盤ガイドを作る。既存のDay 1~5解禁表との整合をとり、説明の再閲覧、スキップ、失敗からの回復を用意する。 +- **UX-03** マウスに加え、トラックパッドや中ボタンのない環境でもパン・ズームできる代替操作を整える。日本語可読性、拡大時のドット、色以外の状態表示を確認する。 + +完成条件は初見試遊で「建てる→人が来る→困りごとを見つける→改善する」を30分以内に経験できること。操作が分からず止まった箇所と、判断に必要だった情報を記録して改善する。 + +### P4:地下・裏方・人物関係を結び付ける + +- **TREE-01** ランク1→2→3、地下掘削、支え、施設解禁の導線を接続する。地下とBACKを混同しない表示と説明を作る。 +- **TREE-02** 裏方の搬送・厨房・勤務経路を改善すると、表の混雑や接客待ちも改善するよう、同一条件の比較で検証する。 +- **TREE-03** 地下営業の追加収益、Heat、監査予告、対策費を見える形でつなぐ。地上・地下のどちらか一方だけが常に有利になる調整を避ける。 +- **TREE-04** MVPのヒロイン1人・ライバル1人について、配置・同一勤務・接触からイベントが生まれる実接続を完成させる。 +- **TREE-05** 人物配置の事前警告、関係変化の理由、重要分岐の確認、イベント後の復帰を一巡させる。本文・肖像・会話演出は少数イベントで形式を決める。 +- **TREE-06** 成人向けコンテンツの設定と代替イベントを接続し、設定を変更しても14日の進行が途切れないことを確認する。 + +### P5:14日MVPを合格させる + +範囲は既存仕様に合わせる。14ゲーム日、ランク1~3、地上・境界・地下、FRONT/BACK、施設16種、客8クラス×4種族、自由雇用候補20人、同時活動客100人、描画試験の従業員30人、ヒロイン1人とライバル1人、昼夜勤務、清掃・物流・警備・Mana、決算・監査・保存・コンテンツ設定を対象とする。 + +- **MVP-01** Day 1~3の開業、Day 4~6の混雑、Day 7の保存、Day 8~10のBACK改善、Day 11~13の地下営業、Day 14の結果を通す。 +- **MVP-02** 全AI・イベント・監査・経路移動を含め20,160分を省略せず実行する。途中保存を挟んでも最終状態が一致することを確認する。00:00の暦日境界と06:00の営業日決算境界は、両方を独立した保存再開試験として残す。 +- **MVP-03** [MVP_ACCEPTANCE_SCENARIOS.md](MVP_ACCEPTANCE_SCENARIOS.md) の混雑・BACK・合法/地下・関係の比較を満たす。特に混雑改善の移動P95 15%以上短縮、BACK物流の中央値20%以上短縮などの既存境界を使う。 +- **MVP-04** 具体的なWindows 11基準機を決め、100客+30従業員の実描画を計測する。既存受入は1280×720、x1/x4、平均55fps以上・1% low 45fps以上・入力反映100ms以下である。製品仕様の1920×1080で通常60fps・最低30fpsの目標は別に測る。停止時間、保存サイズと日次処理も確認する。シミュレーションのみの負荷試験を描画性能の代用にしない。 +- **MVP-05** 初見プレイヤーが30分以内に「地上/地下」と「FRONT/BACK」の違いを説明でき、14日完走後に自分の改善判断を振り返れるか試遊する。 + +### P6・P7:製品版を作って配布できる形にする + +MVP後、試遊で残った課題と既存の製品仕様を照合して後半を区切る。全体エンディングは仕様§3.1の3種と§3.6の4種で記述が異なるため、台本量を確定する前に整理する。ランク4~7、拡張施設、研究・投資・探索、人物と物語、各結末を、遊べる章またはランク帯ごとに追加する。ヒロイン全員や大量のイベントを一括発注する前に、1人分の実装・文章・アート・QAの制作量を測る。 + +タイトル画面、ニューゲーム、続きから、設定、手動10枠と自動3世代の保存UI、回復候補の提示、操作説明、クレジットを製品フローへ統合する。Windows書き出し・入力・日本語表示・初回起動・保存先・DPI・低性能GPUの確認は小規模なうちから行い、最終段階で初めて試さない。 + +仕様の時間倍率から、14日は停止なしのx1で168分、x4で42分になる。本編20~30時間と14日MVPは別の検証単位とし、操作・観察・会話・待ち時間を分けて実測する。後半を単なる資金待ちや同じ建築の反復で引き延ばさない。 + +販売先、価格、正式タイトル、音声・翻訳、製品用の告知素材は別途決定する。選定した配布先の最新要件は、その時点で確認する。計画作成のために発売日や販売先を推測して固定しない。 + +## 5. 並行作業と変更の進め方 + +- **本体実装**は一度に主機能を1つ進める。共有されるRuntime・Path・保存形式の変更は順序を揃えて統合する。 +- **素材制作**は、確定した表示寸法・状態一覧から着手する。Kenney調査、ライセンス記録、共通動作、固有素材の順に進める。 +- **QA・試遊**は実装と並行して確認シナリオを準備する。機能変更では意味のある失敗・再開・保存ケースを確認し、単純な装飾変更に過剰なテストを増やさない。 +- 各作業は「問題・プレイヤーへの変化・依存・担当ファイル・完成条件・確認結果」を残す。レビュー後に小さく統合し、動く基準点を維持する。 +- 大きいMain/WorldPreview/Runtimeは追加する機能の境界に合わせて少しずつ分割する。全体を書き直す作業を前提にしない。 +- 機能追加のたびに、通常開始と見学の両方を確認する。見学だけで動く実装を通常ゲームの完成としない。 + +## 6. 優先するリスクと対応 + +| リスク | 対応する段階・方針 | +|---|---| +| 上下の通路が昇降機を迂回し、移動設計に意味が出ない | P2で階間経路の規則を先に直す。保存互換と閉じ込め復旧を同じ変更で扱う | +| 本体には処理があっても画面から使えない | P1の通常開始3日運営を基準に、配属・仕入れ・経営操作を優先する | +| 仕様と仮データがずれたまま施設を増やす | P1で厨房・食堂の入出力と役割を揃え、安定した型から横展開する | +| 見た目の完成と長期運営の完成を混同する | 各段階の完成条件、実Tick受入、初見試遊を別々に記録する | +| ドット表現が増えると描画が重くなる | P3から可視範囲・更新頻度・動作数を計測し、P5でWindows実機の負荷条件を満たす | +| 施設・人物・文章の量産が先行して修正量が膨らむ | 基本運行1方式、MVP26施設、人物関係1組で先に形式と面白さを確かめる | +| 旧版を戻せず保存データも読めなくなる | P0の版管理、保存形式変更時の移行、更新前バックアップ、途中再開テスト | + +## 7. 次回の着手順 + +1. **BASE-01~03**:現行版の復元可能な基準点と、実装/画面/仕様の差分を確定する。 +2. **PLAY-01~02、PLAY-05の最小部分**:建てる→雇う・配属する→宿泊の売上→翌朝の給与・決算を、通常画面だけで一巡させる。 +3. **MOVE-01~03**:階間経路を整理し、基本昇降機1基で建設→待機→乗車→移動→降車を接続する。 +4. **PLAY-03~04**:厨房生産・発注・実搬送を、確定した配属と会計へ接続する。ルール確定後の素材・UI部分は縦移動の制作と並行できる。 +5. **MOVE-04~06、PLAY-05~06**:断面アニメーション、混雑改善、停止・撤去・保存再開、3営業日の継続運営を確認する。 +6. **LIFE-01~04、UX-01~03**:専用動作と説明・音・日常操作を整え、初見30分の試遊へ渡す。 + +最初のレビューは「通常操作だけで初売上と翌朝の決算を迎える宿」。次のレビューは「3階の宿を客が昇降機で往復し、混雑を改善できる場面」とする。機能・見た目・操作を一緒に確かめ、以後の日程と素材制作量を更新する。 diff --git a/docs/FACILITY_DATA_DESIGN.md b/docs/FACILITY_DATA_DESIGN.md new file mode 100644 index 0000000..e6caf4f --- /dev/null +++ b/docs/FACILITY_DATA_DESIGN.md @@ -0,0 +1,195 @@ +# 施設詳細データ設計 + +- 文書ID: DATA-FACILITY-001 +- 版: 0.2.0 +- 状態: 実装基準 +- 最終更新日: 2026-08-27 + +## 1. 目的と正本範囲 + +本書は、施設の静的定義、設置後の可変状態、日次・累計統計を分離し、施設追加やバランス変更でセーブデータを壊さないための正本である。ゲームルールは`GAME_SPECIFICATION.md`、実行順と障害時処理は`SYSTEM_SPECIFICATION.md`を優先する。 + +施設の数値は次の3層へ分ける。 + +| 層 | 所有者 | 例 | 保存 | +| --- | --- | --- | --- | +| Definition | コンテンツ | サイズ、建設費、提供サービス、必要職種 | `.tres`。原則不変 | +| Instance | セーブデータ | 座標、レベル、耐久、配置職員、行列、現在Policy | Snapshotへ保存 | +| Statistics | 運営履歴 | 当日売上、処理客数、平均待ち時間、事故数 | 日次締めで集約して保存 | + +Definitionへ「現在の汚れ」や「今日の売上」を置かず、Instanceへ表示名や基礎価格を複製しない。実行中サービスだけは開始時の価格、品質、所要時間、PolicyをSnapshotとして保持する。 + +## 2. Schema Version + +| version | 用途 | 検証 | +| --- | --- | --- | +| 1 | 既存施設の互換読込 | 従来必須値のみ | +| 2 | 詳細施設定義 | 本書の詳細項目を厳密検証 | + +`definition_schema_version = 2`を宣言した定義は、不足値を暗黙補完しない。古い定義はversion 1のまま読み込めるため、施設を1件ずつ検証して移行できる。versionを上げるだけの一括変換は禁止する。 + +## 3. FacilityDefinition version 2 + +### 3.1 識別・分類 + +| フィールド | 型 | 規則 | +| --- | --- | --- | +| `id` | StringName | 小文字snake_case、永続的に変更しない | +| `display_name` | String | UI表示名 | +| `description` | String | プレイヤー向け説明 | +| `category` | StringName | `lodging`、`guest_service`、`production`等 | +| `service_types` | Array[StringName] | 施設が提供できるサービスID。1件以上 | +| `default_service_type` | StringName | `service_types`内の既定サービス | +| `service_profiles` | Dictionary | 全service typeに1対1で対応 | +| `tags` | Array[StringName] | 検索、隣接、ルール判定用の一意なID | + +### 3.2 配置・接続 + +| フィールド | 規則 | +| --- | --- | +| `zone` / `layer` | 地上・境界・地下とFRONT・BACKを別軸で指定 | +| `footprint` | 未回転状態の幅・高さ。各辺1以上 | +| `allowed_rotations` | 0/90/180/270の重複なし配列 | +| `guest_access_offsets` | 客がサービスへ入るFootprint内セル | +| `staff_access_offsets` | 職員・物流が入るFootprint内セル | +| `required_connection_tags` | 稼働に必須の通路・ネットワーク種別 | +| `preferred_adjacency_tags` | 効率や選好の加点候補 | +| `forbidden_adjacency_tags` | 配置拒否または重大減点となる隣接 | + +Access offsetは未回転Footprintの左上を`(0, 0)`として記述し、配置時にoriginとrotationからWorld座標へ変換する。通過設備を除く施設は、客用または職員用Accessを最低1つ持つ。 + +### 3.3 建設・維持・成長 + +`construction_cost`はGoldとMaterials、`build_minutes`は工期、`weight_per_cell`は構造負荷を表す。`demolition_refund_percent`は未予約資産に対する標準返却率で、事故・強制撤去補正は実行時に別途適用する。 + +`max_level`は1~5、`upgrade_costs`はLv1→2から順に正確に`max_level - 1`件を持つ。アップグレード後もDefinitionは変えず、Instanceの`level`とレベル別補正式で性能を求める。 + +維持費は`maintenance_gold`、Manaは`maintenance_mana`、停電時の配電順は`power_priority`で表す。 + +### 3.4 人員・営業 + +`staff_min`未満では新規サービスを開始せず、`staff_max`を超える職員を稼働人員へ数えない。`required_roles`は必要職種、`primary_skill`は品質計算の主技能である。 + +`opening_schedule`は次の3キーだけを持つ。 + +| キー | 意味 | +| --- | --- | +| `morning` | 06:00の日次境界から始まる朝営業 | +| `day` | 昼営業 | +| `night` | 夜営業 | + +日次締めは06:00に行い、同じ境界で前夜営業を閉じて朝営業へ切り替える。具体的な時刻帯はClockの正本に従い、Definitionへ時刻を重複保持しない。 + +### 3.5 Service Profile + +各`service_profiles[service_type]`は次の5項目を必須とする。 + +| 項目 | 型・範囲 | 意味 | +| --- | --- | --- | +| `duration_minutes` | int、1以上 | 1回の基礎所要時間 | +| `base_quality` | int、0~100 | 人員・環境補正前の品質 | +| `base_price` | int、0以上 | Policy補正前の基礎価格 | +| `capacity_units` | int、1以上 | 同時処理人数または1バッチの生産単位 | +| `need_effects` | Dictionary[StringName, int] | 欲求ごとの変化量、各-100~100 | + +`need_effects`は負値を欲求不足の解消、正値を悪化として統一する。厨房のような中間生産は、客の欲求を直接変えないため0を指定し、実際の在庫産出は`inventory_outputs`で表す。 + +例: + +```gdscript +service_types = [&"check_in", &"check_out"] +default_service_type = &"check_in" +service_profiles = { + &"check_in": { + &"duration_minutes": 8, + &"base_quality": 60, + &"base_price": 20, + &"capacity_units": 1, + &"need_effects": {&"sleep": 0}, + }, + &"check_out": { + &"duration_minutes": 6, + &"base_quality": 60, + &"base_price": 0, + &"capacity_units": 1, + &"need_effects": {&"sleep": 0}, + }, +} +``` + +旧`base_service_minutes`、`base_quality`、`base_price`、`capacity`はversion 1と現Runtimeの互換入口として当面残す。version 2の新規処理はservice profileを正本とし、移行期間中は既定profileと旧値の差分をテストで監視する。 + +### 3.6 在庫・環境・リスク + +- `inventory_inputs` / `inventory_outputs`: 1サービスまたは1バッチの予約・産出量。 +- `cleanliness_decay_per_service` / `noise_per_service` / `heat_per_service`: 完了時の発生値。 +- `beauty_effect` / `security_effect` / `concealment_effect`: 設置中の環境寄与。 +- `incident_weights`: 事故IDと非負の相対重み。確率そのものではない。 + +### 3.7 Policy + +`policy_schema`はPolicy IDごとに型と既定値を宣言する。 + +```gdscript +&"reservation_only": {&"type": &"bool", &"default": true} +&"price_mode": {&"type": &"enum", &"default": &"standard"} +&"quality_target": {&"type": &"int", &"default": 55} +``` + +現在の必須キーは`type`と`default`で、typeは`bool`、`enum`、`int`のいずれかとする。選択肢、最小・最大、解禁Level、変更猶予は次のSchema拡張で追加し、未知キーを読込側で勝手に解釈しない。 + +### 3.8 客層・成人向け安全境界 + +`allowed_guest_tags`と`forbidden_guest_tags`は候補抽出の入口であり、予約・入室・サービス開始の3地点で再検証する。`adult_content = true`の施設は必ず`adult_only`タグと、一般向け表示へ置換できる`content_replacement_id`を持つ。置換先は成人向け施設を参照してはならない。 + +### 3.9 解禁と統計 + +`unlock_rank`は1~7、`research_requirements`は全件満足を原則とする。`statistics_keys`は施設固有の収集項目を宣言し、共通の売上・稼働率と分離する。定義に存在しない統計キーをセーブから消さず、旧版履歴として読取専用保持する。 + +## 4. FacilityInstance + +Instanceは最低限、次を持つ。 + +- 識別: `instance_id`、`definition_id`、`created_at` +- 配置: `origin`、`rotation`、`occupied_cells` +- 成長: `level`、`durability` +- 稼働: `state`、`state_reasons`、`opening_override` +- 人員: `assigned_staff_by_shift`、`active_staff_ids` +- 接客: `queue_entries`、`active_services` +- 運営: `policy_current`、`policy_pending` +- 資源: `local_inventory_id` +- 集計: `statistics_today`、`lifetime_statistics` + +Instanceは必ず`definition_id`から現行Definitionを引き、Definition変更で処理中サービスが変わらないよう、開始済み`ServiceInstance`だけは算出済みSnapshotを持つ。 + +## 5. 日次履歴 + +06:00の日次締めで`statistics_today`から`FacilityDaySummary`を生成する。推奨キーは次の通り。 + +- `business_date`、`facility_instance_id`、`definition_id`、`level` +- `open_minutes`、`active_minutes`、`downtime_by_reason` +- `services_started`、`services_completed`、`services_failed` +- `guest_processed`、`average_wait_minutes`、`average_quality` +- `gross_revenue`、`refunds`、`maintenance_cost`、`net_contribution` +- `inventory_consumed`、`inventory_produced`、`spoilage_quantity` +- `incidents_by_type`、`heat_generated` + +日次履歴は締め後に書換えず、修正は訂正レコードで行う。Definitionのバランス変更後も過去の売上や品質を再計算しない。 + +## 6. 検証と失敗方針 + +Definition Registryは起動時に全定義を検証し、致命的エラーの定義を検索・建築候補へ公開しない。エラーはcode、definition ID、resource path、field、messageを持ち、順序を決定的にする。 + +version 2では、ID形式、Access範囲、Upgrade件数、営業時間キー、Service Profile、Policy型、成人向け置換境界を必須検証する。欠損を0や空配列へ補完して稼働させない。 + +## 7. 現在の移行状況 + +| 施設 | version | 詳細化の狙い | +| --- | --- | --- | +| 受付 | 2 | 複数サービス、客・職員Access、価格Policy | +| 厨房 | 2 | BACK生産、入力・出力、バッチ単位 | +| 成人向け個室 | 2 | 夜営業、客層制限、成人向け置換、リスク | +| その他MVP 13施設 | 1 | 互換読込中 | +| 拡張16施設 | 1 | 通常パレット未解放、順次移行 | + +次の移行順は、一般客室・食堂・酒場・浴場、倉庫・警備、通路・昇降機、地下拡張施設とする。売上施設を先に移行すると、客AI・価格・在庫の縦断検証を早い段階で固定できる。 diff --git a/docs/FACILITY_LIST.md b/docs/FACILITY_LIST.md new file mode 100644 index 0000000..663773b --- /dev/null +++ b/docs/FACILITY_LIST.md @@ -0,0 +1,125 @@ +# 施設一覧・レイヤー分類 + +## 1. 分類ルール + +本作では「地上/地下」と「FRONT/BACK」は別の概念とする。 + +- **地上/境界/地下**:世界樹内の物理的な高さ。`zone`で制約する。 +- **FRONT**:客が利用・目撃できる営業面。 +- **BACK**:従業員、物流、警備、設備だけが利用する裏面。 + +したがって、地下にある客向け酒場は「地下・FRONT」、地上にある厨房は「地上・BACK」である。保存データと経路判定では、この2軸を混同しない。 + +状態記号: + +- **実装済**:MVP定義へ登録され、建設・保存・施設Systemから参照可能。 +- **制限あり**:定義済みだが、専用Systemが未完成で一部機能を制限している。 +- **拡張定義済**:追加用データ、数値、配置制約まで定義済み。MVPの既存Saveを守るため通常建築パレットにはまだ出さない。 + +## 2. 地上層・一般客向けFRONT施設 + +### 現行MVP + +| 施設 | ゾーン | サイズ | 主な役割 | 状態 | +|---|---|---:|---|---| +| 受付 | 境界 | 4×2 | チェックイン、精算、来客導線の起点 | 実装済 | +| 一般客室 | 地上 | 2×1 | 基本宿泊 | 実装済 | +| 大衆食堂 | 任意 | 4×2 | 低価格の食事、大人数処理 | 実装済 | +| 大浴場 | 任意 | 6×2 | 清潔・休息需要 | 実装済 | +| 酒場 | 任意 | 4×2 | 夜営業、飲食、交流 | 実装済 | +| マッサージ室 | 任意 | 2×1 | 疲労・ストレス回復 | 実装済 | +| 客用通路 | FRONT | 1セル | 客動線 | 実装済 | +| 客用昇降機 | FRONT | 2×2 | 客の上下階移動 | 制限あり:垂直Topology待ち | + +`任意`の施設は地下FRONTにも設置できるが、成人限定施設ではない。一般客向けとして地上へ置くのが標準運用となる。 + +### 追加拡張 + +| 施設 | ゾーン | サイズ | 主な役割 | 追加理由 | +|---|---|---:|---|---| +| 上級客室 | 地上 | 3×2 | 富裕客・長期滞在、高単価宿泊 | 客室の価格帯を作る | +| 喫茶ラウンジ | 地上 | 3×2 | 朝昼の休息、軽食、交流 | 朝昼営業の弱さを補う | +| 売店 | 境界 | 3×2 | 旅用品、薬、土産販売 | 宿泊以外の客単価を増やす | +| 診療所 | 任意FRONT | 3×2 | 客・従業員の治療 | Medical需要と負傷回復を接続する | +| 冒険者窓口 | 境界 | 3×2 | 依頼、探索、護衛、情報 | 宿経営と探索Systemを接続する | + +## 3. 地下層・客向けFRONT施設 + +地下FRONTは、客が利用する営業空間だが、地上一般層より夜間・高単価・限定・裏社会需要が強い。BACKと異なり、客の経路、待機列、満足度、目撃、監査対象になる。 + +### 現行MVP + +| 施設 | ゾーン | サイズ | 主な役割 | 状態 | +|---|---|---:|---|---| +| 成人向け個室 | 地下 | 2×1 | 年齢確認済み成人向け予約サービス | 実装済 | + +### 追加拡張 + +| 施設 | ゾーン | サイズ | 主な役割 | リスク・特徴 | +|---|---|---:|---|---| +| 地下客室 | 地下 | 2×1 | 夜行性種族、静けさを好む客の宿泊 | 地下一般客の滞在基盤 | +| 地下貸切浴室 | 地下 | 3×2 | 少人数・高品質の予約入浴 | 高単価、低回転 | +| 地下酒場 | 地下 | 3×2 | 夜間交流、希少酒、裏社会客 | 警戒と騒音が増えやすい | +| 闇市取引所 | 地下 | 3×2 | 希少品と高利益取引 | Heat・Suspicion・監査リスク | +| 地下VIPサロン | 地下 | 4×2 | 成人富裕客向け高級限定サービス | 年齢確認、複数人員、高価格 | + +地下貸切浴室と地下客室は成人限定ではない。`adult_only`が付くのは成人向け個室と地下VIPサロンだけとする。 + +## 4. 裏レイヤー・従業員用BACK施設 + +BACK施設は客が直接利用せず、FRONT施設の処理能力、清潔、在庫、安全、魔力を支える。地上にも地下にも置ける施設と、地下へ固定する設備を分ける。 + +### 現行MVP + +| 施設 | ゾーン | サイズ | 主な役割 | 状態 | +|---|---|---:|---|---| +| 厨房 | 任意BACK | 4×2 | 食材から料理を生産 | 実装済 | +| 倉庫 | 任意BACK | 4×2 | 食材・資材・装備の保管 | 実装済 | +| 待機室 | 任意BACK | 4×2 | 従業員の待機と短時間休憩 | 実装済 | +| 警備詰所 | 任意BACK | 2×2 | 事故・喧嘩・侵入への出動 | 実装済 | +| 裏通路 | BACK | 1セル | 従業員・物流・救護経路 | 実装済 | +| 業務用昇降機 | BACK | 3×2 | 人員・物資の上下階搬送 | 制限あり:垂直Topology待ち | +| 従業員ドア | FRONT↔BACK | 1×1 | 職員限定の層間接続 | 実装済 | + +### 追加拡張 + +| 施設 | ゾーン | サイズ | 主な役割 | 追加理由 | +|---|---|---:|---|---| +| 洗濯室 | 任意BACK | 3×2 | 使用済み布類から清潔なリネンを生産 | 客室・浴場の清潔ループを作る | +| 魔法冷蔵庫 | 任意BACK | 3×2 | 食材保存、期限切れ損失の軽減 | 厨房物流を深くする | +| 魔力制御室 | 地下BACK | 3×2 | 蓄積、優先配電、Brownout対策 | Mana Networkの中核にする | +| 整備工房 | 任意BACK | 3×2 | 修理、部品、建築支援 | 耐久・故障Systemを支える | +| 従業員宿舎 | 地上BACK | 4×2 | 睡眠、長時間休息、夜勤交代 | Shiftと疲労を施設へ接続する | +| 廃棄物処理室 | 地下BACK | 3×2 | 廃棄物、汚れ、事故リスクの抑制 | 清潔と物流に出口を作る | + +## 5. 接続設備の扱い + +| 設備 | 接続 | 利用者 | 注意 | +|---|---|---|---| +| 客用通路 | FRONT内 | 客、接客中従業員 | 主要占有と共存するROUTE subslot | +| 裏通路 | BACK内 | 従業員、物流、救護 | 職員専用ROUTE subslot | +| 従業員ドア | FRONT↔BACK | 従業員 | 同座標の両Layerを結ぶ | +| 客用昇降機 | FRONTの上下階 | 客、接客中従業員 | 有限Queueと停止時迂回が必要 | +| 業務用昇降機 | BACKの上下階 | 従業員、物資、救護 | 高容量・高優先の設備 | + +## 6. 実装優先順位 + +1. **診療所、洗濯室、魔法冷蔵庫**:既存のMedical、Inventory、期限、清潔へ接続しやすい。 +2. **上級客室、喫茶ラウンジ、地下客室**:一般経営と朝昼夜の収益差を増やせる。 +3. **魔力制御室、整備工房、従業員宿舎**:Mana、耐久、疲労の運用を深める。 +4. **冒険者窓口**:探索Systemの画面と報酬構造が必要。 +5. **地下酒場、闇市、地下VIPサロン**:Heat、Evidence、監査、成人確認を完全接続してから解放する。 +6. **客用/業務用昇降機**:垂直Topology完成後に建設制限を解除する。 + +## 7. アセット制作順序 + +追加施設Iconは2048×2048の別4×4 Atlasとして制作する。並び順は次の通り。 + +| 行 | 列1 | 列2 | 列3 | 列4 | +|---|---|---|---|---| +| 1 | 上級客室 | 喫茶ラウンジ | 売店 | 診療所 | +| 2 | 冒険者窓口 | 地下客室 | 地下貸切浴室 | 地下酒場 | +| 3 | 闇市取引所 | 地下VIPサロン | 洗濯室 | 魔法冷蔵庫 | +| 4 | 魔力制御室 | 整備工房 | 従業員宿舎 | 廃棄物処理室 | + +正式Iconがない間は通常建築パレットへ追加しない。既存Iconの流用によって施設を誤認させない。 diff --git a/docs/GAME_SPECIFICATION.md b/docs/GAME_SPECIFICATION.md new file mode 100644 index 0000000..1aca9f9 --- /dev/null +++ b/docs/GAME_SPECIFICATION.md @@ -0,0 +1,1437 @@ +# The Tree ゲーム仕様書 + +- 文書ID: GDD-CORE-001 +- 版: 0.1.1 +- 状態: 実装基準案 +- 対象エンジン: Godot 4.7系 +- 対象プラットフォーム: Windows PC +- 最終更新日: 2026-08-27 + +## 0. 文書の位置づけ + +本書は『The Tree』のゲームルールに関する正本である。実装、データ、UI、シナリオが食い違う場合は、次の優先順位で判断する。 + +1. 本書 +2. SYSTEM_SPECIFICATION.md +3. DESIGN_DECISIONS.md +4. ARCHITECTURE.md +5. Godotプロジェクト内のデータ定義 +6. 旧仕様書4文書 + +旧仕様書はアイデア資料として保存するが、直接の実装根拠にはしない。内部状態、処理順、不変条件、復旧方法はSYSTEM_SPECIFICATION.mdに従う。本書を変更する場合は、変更理由をDESIGN_DECISIONS.mdへ追記する。 + +「必須」は製品要件、「原則」は例外を設計判断記録に残す要件、「調整値」はプレイテストで変更可能な値を意味する。 + +--- + +# 1. 製品定義 + +## 1.1 基本情報 + +| 項目 | 仕様 | +| --- | --- | +| 仮題 | The Tree | +| 開発コード | Project Yggdrasil | +| ジャンル | 2D世界樹経営シミュレーション/タワービルディング/成人向け | +| プレイ人数 | 1人 | +| 視点 | 2Dサイドビュー断面図 | +| 入力 | マウス中心、キーボードショートカット併用 | +| メインクリア時間 | 20~30時間 | +| 全要素回収 | 50時間以上 | +| 基準解像度 | 1920×1080、最低1280×720 | +| レーティング方針 | 成人のみを対象とするR18作品 | + +## 1.2 一文での定義 + +世界樹の上では信用を、下では欲望を育て、表の接客空間と裏の物流空間を同時に設計する、自律AI観察型の宿屋経営ゲーム。 + +## 1.3 デザインの柱 + +### 柱1: 観察して間接的に導く + +客と従業員は自律的に行動する。プレイヤーは個別の移動先を連打して命令せず、施設、価格、ポリシー、動線、シフトを変更して行動を誘導する。 + +### 柱2: 二軸の建築パズル + +地上/地下は合法性と客層を決め、フロント/バックは客用空間と業務空間を決める。この二軸を同時に最適化する。 + +### 柱3: 安定と高収益の交換 + +地上営業は低リスクで信仰と評判を生む。地下営業は高収益だが、依存、治安悪化、摘発リスクを生む。どちらか一方だけでは最終到達できない。 + +### 柱4: 人間関係も経営資源 + +ヒロインとライバルの同僚関係、勤務時間、現場の安全性、主人公の介入が関係性を変える。恋愛は別画面のミニゲームではなく、配置判断の結果である。 + +## 1.4 非目標 + +- 客をRTSのように一人ずつ直接操作するゲームにはしない。 +- プレイヤー操作のアクション戦闘を主軸にしない。 +- 成人向けシーンだけを収集する線形ADVにはしない。 +- 初期版から全施設、全ヒロイン、世代交代を実装しない。 +- 性別だけを理由に能力、破産処理、労働価値を固定しない。 + +## 1.5 製品方針 + +- オフラインで完結する買い切り型を前提とする。 +- ガチャ、スタミナ課金、期間限定オンラインイベントをゲーム設計へ入れない。 +- アカウント登録を必須にしない。 +- プレイデータ送信は初期状態で無効とし、導入する場合も明示同意を必要とする。 +- MOD対応は製品版後の検討事項とし、MVPの必須要件にはしない。 + +--- + +# 2. 世界観とプレイヤー役割 + +## 2.1 舞台 + +複数国家の国境、旧魔王城跡の龍脈上に芽吹いた世界樹が舞台となる。主人公は世界樹内部を宿泊・交易拠点として開発し、国家と教会の影響下にない自治領へ成長させる。 + +地上は公的な宿、商業、宗教、外交の場となる。地下は一般社会から隠された夜間娯楽、闇取引、拘束施設などを扱う。地下であること自体が違法なのではなく、施設と営業ポリシーが違法性を決める。 + +## 2.2 主人公 + +主人公は「経営者」であり、通常の従業員ユニットではない。従業員スロットを占有せず、以下の能力を持つ。 + +| 能力 | 用途 | +| --- | --- | +| 統率 | 従業員の忠誠、交渉、現場介入 | +| 武力 | 武力決闘、防衛、威圧 | +| 商才 | 商談決闘、投資、価格交渉 | +| 洞察 | 隠し状態、嘘、脅威の発見 | + +能力値は0~100。初期値は統率30、武力25、商才25、洞察20とする。研究、ストーリー、装備、実績で上昇する。 + +## 2.3 経営者の現場介入 + +旧仕様の「主人公と同じ部屋に配置」を、経営者の現場介入として定義する。 + +- 主人公は一度に1施設へ滞在できる。 +- 移動には通常30ゲーム分を要する。専用動線により短縮できる。 +- 滞在中は施設効率に統率由来の補正を与える。 +- ヒロインとライバルが同室の場合、侵食行動を停止させる。 +- 主人公はスタッフ数には数えない。 +- アクティブポーズ中に行き先を指示できるが、移動と効果は時間再開後に処理する。 + +## 2.4 個人行動ポイント + +主人公は毎日06:00に個人行動ポイントを2得る。最大保持数は4。 + +| 行動 | 消費 | 標準所要時間 | +| --- | ---: | ---: | +| 会話 | 1 | 30分 | +| プレゼント | 1 | 30分 | +| デート | 2 | 3時間 | +| 個別訓練 | 1 | 2時間 | +| 親密イベント | 2 | 4時間 | +| 決闘の申し込み | 1 | 内容依存 | + +ポイント制により、全員へ毎日同じ行動を行う最適解を防ぐ。 + +--- + +# 3. 進行、勝利、敗北 + +## 3.1 メイン目標 + +世界樹ランク7へ到達し、最終章で「自治領の承認」「王権の奪取」「地下帝国」のいずれかを成立させることがメインクリア条件となる。 + +エンディングは経営方針、信仰、評判、警戒度、主要人物の状態、最終選択から決まる。単一の善悪メーターだけでは決めない。 + +## 3.2 世界樹ランク + +| Rank | 名称 | 主な解禁 | 必要条件の方向性 | +| ---: | --- | --- | --- | +| 1 | 芽吹き | 受付、客室、食堂、基礎動線 | チュートリアル | +| 2 | 若木 | 浴場、倉庫、シフト、地下掘削 | 評判25、資産10,000G | +| 3 | 宿場 | 酒場、娯楽、夜営業、関係性管理 | 信仰100、累計客300 | +| 4 | 市場 | 交易、地下娯楽、監査、研究2段階 | 評判45、資産50,000G | +| 5 | 都市 | VIP、外交、闘技、防衛 | 信仰500、重要クエスト | +| 6 | 聖魔樹 | 高層・深層、国家イベント | 評判65または地下影響力65 | +| 7 | 世界樹 | 最終施設、最終章 | 信仰1,500、主要章完了 | + +必要値は調整値。ランクアップ時に建築可能な高さ、深さ、横幅が拡張される。 + +Rank 4以降は、地上と地下の両方に稼働施設を要求する。Rank 7はReputation 60以上かつUnderground Influence 40以上を基本条件とする。地下は必ずしも違法施設である必要はなく、物流、Mana、合法的な夜間娯楽でも発展条件を満たせる。 + +## 3.3 敗北条件 + +### 経営破綻 + +日次決算で支払い不能になると未払金が発生し、破綻警告が1増える。翌日までに未払金を解消すれば警告は0へ戻る。3回連続で支払い不能になると経営破綻となる。 + +### 世界樹喪失 + +ストーリー防衛戦で世界樹耐久が0になった場合に発生する。通常の客トラブルだけで世界樹耐久は減らない。 + +### 物語上のバッドエンド + +明示された最終章の選択、決闘、債務契約に失敗した場合のみ発生する。通常のヒロイン一人の離脱は、経営全体の即時ゲームオーバーにしない。 + +## 3.4 継続可能性 + +メインクリア後は同じセーブで経営を続行できる。回想、施設図鑑、別ルートの解禁はニューゲームプラスでも引き継げる。 + +## 3.5 難易度 + +| 項目 | Story | Standard | Expert | +| --- | ---: | ---: | ---: | +| 初期Gold | 7,500 | 5,000 | 3,500 | +| 客の忍耐減少 | 0.75倍 | 1.00倍 | 1.25倍 | +| 維持費 | 0.80倍 | 1.00倍 | 1.20倍 | +| Heat発生 | 0.75倍 | 1.00倍 | 1.25倍 | +| 監査予告 | +6時間 | 標準 | -3時間 | +| 破綻猶予 | 5日 | 3日 | 2日 | + +難易度は経済と危機対応だけを変える。関係性ルートの内容、成人向け表現、取り返しのつかない閾値は難易度で変更せず、コンテンツ設定で管理する。 + +## 3.6 エンディングの機械条件 + +最終章到達時に条件を満たした候補だけを提示する。 + +| エンディング | 必須条件 | +| --- | --- | +| 自治領承認 | Reputation 70以上、Faith累計1,500以上、Heat 49以下 | +| 王権奪取 | World Tree Rank 7、防衛章勝利、主要同盟3件以上 | +| 地下帝国 | Underground Influence 80以上、地下収益比率50%以上、最終監査を突破 | +| 共存の世界樹 | Reputation 60以上、Underground Influence 60以上、主要融和クエスト完了 | + +ヒロインごとの純愛、NTR、堕落状態は全体エンディングへ後日談を追加する。個人ルートと国家ルートを一対一で固定しない。 + +--- + +# 4. 時間システム + +## 4.1 時間倍率 + +- x1: 現実30秒=ゲーム内1時間 +- x2: 現実15秒=ゲーム内1時間 +- x4: 現実7.5秒=ゲーム内1時間 +- 1日はx1で現実12分 +- 一時停止中も建設予約、シフト編集、情報閲覧、経営者の移動予約が可能 + +旧仕様の現実10秒=1時間は、通知確認と動線修正の時間が不足するため採用しない。 + +## 4.2 時間帯 + +| 時間 | フェーズ | 客AIの重点 | 経営上の意味 | +| --- | --- | --- | --- | +| 06:00~09:59 | 朝 | 朝食、精算、退店 | 下り動線、清掃、昼番出勤 | +| 10:00~15:59 | 昼 | 食事、買物、治療 | 安定営業、建設、納品 | +| 16:00~19:59 | 夕方 | 入浴、食事、宿泊 | 帰還ラッシュ、最大混雑 | +| 20:00~05:59 | 夜 | 睡眠、酒、娯楽、親密欲求 | 地下営業、治安、関係イベント | + +## 4.3 日の境界 + +- 日付は00:00に進む。 +- 経営上の営業日は06:00から翌日05:59までとする。 +- 日次決算は06:00に行い、終了した夜営業までを直前の営業日へ含める。 +- 06:00は、前営業日の未確定サービス確定、日次決算、昼番開始、個人行動ポイント回復、監査抽選、朝イベント、オートセーブの順で処理する。 +- 昼番は06:00~18:00、夜番は18:00~06:00。 +- 交代中の従業員は現在の接客を完了してから退勤する。 + +## 4.4 シミュレーション更新 + +| 処理 | 更新間隔 | +| --- | --- | +| 移動表示 | 毎フレーム | +| 経路追従 | 0.1秒 | +| 欲求増減 | ゲーム内1分 | +| 客の再意思決定 | ゲーム内5分。個体ごとに分散 | +| 施設収支 | サービス完了時 | +| 環境拡散 | ゲーム内10分 | +| 関係性の受動判定 | ゲーム内30分 | +| マナ収支 | ゲーム内10分 | +| 日次集計 | 06:00 | + +乱数シードと次回判定時刻はセーブ対象とし、ロードによる結果の引き直しを原則防ぐ。 + +## 4.5 同一時刻の処理順 + +同じゲーム内時刻に複数処理が発生した場合、次の順序で確定する。 + +1. 時刻境界、シフト、営業ポリシー +2. Mana生成と給電状態 +3. 在庫搬入と予約確定 +4. サービス完了と支払い +5. 欲求、疲労、環境値の更新 +6. 事故、関係、監査イベント判定 +7. 待機列の繰り上げ +8. AIの再意思決定 +9. 通知とUIスナップショット + +この順序はセーブ互換性に関わるため、変更時はschema_versionを更新する。 + +--- + +# 5. マップと建築 + +## 5.1 論理座標 + +マップは座標(X, Y, Layer)で管理する。論理Y軸は上方向を正とし、画面描画時のみGodot座標へ変換する。 + +- Y > 0: 地上ゾーン +- Y = 0: 境界ゾーン +- Y < 0: 地下ゾーン +- Layer FRONT: 客用、接客中の従業員用 +- Layer BACK: 従業員、物流、管理設備用 + +「地下のFRONTにあるカジノ」「地上のBACKにある厨房」は正しい組み合わせである。 + +## 5.2 グリッド + +- 1セルは世界設定上およそ3m四方。 +- 施設は長方形の占有セルを持つ。 +- 通路、装飾、配管は施設とは別のサブスロットで管理する。 +- 同じX,YにFRONT施設とBACK施設を置けるが、組み合わせ禁止タグがある場合は不可。 +- 覗き設備など、特定のFRONT施設を参照するBACK施設は座標一致または指定辺への隣接を要求する。 + +## 5.3 建設条件 + +全条件を満たした場合のみ建設予約できる。 + +1. 必要なGoldとMaterialsを保有している。 +2. 占有セルが空いている。 +3. 指定ゾーンとレイヤーを満たす。 +4. 地上施設は幹、枝、支持施設のいずれかに接続している。 +5. 地下施設は対象セルが掘削済みである。 +6. FRONT施設は客用通路へ接続できる。 +7. BACK施設は裏通路へ接続できる。 +8. 稼働に必要なマナ接続が存在する。 + +建設予約時に費用の25%を支払い、着工時に残額を支払う。着工前キャンセルは全額返金、着工後キャンセルは未消費資材の50%のみ返還する。 + +## 5.4 施設状態 + +施設は次の状態を一つ持つ。 + +| 状態 | 意味 | +| --- | --- | +| PLANNED | 建設予約。未着工 | +| CONSTRUCTING | 建設中 | +| CLOSED | プレイヤーが営業停止 | +| NO_ACCESS | 有効な通路がない | +| NO_POWER | マナ供給がない | +| NO_STAFF | 最低人数を満たさない | +| ACTIVE | 正常稼働 | +| DEGRADED | 汚れ、損傷、在庫不足で効率低下 | +| BROKEN | 修理完了まで停止 | + +複数の停止理由を内部に保持し、UIでは最も重大な理由を主表示する。 + +## 5.5 動線 + +### 客用通路 + +景観、清潔、騒音の影響を受ける。客と接客中従業員が利用する。 + +### 裏通路 + +客から不可視。従業員、物資、清掃、警備、救護が利用する。狭い通路ではすれ違いペナルティが発生する。 + +### 接続設備 + +従業員ドアだけがFRONTとBACKを接続する。客は通過できない。厨房から食堂へ料理を渡すカウンターなど、物資だけを接続する設備も存在する。 + +### 垂直移動 + +- 客用昇降機: 低速、容量4から開始 +- 業務用昇降機: 高速、容量8から開始 +- 階段: 待ち時間なし、3階以内を優先 +- ワープ: 高マナ消費、研究後に解禁 + +経路探索コストは、歩行時間+予測待ち時間+混雑ペナルティ+環境ペナルティで求める。 + +## 5.6 局所環境値 + +各FRONTセルは0~100の値を持つ。 + +| 値 | 主な効果 | +| --- | --- | +| 清潔 | 満足度、移動速度、感染・悪臭 | +| 景観 | 高級客の施設選択、価格許容 | +| 騒音 | 睡眠、忍耐、会話イベント | +| 治安 | スリ、喧嘩、暴動、安心感 | +| 隠蔽 | 違法施設の発見難度 | + +環境値は施設中心からマンハッタン距離で減衰する。壁、防音扉、レイヤー境界で減衰率を変える。 + +--- + +# 6. 資源と全体指標 + +## 6.1 消費可能資源 + +| 資源 | 初期値 | 用途 | +| --- | ---: | --- | +| Gold | 5,000 | 建設、給与、仕入れ、買収 | +| Mana | 100/100 | 設備稼働、魔法、ワープ | +| Materials | 50 | 建設、修理 | +| Faith | 0 | 世界樹成長、研究、儀式 | +| Personal AP | 2 | 主人公の個人行動 | + +Faithは現在値と累計獲得値を別に保存する。ランク条件は累計値を参照し、研究消費でランク進行が巻き戻らないようにする。 + +## 6.2 全体指標 + +| 指標 | 範囲 | 役割 | +| --- | ---: | --- | +| Reputation | 0~100 | 来客数、上客率、求人品質 | +| Heat | 0~100 | 監査と摘発の危険度 | +| Underground Influence | 0~100 | 闇客層、地下研究、裏エンド | +| World Tree Rank | 1~7 | 進行と建築範囲 | +| World Tree Durability | 0~100 | ストーリー防衛戦 | + +風紀度は廃止し、意味をReputation、Heat、局所治安へ分割する。宿全体の清潔度も廃止し、局所清潔度の面積加重平均をレポート値として表示する。 + +## 6.3 Mana + +- マナは容量と現在値を持つ。 +- 世界樹と発電施設がゲーム内1時間ごとに生成する。 +- 施設はゲーム内1時間換算で消費する。 +- 供給不足時は、必須、通常、贅沢の優先度順に給電する。 +- エレベーター停止中の乗客は最寄階へ非常退避し、閉じ込め状態を永続させない。 + +## 6.4 違法収益 + +通貨はGoldへ統合し、表金・裏金の二重残高は採用しない。違法な売上ごとにHeatを加算することで、同じ戦略的負担を少ないUI負荷で表現する。 + +Heat加算の基本値は以下とする。 + +- 軽度な無許可営業: 売上1,000Gごとに+0.5 +- 賭博・違法取引: 売上1,000Gごとに+1.0 +- 強制性を含む高危険営業: 売上1,000Gごとに+2.0 +- 脅迫の実行: 1件ごとに+5 +- 暴動または目撃者の逃走: 1件ごとに+8 + +隠蔽施設は発生Heatを減らさず、監査時の発見率を下げる。違法行為そのものと隠し方を別の判断にする。 + +## 6.5 指標の主な増減 + +### Faith + +- 満足80以上で初回退店した客: +1 +- 礼拝堂の儀式完了: 基礎+5 +- ストーリー奉納と実績: +10~100 +- 研究と儀式で消費 + +同一客から得る退店Faithは1ゲーム日1回まで。 + +### Reputation + +- 満足退店、VIP成功、クエスト成功で上昇 +- 不満退店、客前の事故、踏み倒し放置、監査発覚で低下 +- 日次自然変動は±3以内、重要イベントは上限外 + +### Underground Influence + +- 地下施設の初建設、地下客の高満足、闇取引、地下クエストで上昇 +- 地下施設の長期閉鎖、組織との契約破棄で低下 +- 単なる売上だけでは上昇せず、新しい客層と関係を築く必要がある + +--- + +# 7. 施設稼働モデル + +## 7.1 施設定義の必須項目 + +施設データは最低限、次を持つ。 + +フィールド型、サービス種別ごとの内訳、静的Definition/可変Instance/日次履歴の境界は`FACILITY_DATA_DESIGN.md`を正本とする。 + +- 一意ID、表示名、カテゴリ、説明 +- ゾーン、レイヤー、サイズ +- 建設費、建設時間、維持費、マナ消費 +- 最低スタッフ数、最大スタッフ数、対象スキル +- 収容数、基礎処理時間、基礎品質、基準価格 +- 提供サービス種別、既定サービス、種別ごとの時間・品質・価格・処理単位・欲求効果 +- 入出庫品、発生環境値、Heat係数 +- 必須接続、隣接ボーナス、禁止隣接 +- 解禁ランク、研究条件、ポリシー項目 + +## 7.2 効率 + +施設効率は次の式で求める。 + +Efficiency = clamp(0.25, 1.75, +0.55 ++ 平均対象スキル × 0.006 ++ 施設レベル補正 ++ 隣接補正 ++ 経営者介入補正 +- 疲労補正 +- ストレス補正 +- 環境補正 +- 在庫不足補正) + +施設レベル補正はLv1で0、Lv2で0.08、Lv3で0.16。Lv3を通常上限とし、固有施設だけLv5まで許可する。 + +サービス時間 = 基礎処理時間 ÷ Efficiency。 + +## 7.3 品質 + +Quality = clamp(0, 100, +基礎品質 ++ 平均対象スキル × 0.45 ++ 設備補正 ++ 良好環境補正 +- 疲労とストレス +- 汚れと騒音) + +品質は満足度、依存付与、事故率、価格許容へ影響する。 + +## 7.4 価格 + +価格ポリシーは以下の5段階。 + +| 設定 | 基準価格倍率 | 満足度補正 | +| --- | ---: | ---: | +| 奉仕価格 | 0.60 | +10 | +| 安価 | 0.80 | +5 | +| 標準 | 1.00 | 0 | +| 高価 | 1.50 | -5 | +| ぼったくり | 2.50 | -20 | + +品質が客の期待品質を20以上上回る場合、価格による満足度低下を半減する。 + +## 7.5 行列 + +- 施設容量を超えた客はFIFO行列へ入る。 +- VIP優先ポリシーでは一般客よりVIPを先に処理できるが、追い越された一般客の忍耐が追加で減る。 +- 推定待ち時間が客の残り忍耐時間を超える施設は、候補評価に大きなペナルティを受ける。 +- 施設停止時は全待機客へ再意思決定を即時要求する。 + +## 7.6 初期施設の基準値 + +| 施設 | 層/ゾーン | サイズ | 建設費 | 人員 Min/Max | 容量 | 基礎時間 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| 受付 | FRONT/境界 | 4×2 | 500G/8M | 1/3 | 8 | 20分 | +| 一般客室 | FRONT/地上 | 2×1 | 350G/5M | 0/0 | 2 | 宿泊 | +| 大衆食堂 | FRONT/任意 | 4×2 | 800G/12M | 2/4 | 12 | 45分 | +| 厨房 | BACK/任意 | 4×2 | 700G/10M | 1/3 | 20食 | 15分/4食 | +| 大浴場 | FRONT/任意 | 6×2 | 1,200G/16M | 1/2 | 12 | 60分 | +| 倉庫 | BACK/任意 | 4×2 | 450G/10M | 1/3 | 100枠 | 入出庫依存 | +| 待機室 | BACK/任意 | 4×2 | 400G/6M | 0/0 | 8 | 常時 | +| 警備詰所 | BACK/任意 | 2×2 | 900G/10M | 1/3 | 3 | 出動依存 | +| 酒場 | FRONT/任意 | 4×2 | 1,000G/12M | 2/4 | 14 | 50分 | +| マッサージ室 | FRONT/任意 | 2×1 | 800G/8M | 1/2 | 2 | 60分 | +| 客用昇降機 | FRONT/任意 | 2×2 | 1,500G/15M | 0/0 | 4 | 1階10分 | +| 業務用昇降機 | BACK/任意 | 3×2 | 2,200G/20M | 0/0 | 8 | 1階5分 | +| 従業員ドア | 両層接続 | 1×1 | 100G/1M | 0/0 | 1 | 通過1分 | +| 客用通路 | FRONT | 1セル | 20G/0M | 0/0 | 3密度 | 移動 | +| 裏通路 | BACK | 1セル | 25G/0M | 0/0 | 2密度 | 移動 | +| 成人向け個室 | FRONT/地下 | 2×1 | 1,500G/14M | 1/1 | 2 | 90分 | + +MはMaterialsを表す。数値は最初のバランステスト用の調整値である。 + +基準価格: + +- 一般客室: 120G/泊 +- 大衆食堂: 25G/食 +- 大浴場: 20G/回 +- 酒場: 40G/注文 +- マッサージ室: 100G/回 +- 成人向け個室: 250G/回 + +受付、厨房、倉庫、待機室、警備、動線設備は直接売上を生まず、他施設の処理能力と損失回避へ寄与する。 + +### 7.6.1 拡張施設 + +MVP16施設のSave互換性を維持したまま、地上一般向け5種、地下FRONT5種、BACK6種の追加定義を持つ。分類、基準値、導入順、追加Icon Atlas順序は`FACILITY_LIST.md`を正本とする。追加定義は専用Systemと正式Iconが揃うまで通常建築パレットへ表示しない。 + +## 7.7 在庫と物流 + +在庫はアイテムID、品質、数量、消費期限、予約数を持つスタックとして倉庫に保存する。 + +物流の流れ: + +1. 施設が必要在庫を確認する。 +2. 不足分を物流要求として発行する。 +3. 倉庫は在庫を予約し、他施設の二重予約を防ぐ。 +4. 運搬員が裏動線で搬送する。 +5. 到着時に施設ローカル在庫へ移す。 +6. 搬送失敗、施設閉鎖、経路切断時は予約を解除する。 + +物流要求の優先順位: + +1. Critical消耗品 +2. 営業中施設の欠品 +3. 予約客のある施設 +4. 通常補充 +5. 余剰移送 + +- 食堂とレストランは完成料理ではなく、厨房の料理ポイントと食器在庫を消費する。 +- 倉庫容量を超えた納品は拒否し、自動消失させない。 +- 食材は期限切れで廃棄物へ変わる。 +- 自動発注は在庫下限、発注量、最高購入単価を設定する。 +- FRONTを通る搬送は可能だが、景観と満足度にペナルティを与える。 + +## 7.8 清掃、損傷、修理 + +客とサービスは汚れポイントを生成する。汚れポイント1につき対象セルの清潔を1下げる。 + +- 清掃員は最寄りではなく、重大度÷到着時間が最大の要求を選ぶ。 +- 清掃用具が不足すると速度50%。 +- 清潔20未満で悪臭、0で施設強制停止。 +- 吐瀉物、破損物、危険物は異なる清掃タグを持ち、必要用具も分ける。 + +施設耐久は0~100。 + +- 通常稼働では日次維持費を払う限り減らない。 +- 暴動、災害、過負荷、整備不足で減る。 +- 耐久30未満でDEGRADED、0でBROKEN。 +- 修理はMaterials、Gold、作業時間を消費する。 +- 解体は建設Materialsの25%を返還する。Goldは返還しない。 + +## 7.9 補正の合成 + +同種の補正は先に加算し、異なるカテゴリを乗算する。 + +FinalValue = +Base +× (1 + SkillBonus + FacilityBonus) +× EnvironmentMultiplier +× EventMultiplier +× DifficultyMultiplier + +- 一つのカテゴリ内の合計は原則-75%~+100%に制限する。 +- 最終処理速度は基礎値の25%~300%に制限する。 +- 収益倍率はイベントを含め、通常時0.25~5.00に制限する。 +- 固有スキル同士が重なる場合の上限をデータへ明記する。 + +--- + +# 8. 客AI + +## 8.1 客ユニットのデータ + +全ての客は成人として生成し、次の情報を持つ。 + +- 一意ID、表示名、成人年齢、性別、種族 +- 客クラス、社会ランク、危険度 +- 所持金、戦利品、信用限度 +- 6欲求、忍耐、満足、依存 +- 性格タグ、好みタグ、忌避タグ +- 現在の目的、経路、予約施設、待機列 +- 入店時刻、予定退店時刻、過去の利用履歴 +- 違法施設を目撃した証拠値 + +性別は外見、代名詞、好み、妊娠可能性などに利用できるが、破産時の挙動や基本能力を直接決めない。 + +## 8.2 欲求 + +欲求は0~100。高いほど解消を急ぐ。 + +| 欲求 | 標準増加/時間 | 主な解消施設 | +| --- | ---: | --- | +| Hunger | +8 | 食堂、酒場、屋台 | +| Sleep | +6 | 客室、休憩所 | +| Hygiene | 汚れに応じて+0~12 | 浴場、洗面 | +| Medical | 負傷・病気で上昇 | 診療所、闇医院 | +| Leisure | +5 | 酒場、劇場、カジノ、闘技場 | +| Intimacy | 昼+3、夜+7 | マッサージ、成人向け施設、個別イベント | + +欲求値100は即時失敗を意味しない。対応施設を最優先にし、一定時間解消できない場合に状態異常へ移る。 + +この6項目を保存・判断に使う正式な欲求軸とする。安全は欲求値にせず、施設候補の危険度、客の`risk_tolerance`、事故・戦闘・避難命令による強制行動で扱う。交流は独立した欲求値にせず、施設タグへの好み、`GroupFit`、同行者、会話・接客、関係イベントの文脈として扱う。これにより、負傷・病気を表すMedicalへ不安や孤独を混ぜない。 + +Hygieneは汚れによって、Medicalは負傷・病気によって上昇する。Intimacyの昼夜増加と解消候補は成人確認済みの客にだけ適用し、成人向け候補はContent Filterと`adult_verified`の両方を通過しなければならない。全ての生成客を成人とする原則は維持するが、年齢確認が欠けた個体を「成人であるはず」と推測して制限候補へ通さない。 + +## 8.3 意思決定 + +客は以下の順で目的地を決める。 + +1. 生命・安全上の強制行動を確認する。 +2. 退店条件を確認する。 +3. 欲求を満たせる営業中施設を列挙する。 +4. 予算、利用条件、経路の有無で除外する。 +5. 各候補のUtilityを計算する。 +6. 最高Utilityが15以上なら予約し、未満なら回遊または退店する。 + +Utilityの基準式: + +Utility = +欲求重要度 × (欲求値 ÷ 100)の1.6乗 × 100 ++ 好み一致 ++ 期待品質 ++ 流行補正 +- 価格負担 +- 移動時間 × 1.5 +- 予測待ち時間 × 忍耐係数 +- 忌避と危険補正 + +補正の標準範囲: + +- 好み一致: -30~+30 +- 期待品質: 0~+25 +- 流行: 0~+20 +- 価格負担: 0~60 +- 忌避: 0~100 + +同点の場合は距離、待ち時間、個体乱数の順で決める。完全に同じ個体群が一施設へ同期しないよう、個体ごとに±5の固定嗜好揺らぎを持たせる。 + +## 8.4 予算 + +客は所持金の一部を生存予備費として確保する。 + +- 通常: 所持金の30% +- 浪費家: 10% +- 倹約家: 50% +- 依存Lv3以上: 0% + +通常の客は予備費を下回る娯楽を選ばない。宿泊の基本料金はチェックイン時に前払いし、追加サービスと延泊分をチェックアウト時に精算する。 + +## 8.5 忍耐と満足 + +忍耐は0~100。行列1分につき1、混雑移動1分につき0.25減る。貴族は減少1.5倍、戦士は0.75倍を基準とする。 + +忍耐が0になると、現在の予約を破棄する。直ちに退店するか別施設を探すかは性格で決める。 + +サービス満足変化: + +SatisfactionDelta = +(Quality - 期待品質) × 0.5 ++ 好み一致 +- 実待ち時間 × 0.5 +- 価格不満 +- 不潔・騒音・事件補正 + +総合満足は0~100。退店時の効果: + +- 80以上: Reputation +0.05、再訪候補、チップ +- 50~79: 変化なし +- 20~49: Reputation -0.03 +- 19以下: Reputation -0.10、悪評イベント候補 + +## 8.6 依存 + +依存は0~100で、施設ではなく客個体に蓄積する。 + +| 段階 | 値 | 行動変化 | +| --- | ---: | --- | +| Lv0 健全 | 0~24 | 通常行動 | +| Lv1 興味 | 25~49 | 対象施設のUtility +10 | +| Lv2 耽溺 | 50~74 | 滞在延長、予備費半減 | +| Lv3 依存 | 75~89 | 予備費0、健全施設の優先低下 | +| Lv4 破綻 | 90~100 | 退店判断を失い、債務処理候補 | + +依存上昇量は施設の基礎値、サービス品質、好み一致、客の耐性から求める。一回の利用での上昇上限は通常15とし、一度の偶発イベントだけでLv0から破綻へ進ませない。 + +## 8.7 破産処理 + +所持金と換金可能資産が0、信用限度まで借入済み、依存Lv3以上の場合に破産処理へ入る。 + +結果は性別ではなく、気質、戦闘力、社会的信用、施設ポリシーで決まる。 + +| 結果 | 主条件 | 内容 | +| --- | --- | --- | +| 退店・踏み倒し | 臆病、低依存 | 未収金を残して逃走 | +| 装備精算 | 戦利品あり | 装備を査定し債務へ充当 | +| 暴動 | 攻撃的、高戦闘 | 警備とのオート解決 | +| 身元保証 | 高社会信用 | 後日支払い。外交リスクあり | +| 成人隷属契約 | 本人・制度上の条件成立 | 期間契約の労働ユニット化 | + +成人隷属契約を許可するには地下施設、該当ポリシー、コンテンツ設定が必要。未成年、年齢不詳、保護対象キャラクターは対象外とする。 + +## 8.8 客クラス + +| クラス | 主な特徴 | +| --- | --- | +| 戦士 | 忍耐高、食事と酒需要、喧嘩率やや高 | +| 魔法使い | 騒音に弱い、Mana系施設を好む | +| 盗賊 | 地下嗜好、警備不足時に窃盗 | +| 聖職者 | 不潔と違法目撃に弱い、Faith収益 | +| 貴族 | 高予算、低忍耐、高品質要求 | +| 商人 | 交易中心、価格差へ敏感 | +| 亜人・魔族 | 夜行性タグを持ちやすい | +| 外交官 | VIP要求、満足時に監査対策を付与 | + +種族と職業は別データとし、「エルフの商人」「獣人の聖職者」などを生成可能にする。 + +## 8.9 客の状態機械 + +| 状態 | 遷移条件 | +| --- | --- | +| SPAWNING | 入店生成中 | +| ARRIVING | 受付へ移動 | +| DECIDING | 次の目的を評価 | +| TRAVELING | 予約施設へ移動 | +| QUEUING | 施設待機列 | +| USING | サービス利用中 | +| RESTING | 睡眠など長時間サービス | +| LEAVING | 精算または退店へ移動 | +| INCIDENT | 怒り、暴動、負傷、拘束 | +| DESPAWNING | マップ外へ退出 | + +- 一個体は同時に一状態だけを持つ。 +- 状態遷移時に施設予約と待機列所属を検証する。 +- 60ゲーム分以上移動距離が進まない場合、経路を再計算する。 +- 再計算3回失敗で安全地点へ誘導し、デバッグログを残す。 +- 進行不能を演出として放置しない。 + +--- + +# 9. 従業員 + +## 9.1 分類 + +| 分類 | 特徴 | +| --- | --- | +| 自由雇用 | 契約金と日給が必要。退職、ストライキ、転職がある | +| 成人隷属契約 | 給与は低いが維持費と忠誠管理が必要。契約移転が可能 | +| ユニーク | ストーリー人物。売却・通常解雇不可。固有スキルを持つ | + +自由雇用者を商品として売却することはできない。本人の債務契約やストーリー上の明示状態を経ずに成人隷属契約へ変更することもできない。 + +全従業員データは成人確認済み年齢を持つ。成人確認できない生成データは雇用候補へ出さない。 + +## 9.2 基本パラメータ + +全て0~100。 + +| 値 | 役割 | +| --- | --- | +| Health | 肉体状態。0で行動不能 | +| Stamina | 労働可能量。0で気絶 | +| Stress | 精神負荷。高値で事故・離職 | +| Loyalty | 経営者への忠誠と情報保全 | +| Operation | 調理、清掃、事務、運搬 | +| Service | 接客、施術、成人向け接客 | +| Combat | 警備、鎮圧、防衛 | +| Underworld | 違法業務耐性、隠蔽、地下交渉 | + +魅力は独立能力値にせず、外見・社交・種族などのタグとServiceへ分解する。 + +Health0では医務室へ搬送する。通常の経営事故でユニーク人物を永久死亡させない。モブの死亡は高危険ポリシー、明示された防衛戦、重大イベントに限定し、発生前にリスクを表示する。 + +## 9.3 勤務と回復 + +- 昼番: 06:00~18:00 +- 夜番: 18:00~06:00 +- 勤務中のStamina減少: 基礎4/時間 × 施設負荷 +- 休憩室回復: +8/時間 +- 寮回復: +12/時間 +- 通常業務Stress: +1/時間 +- 不適性業務: 追加+2~6/時間 +- 休憩室Stress回復: -4/時間 +- 寮Stress回復: -7/時間 + +Staminaによる効率倍率: + +StaminaFactor = clamp(0.5, 1.0, 0.5 + Stamina ÷ 200) + +Stressが50を超えた分について、1ポイントごとに効率を0.6%下げる。Stress100で発生する結果は、離職、サボタージュ、気絶、暴発から性格により選ぶ。 + +## 9.4 通勤 + +シフト開始時刻に瞬間配置しない。非番の居場所から勤務施設まで実際に移動する。 + +- 出勤指示は開始1時間前から行う。 +- 交代者が到着するまで前任者は接客完了後に待機できる。 +- 残業はStamina減少1.5倍、Stress増加2倍。 +- 裏動線不足による遅刻を、シフトUIで予測表示する。 + +## 9.5 特性 + +個体は原則1~3個の特性を持つ。特性は数値の単純上位互換だけでなく、配置判断を変えるものにする。 + +例: + +- 夜型: 夜勤ペナルティなし、昼勤務効率-10% +- 潔癖: 清潔80以上で効率+10%、50未満でStress増加 +- 商売上手: 販売価格許容+5、仕入れ交渉+10% +- 短気: 戦闘+10、クレーム時Stress増加 +- 面倒見: 同施設の新人効率+10% + +## 9.6 気絶と救護 + +Stamina0または事故で気絶すると、施設スロットを外れ、その場に救護対象として残る。救護班は業務用動線で医務室へ運ぶ。客用通路を通った場合はReputationと周辺満足が低下する。 + +## 9.7 従業員の状態機械 + +OFF_DUTY、COMMUTING、WORKING、BREAK、RESPONDING、COLLAPSED、TREATMENT、CAPTIVE、UNAVAILABLEのいずれかを持つ。 + +- WORKING中だけ施設の最低人数へ算入する。 +- COMMUTING中の到着予測を施設UIへ表示する。 +- RESPONDINGは清掃、警備、救護、搬送など巡回業務に使う。 +- CAPTIVEとUNAVAILABLEはシフトへ割り当てられない。 +- 状態変化の理由と予定解除時刻を人事ログへ残す。 + +--- + +# 10. ユニークキャラクターと関係性 + +## 10.1 方針 + +ヒロインは全員成人で、経営能力とストーリー役割を持つ。関係イベントは数値だけで自動確定せず、重要段階では予告と明示イベントを要求する。 + +一人のヒロイン喪失は重大な不利益となるが、通常プレイの即時ゲームオーバーにはしない。 + +## 10.2 感情パラメータ + +| 値 | 範囲 | 意味 | +| --- | ---: | --- | +| Affection | 0~100 | 主人公への愛情と信頼 | +| Rival Bond | 0~100 | 固有ライバルとの秘密・依存・関係 | +| Corruption | 0~100 | 禁忌や快楽への心理的抵抗低下 | +| Shield | 0~Shield Max | ライバルの受動侵食を防ぐ現在耐久 | + +CorruptionはRival Bondと同義ではない。Corruptionが高くても主人公への忠誠を維持する分岐は存在する。 + +## 10.3 愛の盾 + +Shield Max = clamp(0, 100, 20 + Affection × 0.8 + 信頼イベント補正) + +- Shieldが1以上なら、通常のライバル接触はRival Bondを直接上げずShieldを削る。 +- 時間経過だけでは減らない。 +- 主人公の会話、デート、親密イベントで回復する。 +- 主人公の現場介入中は通常攻撃を完全停止する。 +- Shield0で侵食イベント候補が解禁される。 +- Shieldは好感度と別保存し、被害と回復の短期的な攻防を表す。 + +ライバル攻撃の標準式: + +ShieldDamage = +max(1, +ライバル影響力 ++ 同施設時間補正 ++ 夜間補正 +- ヒロイン洞察 +- 警備と監視補正) + +判定は同一施設・同一シフトで30ゲーム分以上接触した場合、1時間ごとに行う。 + +## 10.4 段階 + +各軸は0~19、20~49、50~79、80~99、100の5段階を持つ。50と80を越える際には専用イベントが必要で、バックグラウンド計算だけでは越えない。 + +### 純愛の確定 + +- Affection 80以上 +- 純愛の告白イベント完了 +- Rival Bond 50未満 +- プレイヤーが関係確定を選択 + +確定後は通常侵食に強い耐性を得るが、ストーリー上の重大イベントまで完全無効にはしない。 + +### NTRの確定 + +- Rival Bond 80以上 +- 盾破壊後の裏切りイベント完了 +- プレイヤーへ十分な予告が出ている + +確定後は日常会話、プレゼント、通常決闘だけではRival Bondを80未満へ戻せない。救済ルートを有効にする場合は、キャラクター固有の長期クエストとして扱う。 + +### 堕落の確定 + +- Corruption 90以上 +- 本人の受容または精神的破綻を扱う固有イベント完了 + +堕落確定後も主人公への忠誠、ライバルへの関係は独立して分岐する。堕落は自動的なNTRではない。 + +## 10.5 関係値の標準変化 + +| 行動 | Affection | Rival Bond | Corruption | Shield | +| --- | ---: | ---: | ---: | ---: | +| 会話成功 | +2 | 0 | 0 | +3 | +| プレゼント成功 | +3~8 | 0 | 内容依存 | +5 | +| デート成功 | +8 | -3 | 0 | +20 | +| 適職勤務1日 | +1 | 0 | 0 | +2 | +| 過重労働1日 | -2 | 0 | +1 | -2 | +| 地下成人業務1シフト | -1~0 | 0 | +2~6 | 0 | +| 盾0でライバル密会 | -1 | +3~8 | 0~2 | 0 | +| 主人公が約束を破る | -5 | +2 | 0 | -10 | + +一日の自動変動上限は、各軸±10を原則とする。重要イベントは上限外。 + +## 10.6 決闘 + +決闘はRival Bond 40~79で解禁する。80以上では通常決闘を閉じ、物語上の最終対決へ移る。 + +種類: + +- 武力: 主人公の武力と装備を使用 +- 商談: 商才、期間売上、保有情報を使用 +- 権威: 統率、Reputation、協力者を使用 +- 親密: 成人向けコンテンツ設定が有効な場合のみ使用 + +勝利: + +- ライバルを3日間接近禁止 +- Rival Bond -20 +- Shield +30 +- Reputationまたは関係者評価が内容に応じて変化 + +敗北: + +- Shieldを0へ +- Rival Bond +15 +- 主人公能力またはReputationへ一時ペナルティ + +勝利してもRival Bondを0へリセットしない。過去の出来事を一回の勝負で消去しないためである。 + +## 10.7 妊娠と世代 + +- 対象は成人キャラクターのみ。 +- 妊娠可否は生物学設定、個別設定、避妊ポリシーから決める。 +- 妊娠期間は世界観に合わせて短縮表現できるが、子供は非性的な家族・人口データとして扱う。 +- 未成年期のキャラクターを成人向けイベント、労働、取引の対象にしない。 +- 次世代ユニット化は長期タイムスキップ後、成人になったことを明示してから行う。 +- MVPでは妊娠は状態とイベントまでとし、世代育成は実装しない。 + +## 10.8 メインキャラクターの機械的役割 + +表示名は仮称だが、機械的な役割枠は固定する。 + +| ID/仮称 | 役割 | 得意 | 初期固有効果 | 対応ライバル | +| --- | --- | --- | --- | --- | +| heroine_anri/アンリ | 女将・案内役 | 受付、食堂 | 配置フロアの忍耐減少-10% | ライ | +| heroine_fiene/フィーネ | 業務主任 | 清掃、物流 | 清掃速度+50%、運搬予約+1 | リーゼンバルト | +| heroine_tomari/トマリ | 警備主任 | 警備、訓練 | 周辺治安+20、初回暴動無効 | ソウ | +| heroine_amelia/アメリア | 医療・宗教 | 礼拝堂、医務 | Faith収益+25%、治療+25% | ドスファン | +| heroine_liz/リズ | 探索・調達 | クエスト、厨房 | レア素材率+15%、食材消費-10% | モーリアス | +| heroine_mimi/ミミ | 商業主任 | 交易、倉庫 | 仕入価格-10%、売価+5% | トト | +| heroine_satsuki/サツキ | 外交・VIP | VIP、庭園 | VIP満足+15、外交報酬+20% | マッケンジー | + +固有効果は初期値であり、ルートと成長で派生する。同じ効果の完全上位互換をモブへ持たせない。 + +--- + +# 11. 経済 + +## 11.1 収益発生 + +- 飲食、物販、娯楽: サービス完了時に計上 +- 宿泊: 基本料金をチェックイン時、延泊・追加料金をチェックアウト時に計上 +- 交易: 売買成立時に計上 +- 定期契約: 日次決算時に計上 +- 人員契約移転: 実行確認後に一時収益として計上 + +## 11.2 支出 + +- 従業員給与 +- 契約者の食費・生活費 +- 施設維持費 +- マナ燃料費 +- 食材、商品、消耗品 +- 建設・修理費 +- 税、上納金、借入利息 + +### 日給 + +自由雇用者の日給: + +20G ++ Level × 5G ++ (Operation + Service + Combat + Underworld) × 0.08G ++ 特性加算 + +成人隷属契約者は日給0だが、生活維持費12G/日と監督コストを必要とする。ユニーク人物は日給0だが、個別イベントや設備要求を持つ。 + +給与未払い時は自由雇用者のLoyalty -10、Stress +15。2日連続で離職判定、3日連続で原則退職する。 + +## 11.3 日次決算 + +06:00に、直前の06:00から当日06:00直前までの営業実績を以下のとおり集計する。 + +Revenue +- Cost of Goods +- Wages +- Maintenance +- Utilities +- Taxes and Interest += Net Profit + +レポートには当日値だけでなく、7日移動平均と前日差を表示する。建設費は営業利益と分け、キャッシュフロー欄へ表示する。 + +## 11.4 支払い不能 + +Goldは通常0未満にしない。決算時に不足した金額をArrearsとして記録する。 + +- Arrearsがある間、新規建設と通常投資は不可。 +- 翌決算までに全額払えば破綻警告を解除。 +- 3決算連続で残ると経営破綻。 +- 闇金融は任意の救済手段であり、使用を強制しない。 + +## 11.5 バランス目標 + +標準難易度の目標: + +- Rank 1の健全経営: 日次利益率8~15% +- 地下営業導入後: 15~30%を狙える +- 高Heat対策費を含む長期利益率: 10~20% +- 一つの施設カテゴリだけで全進行条件を満たせない +- Rankごとの投資回収期間: 3~7ゲーム日 + +--- + +# 12. 監査、治安、防衛 + +## 12.1 Heat + +Heatは違法行為が当局に注目されている程度を表す。 + +- 0~29: 通常 +- 30~49: 噂。監査候補 +- 50~69: 警戒。定期監査 +- 70~89: 危険。抜き打ち監査と密偵 +- 90~100: 摘発準備。強制捜査候補 + +違法営業を完全停止した日はHeat -3。外交、証拠処理、ストーリー報酬で追加低下できる。 + +## 12.2 監査抽選 + +毎日06:00、Heat30以上で通常監査を抽選する。 + +監査確率 = clamp(0%, 75%, (Heat - 30) × 1.5% + 物語補正) + +通常監査は6ゲーム時間以上前に予告する。Heat90以上の強制捜査は予告なしの場合があるが、その可能性をUIで常時警告する。 + +## 12.3 発見 + +Evidence = +違法施設の可視性 ++ 営業中補正 ++ 目撃証言 ++ 保管証拠 +- 隠蔽値 +- 買収済み権限 +- 偽装研究 + +監査官は実際の経路で施設を巡回する。単純な全施設一括乱数にはしない。FRONTから到達できず、隠し入口も発見されなかった施設は調査対象外となる。 + +## 12.4 対応 + +| 対応 | コスト/リスク | +| --- | --- | +| 合法証明 | 書類とReputation。安全 | +| 営業停止・隠蔽 | 収益停止、証拠移動 | +| 買収 | Gold消費。後日のHeat増加リスク | +| 情報取引 | 脅迫情報を消費。相手ごとに一度 | +| 強制排除 | 防衛戦。成功してもHeat大幅増 | + +## 12.5 処罰 + +発見証拠値に応じ、罰金、対象施設封鎖、没収、営業停止、重要人物拘束、強制捜査へ段階的に進む。一回の軽微な発見で全施設を失わない。 + +## 12.6 防衛 + +防衛は配置と事前準備によるオート解決を基本とする。 + +Defense Score = +配置警備員のCombat ++ 防衛設備 ++ 指揮官スキル ++ 地形補正 +- 疲労 +- 奇襲補正 + +戦闘中、プレイヤーはアクティブスキルと撤退方針だけを選ぶ。アクション操作は行わない。 + +--- + +# 13. 研究と投資 + +## 13.1 研究分野 + +| 分野 | 内容 | +| --- | --- | +| Hospitality | 客室、料理、浴場、接客品質 | +| Logistics | 倉庫、搬送、昇降機、自動補充 | +| Security | 警備、監視、防衛、監査対策 | +| Underworld | 地下娯楽、闇取引、依存、隠蔽 | +| Mysticism | Mana、世界樹、ワープ、Faith | + +研究はFaith、Gold、研究時間を消費する。研究中断時は進捗を保持する。 + +## 13.2 投資 + +投資はマップ外の長期強化。 + +- 街道整備: 来客数と上客率 +- 求人組合: 求人品質 +- 王都ロビー: 監査予告時間 +- 広告: 指定客層の来訪 +- 冒険者支援: 戦利品品質 + +同時に有効な大型投資は3件までとし、無制限な永続倍率の積み重ねを防ぐ。 + +## 13.3 クエスト派遣 + +クエストは1~4人のユニットを一定時間マップ外へ派遣する非操作型システム。 + +Quest Score = +要求スキル合計 ++ リーダースキル ++ 装備 ++ 情報 +- 負傷と疲労 +- 難度 + +成功率は5~95%に制限し、表示は数値と「低い/五分/高い」の両方を出す。 + +- ストーリークエスト: 失敗しても即時ゲームオーバーにせず、再挑戦、追加コスト、分岐結果のいずれかにする。 +- キャラクエスト: 関係イベントとスキル解禁。 +- 資源クエスト: Materials、食材、装備。 +- 治安クエスト: Heat、Reputation、防衛への影響。 + +派遣中は施設勤務できない。帰還時は結果レポート、報酬、負傷、関係変化を一括表示する。 + +--- + +# 14. 天候、流行、イベント + +## 14.1 天候 + +| 天候 | 効果 | +| --- | --- | +| 晴れ | 補正なし | +| 雨 | 新規客-20%、滞在時間+20%、汚れ増加 | +| 嵐 | 新規客-70%、外出キャンセル、防衛設備故障率増 | +| マナ霧 | 魔法系客+50%、Mana生成+50%、地下興奮補正 | + +天候は最低6時間継続し、急変を連発しない。 + +## 14.2 流行 + +流行は予兆、発生、終息の3段階。 + +- 予兆: 1~3日前にニュースへ表示 +- 発生: 2~5日継続 +- 終息: 価格と来客補正が段階的に戻る + +対象カテゴリの需要、チップ、仕入れ価格を変える。事前準備できない完全な不意打ちだけにはしない。 + +## 14.3 イベント優先度 + +同時イベントはCritical、Story、Relation、Economy、Flavorの順に処理する。同一人物の重要イベントを同時に二つ開始しない。 + +--- + +# 15. UX + +## 15.1 HUD + +上部: + +- Gold、Mana、Materials、Faith +- Reputation、Heat +- Day、時刻、天候 +- Pause、x1、x2、x4 +- 世界樹ランク + +下部: + +- FRONT/BACK切替 +- 建設、人事、経済、研究、クエスト、図鑑 +- 警戒時の緊急操作 + +## 15.2 オーバーレイ + +ワンクリックで次を切り替える。 + +- 清潔 +- 騒音 +- 景観 +- 治安 +- 隠蔽 +- 客混雑 +- 従業員混雑 +- Mana供給 +- 施設収益 + +複数オーバーレイの同時表示は2種類まで。 + +## 15.3 通知 + +| 優先度 | 表示 | +| --- | --- | +| Critical | 自動一時停止を設定可能。赤。場所へ移動 | +| Warning | 長めのトースト。橙 | +| Economy | 集約可能。緑 | +| Info | 短いトースト。青 | +| Flavor | ログのみ | + +同種通知は5秒以内にまとめ、「清掃要求 ×12」のように表示する。 + +## 15.4 予測表示 + +プレイヤーの操作前に結果を示す。 + +- 建設時: 接続、支持、騒音範囲、予測収益 +- シフト時: 通勤時間、適性、疲労予測、関係リスク +- 価格変更時: 需要と満足の予測 +- 地下営業時: 予測Heat +- 決闘時: 使用能力、勝率帯、敗北時の結果 + +## 15.5 アクセシビリティ + +- UI倍率75~175% +- 色だけに依存しないアイコン +- 点滅、画面揺れ、カットインの個別軽減 +- 通知の自動一時停止をカテゴリ別に設定 +- 全ショートカットの再割り当て +- 成人向けシーンのスキップと既読高速化 + +## 15.6 チュートリアル + +初回シナリオは機能を段階解禁する。 + +| 時点 | 解禁 | 学習目標 | +| --- | --- | --- | +| Day 1朝 | FRONT、受付、客室、食堂 | 建設、来客、支払い | +| Day 1夕 | 清掃、浴場、混雑表示 | 欲求と夕方ラッシュ | +| Day 2朝 | BACK、厨房、倉庫、従業員ドア | 物流と二層構造 | +| Day 2夜 | シフト、酒場 | 昼夜需要と疲労 | +| Day 3 | 地下掘削、Heat | 地下はゾーンでありBACKではない | +| Day 4 | ヒロイン、ライバル、Shield | 配置と関係リスク | +| Day 5以降 | 監査、研究、自由経営 | 中期判断 | + +- 説明前に複雑な失敗を発生させない。 +- チュートリアル中の破綻は一度だけ無利息救済する。 +- 経験者は解禁済み状態で開始できる。 +- 各説明は後からヘルプで再閲覧できる。 + +--- + +# 16. 成人向けコンテンツ境界 + +## 16.1 絶対条件 + +- 成人向け行為へ登場する人物は全員成人である。 +- 年齢不詳の人物を成人向け行為、成人労働、取引対象にしない。 +- 子供、未成年期、幼い外見を成人向けに扱わない。 +- 成人確認情報はキャラクターデータに必須とする。 + +## 16.2 コンテンツ設定 + +ニューゲーム時および設定画面で次を個別に変更できる。 + +- NTRルート: 有効/無効 +- 強制性の強い展開: 標準/軽減/無効 +- 妊娠要素: 有効/無効 +- 暴力表現: 標準/軽減 +- 成人向けイベントの自動スキップ + +ルート無効時は、そのイベントを単純削除して進行不能にせず、業務上の裏切り、引き抜き、金銭トラブルなどへ置換する。 + +## 16.3 表現とゲームルール + +成人向けイベントは、関係値、勤務、客の利用、物語フラグの結果として発生する。イベント閲覧だけを最適解にせず、収益、疲労、Heat、関係性の代償を伴わせる。 + +--- + +# 17. セーブ、再現性、技術要件 + +## 17.1 セーブ + +- 手動セーブ10枠 +- オートセーブ3枠のローテーション +- オートセーブ時刻: 06:00の日次決算・朝処理完了後、重要イベント前後。決算から朝処理までを同じ境界内で原子的に完了させてから保存する +- セーブ中もシミュレーションを止め、途中状態を書き込まない +- セーブデータにschema_versionを持たせる +- 破壊的な更新時は旧版バックアップを一つ残す + +## 17.2 セーブ対象 + +- マップ全セルと施設状態 +- 客、従業員、ユニーク人物 +- 経路、予約、行列 +- 経済、研究、投資、在庫 +- 関係値とイベントフラグ +- 乱数シード、次回判定時刻 +- 設定とコンテンツフィルター + +## 17.3 性能目標 + +基準PCを、Windows 11、4コアCPU、16GB RAM、内蔵GPU相当とする。 + +- 1920×1080で通常60fps +- 最低保証30fps +- MVP: 同時活動客100、従業員30 +- 製品版: 同時活動客400、従業員100 +- 画面外ユニットは表示更新を間引くが、経済結果は変えない +- AI意思決定をフレーム単位で分散する +- 経路再計算は必要時のみ行う +- 1セーブの目標容量は50MB以下 +- 06:00決算・朝処理は合計100ms以下を目標 + +## 17.4 決定論 + +同じセーブ、同じ入力、同じ乱数シードなら主要なシミュレーション結果を再現できることを目標とする。フレームレートを乱数や経済計算へ使用しない。 + +## 17.5 データIDと互換性 + +- 施設、人物、特性、アイテム、イベントは変更しない一意IDを持つ。 +- 表示名の変更でIDを変更しない。 +- ID削除時は旧IDから代替IDへの移行表を用意する。 +- セーブにはResourceのメモリアドレスではなくIDを保存する。 +- 調整値は可能な限りResourceまたはJSONへ置き、シーンコードへ直書きしない。 + +--- + +# 18. MVP + +## 18.1 目的 + +MVPはゲーム全体を薄く作るものではなく、「自律客を二層動線で捌き、合法と地下営業の選択が関係性へ影響する」という本作固有の体験を検証する垂直スライスとする。 + +## 18.2 含めるもの + +- 14ゲーム日のシナリオ +- 世界樹ランク1~3 +- 地上、境界、地下の小マップ +- FRONT/BACK切替 +- 施設16種類 +- 客クラス8種類、種族4種類 +- 同時客100人 +- 自由雇用従業員20人 +- ヒロイン1人、ライバル1人 +- Affection、Rival Bond、Corruption、Shield +- 昼夜シフト +- 客の6欲求、予算、満足、依存 +- 受付、食事、入浴、宿泊、酒場、成人向け施設1種 +- 清掃、物流、警備、Mana +- 日次決算、Heat、監査1種類 +- 手動セーブ、オートセーブ +- コンテンツ設定 + +## 18.3 MVPでは含めないもの + +- 7人全員のヒロイン +- 世界樹ランク4以降 +- 国家戦争と大規模防衛 +- 世代育成 +- 複数の決闘方式 +- クエスト派遣の本実装 +- 高度な脅迫、外交、税制 +- 完成版の成人向けアニメーション +- ワープ、複数種類のエレベーター制御 + +## 18.4 合格条件 + +1. MVP-AC-01: 14日をクラッシュ、進行不能、永久行列なしで完走できる。 +2. MVP-AC-02: 夕方の混雑が施設追加と動線改善で目に見えて解消される。 +3. MVP-AC-03: BACK動線を無視した構造が明確に非効率となる。 +4. MVP-AC-04: 地下営業は健全営業より高収益だが、Heat対策後には自動的な最適解にならない。 +5. MVP-AC-05: ヒロインとライバルの配置リスクを、イベント発生前に予測できる。 +6. MVP-AC-06: 重要な関係ルートが、警告なしの数値変化だけで確定しない。 +7. MVP-AC-07: 100客+30従業員で基準PCの平均55fps以上。 +8. MVP-AC-08: セーブとロード後に所持金、時刻、行列、関係値が一致する。 +9. MVP-AC-09: 初見プレイヤーが30分以内に、地上/地下とFRONT/BACKの違いを説明できる。 + +--- + +# 19. 意図的に保留する事項 + +以下はコア実装を妨げないため、本書0.1.0では確定しない。 + +- 正式タイトル +- 主人公と7ヒロインの最終プロフィール +- 具体的な成人向けシーン台本と画角 +- 音声収録範囲 +- 最終的な販売ストア +- DLCと追加キャラクター +- 多言語対応範囲 + +これらを決める際も、本書のコア体験と成人条件を変更しない。 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..36a2cb5 --- /dev/null +++ b/docs/IMPLEMENTATION_STATUS.md @@ -0,0 +1,180 @@ +# 実装状況 + +この文書は仕様書そのものではなく、現在のコードがどこまで到達しているかを記録する。ゲームルールの正本は `GAME_SPECIFICATION.md`、System境界と不変条件の正本は `SYSTEM_SPECIFICATION.md` とする。 + +## 実装・自動検証済み + +### S0: 決定的Simulation基盤 + +- 1ゲーム分固定Tickと11段階の処理順 +- Command Queue、priority、予約時刻、処理後も保持する受理ID/idempotency対応、旧Queue Snapshot移行 +- 原子的State Transaction +- subsystem別の決定的RNG Stream +- Stable ID、全件検証後に一括Commitする再入安全なDomain Event Batch、Simulation Digest +- Directory単位のAtomic Save、Checksum、backup、Load直後の時刻維持 +- 同一Seed・Command列を24時間再生したDigest一致 + +### S1: 世界・建設 + +- `(x, y, layer)` Cell、ZoneとFRONT/BACKの分離 +- Rank 1〜7の非破壊World拡張 +- Occupancy、掘削、構造支持Component +- 決定的A*、Path Cache、明示Connectorと利用権限 +- セル容量・密度・動的Cost、Soft Collision、混雑回避 +- Cost低下・通行再開時のCache失効、権限別の正確な経路Cost、無関係な障害を除いた不到達理由 +- Elevator稼働状態と有限Queue、Request/Unit二重登録防止、停止時迂回、30分停滞検知と安全復旧候補 +- Placement検証、ConstructionJob、取消、完成、撤去 +- 建設完了通知の保留・確定・破棄境界。完成処理の検証中は外部通知を出さず、成功時だけ順序を保って一度ずつ公開する +- 硬度別の地下掘削Job、入口までの接続、支保条件、中断再開、FRONT/BACK同時解放 +- 地下施設の全Footprint掘削済み検証と、Zoneを跨ぐFootprintの拒否 +- 境界施設はanchorを境界へ固定し地上側へのFootprint展開を許可、地上/地下施設は全Footprintを指定Zoneへ厳格固定 +- 主要施設占有と独立して同一Cellへ共存できるROUTE/DECORATION/UTILITYの3種subslot、複数Cell横断、Path投影用query、厳密Snapshot +- ROUTE subslotの追加・置換・撤去とPath graphを原子的に同期し、失敗時の双方復元、revision/stale判定、冪等再送、隔離まで扱うAdapter +- 完成した客用通路/業務用通路を実ROUTEへ、従業員ドアを職員専用FRONT↔BACK Connectorへ原子的に投影・撤去する境界。安定ID、crosslink、exact rollback、隔離、厳密Snapshotを含む +- Runtime v6で掘削、subslot、Path投影、施設投影を1分Tick・Rank拡張・建設完了・撤去・Saveへ統合。Runtime v5の旧代理施設を決定的に実ROUTEへ移行する +- World、Path、Constructionを含むSnapshot往復 + +### S2: 施設・在庫・物流・Mana・経済 + +- Facility状態の優先評価と複数reason +- 営業時間、Policy Snapshot、priority Queue、Capacity Slot +- Service開始・進行・中断・完了Callback +- owner別Inventory Stack、FEFO Reservation、期限切れ、監査修復、既定IDの再利用を防ぐTombstone +- 入力fingerprint付き冪等性、競合する一括操作の事前拒否、全件原子rollback +- Logistics Request、貨物所有権移転、決定的Dispatcher、候補外になった運搬人の予約積載保持 +- 歩行Graphと独立したMana Network、優先供給、蓄電、Brownout、復旧保持 +- Goldを直接代入しない複式Ledger、逆仕訳、部分返金、日次照合 +- 建設費・撤去返金とLedgerの原子的Adapter +- 原料予約から施設Serviceと売上記帳までの統合Cycle +- Cycle途中のSave→Load→再実行でDigest完全一致 +- §7.6のMVP施設16種をStable ID付きDefinitionとして登録・Schema検証 +- 施設詳細Schema version 2を追加。静的Definitionと可変Instance/日次履歴の境界、Access、Upgrade、営業時間、Policy、環境・リスク、客層、解禁、統計、サービス種別ごとの時間・品質・価格・処理単位・欲求効果を定義し、Registryで厳密検証 +- 受付、厨房、成人向け個室を詳細Schemaへ先行移行し、複数接客・BACK生産・夜間成人向け営業の3類型を自動検証。その他MVP 13施設と拡張16施設は互換Schema version 1で段階移行可能 +- MVP互換性を保つ拡張施設16種を`data/facilities/expansion/`へ分離して定義。地上一般向け5種、地下FRONT5種、BACK6種についてZone/Layer、建設費、Footprint、人員、容量、時間、価格、在庫、Risk Tagを固定し、通常建築パレットへの解放前に個別検証できるCatalog境界を追加 + +### S3: 客・従業員 + +- 6種のGuest Need(Hunger/Sleep/Hygiene/Medical/Leisure/Intimacy)、Utility、状態遷移、実経路を1セル/分で移動 +- 旧5欲求Saveだけの決定的移行、現行経路での旧Need名・欠落・混在拒否、成人確認付き昼夜Intimacy +- 所持金と未決済予約を考慮した施設選択 +- Queue、Service、退出、経路失敗の安全な復旧 +- Staffの複数Shift、Task Dispatcher、実移動、疲労と危険作業制約 +- 雇用区分、人物別日給、ID順の日次給与入力、当日発生と繰越債務を分けた部分清算、退職者の未払給与別台帳、旧Saveの互換移行 +- 清算ID/日付/入力fingerprintによる二重発生防止と、派生合計まで照合する給与履歴Snapshot +- 勤務終了、施設人員喪失、Service猶予と失敗の連動 +- Guest/Staffのnamespaced Path投影、密度同期、固定小数点混雑速度、30分停滞復旧 +- Guest/Staff/Facility/Inventory/Ledgerの24時間決定的再生 +- 破損Snapshotと孤児参照を副作用なしで拒否 + +### S4: 関係・Event + +- Affection、Trust、Fear、Corruption、Rival Bond、Shield +- Shield攻撃、反復Penalty、段階閾値、Route Lock確認境界 +- データ駆動Condition/Action/Choice +- foreground Event排他、Actor Lock、Cost escrow +- Content FilterのFADE/REPLACE/DISABLE +- 選択二重送信、Actor欠落、Action失敗、Save途中再開の安全な復旧 +- Threshold発火と24時間exact replayのRuntime統合 +- 配置確定前に共有施設・重複時間、Shield攻撃候補、発火可能Event、Route Lock警告、施設/時間の回避案を決定的に比較する読み取り専用Preview + +### S5: 行政・監査 + +- Heat、Suspicion、Incident重複排除、日次減衰とactive-case下限 +- Evidenceの位置、視線、到達、証言、既知施設による発見根拠 +- 監査予告、専用RNG、監査官の実Path巡回、到達不能時の安全退出 +- 証拠点による処分、罰金の複式台帳反映、`resolution_id`二重適用防止 +- Authority全Command、Domain Event、Snapshot、原子的RestoreのRuntime統合 +- 毎日06:00評価、途中Save、24時間exact replay +- Raid Wave、明示参加者と画面外戦力、扉・設備・目的物のラウンド更新 +- 防衛Score、疲労・奇襲・複数入口、Active Skill、撤退方針、複数Wave +- Round再送、操作種別付き解決ID、外部効果/RNG rollback、厳密な時刻契約 +- Cancel終了の原子反映、Wave別侵入口、防具軽減、途中Save、JSON輸送、破損Snapshot拒否 +- Defense全10 Command、専用RNG、Skill費用、Staff/Facility損害、Raid排他LockのRuntime統合 +- 終了Raidの履歴化と後続治療/修理、scheduled Command時刻、Runtime v1からDefense付きv2への決定的移行 + +### S6: 保存・運用の基礎 + +- 正式Saveを維持する3世代Autosave +- Checksum付きRecovery候補と明示的Recovery Load +- N→N+1の連続Migration RegistryとMigration前backup +- Migration/Autosave回転中断の自己修復とRecovery候補 +- 保存root・親directory・非package・package内symlink越境を全Load入口で拒否するパス安全性 +- 通常Saveのrename中断マーカーと自己復旧、Autosave世代縮小時の検証済み候補だけを安全に整理 +- 安定Save ID、完全Manifest既定値、Thumbnail生成と上書き時のidentity保持 +- 実行画面だけが有効化する本番Autosave policyと、Test/独立Runtimeの保存先分離 +- 毎日06:00の固定境界Checkpoint、3世代回転、手動保存Queue、復旧候補の検査・明示Load +- Load直後の時刻保持と、壊れたTask/終了Event Lockだけを対象とする原子的なRecovery限定修復 +- 正式、backup、移行前、Autosaveの各候補をRuntime全Domainへ隔離復元し、ゲームとして成立する最初の候補だけを採用する復旧fallback +- Saveより厳しいContent FilterをLoadで弱めない安全な統合と、不明enumの厳密拒否 +- Simulation、Path、Save、Load、stallの実測付き性能予算Collector +- PERF-MVP-001の正確なFixtureと24時間Harness。完全条件を3回連続24/24合格(x4 P95 8.14~8.45ms、最大停止予算も合格)。これは独立Service合成の論理負荷であり、Runtime全Domainや実描画Frameの計測とは区別する + +### 14日運営Coordinator基盤 + +- 暦日は00:00に更新し、06:00に前営業日の日次締めと朝運用境界を固定順で一度だけ実行 +- 需要の60分更新と30分ごとの来客計画、fixed-point carry、収容・来客上限 +- DaySummary、前日差、7日移動平均、施設別損益、未払解消と3回連続破綻 +- 外部Domainのcapture/validate/apply/rollback境界、14日間の省略なしexact replay、Snapshot v3と旧版移行 + +### MVP人物・進行データ境界 + +- 客8クラスと種族4種を別軸にした32通りの成人確認済みGuestDefinition生成 +- 自由雇用候補20人の固定ID、成人確認、役割、契約費、日給、4技能、許可業務 +- ヒロイン`heroine_anri`とライバル`rival_rai`の相互参照、固有効果、通常売却禁止タグ +- 不明field/tag、重複ID、成人未確認、18歳未満、孤児参照、型の暗黙変換を全体fail-closedで拒否 +- Rank 1~7の逐次条件、累計Faith/客、地上・地下稼働条件、派生MapBounds、冪等昇格Request +- World拡張用capture/apply/rollback境界、rollback失敗時の隔離、厳密Snapshot往復 +- 32 Guest定義のRuntime登録、候補雇用時の契約費・日給・Shift・Pathの一括確定、重複雇用防止 +- 累計Faith/累計客の実取引連動、Rank昇格とWorld/Path/GameStateの原子的確定、旧Runtime Rank 1保存の安全な移行 + +### 操作画面 + +- V3モックを基準にした暖色の木・革・羊皮紙UI。上部の常設HUD、左の管理カテゴリ、中央の生きたMap、右の施設Inspector、下部の建築パレットへ領域を分離 +- 表層の世界樹宿と地下根域の製品向け背景アセットを導入。背景Texture、UI Palette、重ね描画Opacityを`VisualThemeProfile`へ分離し、Main SceneのTheme指定だけで一括交換可能。Theme欠落時は手続き描画へ安全にFallbackする +- 施設16種、管理カテゴリ6種、資源4種、表裏層・掘削・回転・保存などの操作アイコンを、実透明の4×4 Atlas Assetとして導入。UIは安定IDから`VisualThemeProfile`の割り当てを取得し、画像名やAtlas座標を直接参照しないため、後からTheme単位または個別割り当てで交換可能 +- 施設Iconを中央Mapの完成施設・建設中施設・配置Previewへも接続。占有CellとHit Testは従来どおりWorldServiceを正とし、Iconは占有範囲中央へ視覚的に重ねるだけなのでSimulationとSave互換性を維持する。選択中の施設/工事は右Inspectorにも同じTheme Iconを表示 +- 客8クラスと従業員8役割群の成人キャラクターMarkerを実透明4×4 Atlasとして導入。客種別/従業員役割の安定IDからTheme割り当てを解決し、派生客種別はクラス接頭辞へ、未知IDは既定MarkerへFallbackする +- 外枠・木・革・羊皮紙Panel、Button 4状態、選択/警告枠、待機Badge、工事中、客/従業員経路、FRONT/BACK境界装飾を4×4 UI Skin Atlasとして導入。主要Panel、全Button、Map経路、工事中Cell、待機Badgeを意味ID経由で接続し、Theme欠落時は従来のStyleBoxFlat/手続き描画へFallbackする +- 外部の画像生成AIで正式素材を再制作するための`ART_ASSET_PRODUCTION_SPECIFICATION.md`を追加。現在接続済みの全画像について推奨解像度、Alpha、Atlas順序、生成指示、差し替え手順、納品判定を固定した +- 1440×810を基準解像度として、`canvas_items`/`expand`/fractional stretchとHiDPIを有効化。背景、施設、共通操作、人物AtlasにはMipmapを生成し、720p・1080p・1440p・4Kで同一UI構造を維持する +- 上部HUDへ営業日、時刻、時間帯、来客、宿泊予定、所持金、Mana、資材、Faith、Heat、一時停止、x1/x2/x4を常設 +- 実World Cell、占有、工事、施設、Guest、StaffのFRONT/BACK別描画 +- PathServiceの実密度/容量から緑・黄・赤の混雑経路を描画し、FacilityServiceの実待機列をMap上の数字Badgeとして表示 +- 施設選択と状態、reason、待機列、Service、配置人員の表示 +- 仕様表のMVP施設16種を選べる画面下部の横スクロール建設パレット +- 回転、配置Preview、重なり等の失敗理由、建設Command +- 掘削モード、正式な費用・入口接続・支保Preview、Rank/運営gate、進捗表示、FRONT/BACK同時解放Command +- 時間、一時停止、x1/x2/x4、Event、Heat、手動Save/Load +- 本番運営の明示起動、個人AP、未払給与、前日損益、要確認/破綻状態、失敗境界の再試行表示 +- 検証済み雇用候補20人の一覧。氏名、役割、勤務帯、契約金、日給、4技能を表示し、原子的な雇用Command、未払/破綻gate、二重クリック防止、即時/予約実行結果、Save済みCommand履歴からの再試行連番復元へ追従する +- foreground Eventの日本語Choiceボタン、安定した選択Command、解決後の停止解除と選択欄消去 +- 1280×720、1440×810、1920×1080、2560×1440、3840×2160で、左Navigation/中央Map/右Inspector/下部Paletteの位置関係を含むLayout自動検証 + +## 統合済みの運営縦断 + +- 14日運営CoordinatorをEconomy/Guest/Staff/Facility/Authority/Eventへ接続し、06:00の営業日締め・朝処理、給与、未払、3回連続支払不能による破綻までRuntimeから実行する +- 人物カタログを使った自動来客・雇用時の日給と、累計客/Faith/稼働施設によるRank条件を日次運営へ接続する +- 実Simulation Tickによる24時間連続運転、選択待ち停止再開、Save復元、部分給与、破綻後no-opを受入試験で確認する +- 14日20,160分については運営境界を加速した連続/Day 7復元の一致を確認する。全Actor AIを含む20,160回の実Tick受入とは区別する +- 掘削と客用通路/業務用通路/従業員ドアをRuntime v6へ統合し、完成失敗時のConstruction・World・Path・通知を一括で戻す + +## MVP垂直スライスとして残る範囲 + +現在は、安全に保存・再生できる「操作可能なSystem縦断」であり、14日MVPの内容完成ではない。次の範囲は引き続き必要となる。 + +- 全Actor AI、全Event、監査、経路移動を含む20,160分の実TickによるS14-CORE受入 +- ユニーク2人の配置・接触判定と、そこから生成する14日Event列 +- 通常隣接Edgeを安全に抑制する垂直Topologyと、客用/業務用昇降機 +- 配置と同一Shiftから関係Event候補を生成する接触判定と事前リスクPreview +- 夕方混雑、FRONT/BACK比較、健全/地下収支を直接比較するScenario Test +- 100客・30従業員の実描画Frame試験 +- 製品用アート、音響、物語本文、完全なチュートリアル、バランス調整 + +## 実行方法 + + make test + make check + make run + +試遊の手順は `PLAYTEST_GUIDE.md`、仕様とTestの対応は `TEST_MATRIX.md` を参照する。各Systemのテストは画面Nodeに依存せずheadlessで実行でき、新しいSystemを追加する際はSnapshot、Atomic Restore、決定性Testを同じ変更に含める。 +14日受入の共通Fixtureと数値境界は `MVP_ACCEPTANCE_SCENARIOS.md` に固定する。 diff --git a/docs/MVP_ACCEPTANCE_SCENARIOS.md b/docs/MVP_ACCEPTANCE_SCENARIOS.md new file mode 100644 index 0000000..6bb4a98 --- /dev/null +++ b/docs/MVP_ACCEPTANCE_SCENARIOS.md @@ -0,0 +1,141 @@ +# MVP 14日受入シナリオ + +この文書は、System単体テストを通過した実装を「14日遊べるゲーム」として受け入れるための判定契約である。ゲームルールは `GAME_SPECIFICATION.md`、System不変条件は `SYSTEM_SPECIFICATION.md` を正本とし、本書は同じ初期状態を分岐して比較するScenario、計測値、合否境界を固定する。 + +## 1. 共通実行契約 + +- Seedは`20260827`、開始時刻はDay 1 06:00、終了時刻はDay 15 06:00(正味20,160分)とする。 +- Simulationは1分を省略せず、通常実行、途中Save再開、同一SnapshotからのA/B分岐で同じCommand列を使う。 +- 自動来客は客クラスと種族を別々に決定し、生成済みの複合GuestDefinition IDを保存する。 +- Player GoldはEconomy Ledgerだけを正本とし、給与、罰金、建設、売上、返金を日次分類へ一度だけ計上する。 +- 各30分境界と、06:00の日次締め・朝処理直後に横断Invariantを検査する。 +- 比較Scenarioでは分岐後も、比較対象以外の需要、価格、従業員、Seed、Event入力を同一にする。 +- 表示Frame性能はSimulation受入と分離し、同じ論理Snapshotを描画Fixtureへ渡して測る。 + +## 2. 共通Fixture + +### 2.1 人口と需要 + +- 客クラス8種すべてを需要表に含める。 +- 種族4種は`guest_id + class_id + world_seed`から決定的に巡回し、長期偏りを最大1人差に抑える。 +- 06:00~10:00をMORNING、10:00~16:00をDAY、16:00~20:00をEVENING、20:00~06:00をNIGHTとして扱う。 +- 通常上限は同時客100人、30分境界の新規到着上限は10人とする。 +- 夕方は食事・酒場・入浴の合成需要を日中より高くし、経路密度差が観測できる入力に固定する。 + +### 2.2 従業員 + +- 自由雇用候補20人のうち、受付、調理、給仕、清掃、物流、警備、保守を少なくとも1人ずつ雇用する。 +- 日勤・夜勤を重複なしで作成し、日給は人物カタログ値を使う。 +- 退職者は翌日以降の給与対象から外し、既発生の未払額だけを別債務として保持する。 + +### 2.3 施設と経路 + +- Rank 1開始時は受付、食堂、標準客室、倉庫、厨房、客用通路、従業員待機所を配置する。 +- FRONTだけでも到達可能な基準Layoutと、BACKの業務通路を追加した改善Layoutを同一Snapshotから作る。 +- 地下営業分岐では酒場、マッサージ室、成人向け個室、警備拠点を段階的に追加する。 +- 客用/業務用通路と従業員ドアは完成時に実ROUTE/職員専用Connectorへ投影する。同一階比較でも従業員ドアの権限分離を受入対象に含める。昇降機を必要とする階間比較は、通常隣接Edgeを抑制する垂直Topologyの完成後に別Scenarioへ昇格する。 + +## 3. S14-CORE: 14日連続運営 + +### 3.1 日程 + +| 期間 | 運営テーマ | 必須観測 | +|---|---|---| +| Day 1~3 | 合法営業の立上げ | 到着、Queue、Service、給与、日次締め | +| Day 4~6 | 夕方需要増加 | 密度、移動時間、待ち時間、離脱 | +| Day 7 | 中間保存 | 05:50 Save、06:10 Load、Digest比較 | +| Day 8~10 | BACK動線改善 | 物流と従業員移動のA/B差 | +| Day 11~13 | 地下営業 | 追加収益、Heat、監査、対策費 | +| Day 14 | 終了判定 | 収支、滞納、関係、全参照整合 | + +### 3.2 合格条件 + +1. 20,160分を欠落、二重Tick、クラッシュ、永久停止なしで処理する。 +2. 30分を超えて停滞したActorは同じTickで復旧処理へ移り、60分以内に移動再開または理由付き退出へ到達する。 +3. Guest、Staff、Path projection、movement credit、Task、Reservation、Service、Event Lock、Ledgerに孤児参照を残さない。 +4. 06:00の日次締め・朝処理、60分需要更新、30分到着を各予定時刻につき一度だけ記録する。 +5. DaySummaryの分類合計、施設別損益、未払、7日平均がLedgerと再計算一致する。 +6. 3回連続支払不能のときだけBANKRUPTとなり、以後の建設・投資・新規到着を停止する。 +7. 同一Seed・同一Command列を2回実行した最終Digestが一致する。 + +## 4. S14-CONGESTION: 夕方混雑の改善 + +Day 4 15:30のSnapshotから次へ分岐する。 + +- A: 施設と通路を変更しない。 +- B: ボトルネックを迂回する客用通路を1本追加し、同じ施設数、価格、需要、人員を維持する。 + +16:00~20:00について、Actorごとの経路要求時刻から到着時刻までを`travel_minutes`、Queue参加からService開始までを`wait_minutes`として測る。途中退出は上限値に丸めず、別件数として残す。 + +合格条件: + +- Bの客移動P95がAより15%以上短い。 +- Bのセル最大密度超過分の累計がAより20%以上少ない。 +- BのService完了数がA以上で、経路不能件数は増えない。 +- 改善が需要減少やActor間引きによるものではないことを、到着計画ID列の一致で確認する。 + +## 5. S14-BACK: FRONT/BACK業務動線 + +Day 8 06:00のSnapshotから次へ分岐する。 + +- A: 物流担当と従業員が客用FRONT通路を共用する。 +- B: 倉庫、厨房、対象施設をBACK業務通路で接続する。 + +Day 8~10について測定し、次をすべて満たす。 + +- Bの物流Request作成から納品までの中央値がAより20%以上短い。 +- Bの従業員Task割当から作業開始までのP95がAより15%以上短い。 +- Bの客待ち時間P95がA以下である。 +- BでもStaff専用EdgeをGuestが利用せず、FRONT/BACKの権限違反が0件である。 +- 従業員ドアの完成・撤去を各1回行い、完成時だけStaffにCost 120のFRONT↔BACK経路が存在し、撤去失敗時はROUTEとConnectorの片側だけを残さない。 + +## 6. S14-LEGALITY: 合法営業と地下営業 + +Day 10 06:00のSnapshotから次へ分岐する。 + +- HEALTHY: 合法施設だけを営業する。 +- UNDERGROUND: 地下施設を追加し、同じ基本需要に地下需要を加える。 + +Day 10~14の比較では、`operating_profit`を売上-原価-給与-維持-Mana費、`risk_adjusted_profit`をoperating_profit-罰金-監査対応費-事件復旧費として定義する。 + +合格条件: + +- 対策費計上前のUNDERGROUND operating_profitはHEALTHYより高い。 +- UNDERGROUNDのHeatまたはSuspicionがHEALTHYより高く、EvidenceまたはIncidentに追跡可能な発生根拠がある。 +- risk_adjusted_profitではUNDERGROUNDが毎回自動的に優位にならず、同一入力で少なくとも1日は差が反転または10%以内へ縮小する。 +- 罰金と対策費を重複計上せず、処分resolution IDの再送で金額が変わらない。 + +## 7. S14-RELATIONSHIP: 配置リスクの予測 + +ヒロインとライバルを同じShift・同じ施設へ配置する分岐と、時間または施設を分ける分岐を比較する。 + +合格条件: + +- 配置確定前Previewに、接触予定分数、Shield攻撃候補、発火可能Event、回避案を表示できる。 +- 高接触分岐だけでRival BondまたはShield関連候補が増え、その根拠に共有施設と重複時間が含まれる。 +- Route Lockは警告を伴う確認Commandなしに確定しない。 +- Content FilterがREPLACEまたはDISABLEでも関係値、Cost、Lock、Cooldownの機械的結果は一致する。 + +## 8. S14-SAVE: 途中保存とRecovery + +- Day 7 23:50に正式Saveし、Day 8 00:10まで進めた最終状態を記録する。 +- 23:50からLoadして同じCommand列を再実行し、00:10のClock、Gold、Queue、関係値、Path密度、Operations境界履歴、最終Digestを一致させる。 +- checksum-validだがDomain-invalidな正式候補と、Domain-validなbackupを同時に置き、Recoveryがbackupへ自動fallbackすることを確認する。 +- 全候補がDomain-invalidなら、Load前のlive state、pause、Content Filterを1フィールドも変更せず失敗する。 + +## 9. PERF-DISPLAY: 表示性能 + +- 論理Fixtureは100客、30従業員、MVP施設16種、FRONT/BACK両層とする。 +- 基準PC、解像度1280×720、x1とx4を各5分測定し、最初の30秒をwarm-upとして除外する。 +- 平均55fps以上、1% low 45fps以上、入力から表示反映まで100ms以下を合格とする。 +- Simulation Tick遅延、描画Frame遅延、Save停止を別々に記録し、平均値だけで瞬間停止を隠さない。 + +## 10. 失敗時の記録 + +失敗レポートには最低限、Seed、Scenario ID、分岐名、絶対分、直前Command ID、境界処理名、関連Entity ID、Invariant code、直前の有効Snapshot digestを含める。自動修復を行った場合は修復対象と根拠を列挙し、Gold、施設、関係Route Lockを推測で作り直して合格扱いにしてはならない。 + +## 11. 現時点の自動化状況 + +`tests/mvp/test_runtime_operations_acceptance.gd`では、実Simulation Tickを1,440回進める24時間受入と、20,160分分の運営境界を加速した14日受入を分けている。前者は選択待ちの停止再開、Save復元、Command再送、部分給与、3回連続支払不能、破綻後no-opまで実Tickで確認する。後者は連続実行とDay 7復元のOperations Snapshot/Digest一致を確認する。 + +14日側はActor AI、Path移動、全Event、監査を20,160回更新するS14-COREそのものではない。第3章の合格を宣言するには、同じFixtureで省略なしの全Runtime Tickを別途完走する必要がある。 diff --git a/docs/PIXEL_TOWER_VISUALS.md b/docs/PIXEL_TOWER_VISUALS.md new file mode 100644 index 0000000..8729b73 --- /dev/null +++ b/docs/PIXEL_TOWER_VISUALS.md @@ -0,0 +1,57 @@ +# ドット絵表示と見学ガイド + +2026-09-09時点。『The Tower SP』を参考に、世界樹の断面・施設の内装・小さな人物の営みを中心に見せる。ゲームルールや通常の新規ゲーム条件は変更していない。 + +## 起動と確認 + +ローカル制作フォルダで実行する。 + +```sh +make preview-pixel # 建設済みの宿を操作して眺める +make capture-pixel # 時間差の画面2枚と状態JSONを保存 +make test-pixel-ui # ドット表示・カメラ・実施設クリックの回帰確認 +``` + +`make run`は通常ゲームを起動する。`make preview-pixel`は、受付・食堂・厨房・客室と上階通路を組み、雇用した従業員と実際の来客で運営する専用見学モードである。起動時に施設の建設と時間進行を行うため、準備に少し時間がかかる。 + +見学は保存なし。自動保存を無効にし、画面の保存・読込ボタンも無効にする。通常の新規ゲームを完成済みの宿へ置き換えない。見学用の食堂には食材32個を明示的に用意し、通常の在庫処理で消費させる。 + +キャプチャは`artifacts/pixel-tower/pixel-tower.png`、`pixel-tower-later.png`、`pixel-tower-state.json`を生成する。既定では18ゲーム分を隔てた2枚となり、実際の客・従業員・営業状態をJSONと照合できる。PNG出力には描画可能なデスクトップ環境が必要。 + +## 操作 + +| 操作 | 動作 | +|---|---| +| ホイール | カーソル付近を基準に拡大・縮小 | +| 中ボタンドラッグ | 断面を移動 | +| Home | 全体表示へ戻す | +| G | 格子と混雑表示を切り替え | +| B | 建築パレットを開閉 | +| I | 運営・選択中施設の詳細を開閉 | +| H | 雇用候補を開閉 | +| Tab | 客用の表層 / 裏方の裏層を切り替え | +| E / R | 掘削モード / 建築物を回転 | +| Space / 1・2・4 | 停止・再開 / 時間倍率を変更 | +| Esc | 雇用候補・使用中のツールを解除。ツール未使用時はパネルも閉じる | +| 施設をクリック | 営業・待機列・人員などの実状態を右側に表示 | + +初期状態では建築パレットと運営詳細を閉じ、マップを広く見せる。詳細はマップ右側に重ねて開く。判断が必要なイベントは詳細パネルを開き、選択肢を表示する。案内は下部に常時表示する。 + +## 現在の表示 + +- 世界樹、空、幹、葉、床と室内をドット主体で描く。受付のカウンター、客室のベッド、食堂の卓、厨房などを施設ごとに描き分ける。 +- Kenneyの[Tiny Dungeon](https://kenney.nl/assets/tiny-dungeon)と[Tiny Town](https://kenney.nl/assets/tiny-town)を人物・小物・庭木に利用する。16×16px素材、Nearest、Mipmap無効で表示する。配布元とCC0ライセンスは[素材一覧](../assets/kenney/README.md)と各パックの`License.txt`に記録している。 +- 客と従業員はRuntime上の実在人物で、移動元と現在セルの間を補間する。同じ元絵の足のずらし・上下動・左右反転で歩行を表現し、待機・利用・作業の小さな状態表示を添える。停止中はアニメーションも止まり、人物位置は確定セルを表示する。 +- UIは濃緑・クリーム・木色の小さな平坦な枠を使用する。従来の高解像度生成背景・人物Bust・装飾Skinは現在のマップに使用しない。小型のUIアイコンは既存Theme参照を残す。 +- ズーム・移動・表示レイヤー・格子の切替は描画側だけで扱う。建築の占有セルや保存データの形式は変えない。 + +## 制約と次の制作 + +- Kenneyの導入パックは静止画である。現在の歩行はコードによる元絵の加工であり、手描きの歩行・食事・清掃・運搬の専用アニメーションは今後追加する。Atlasで隣にいる別人物を歩行差分として再生していない。 +- エレベーター本体の昇降・乗降・待ち行列を含む運行は未実装。施設カタログの昇降機は接続の試作用で、見学では既存の上階通路を使って人物の行動を確認する。 +- 見学は在庫を用意した再現用の運営場面であり、収支・配送・混雑の完成度を保証する受入試験ではない。キャプチャにはその時点の実状態が出る。 +- 施設ラベルは表示幅・選択・ホバーに応じて省略する。大きい世界を全体表示すると縮小されるため、人物の動きはズームして確認する。 + +表示の主な実装は`scenes/main/world_preview.gd`、専用の家具描画は`scripts/ui/pixel_tower_art.gd`、UIは`scenes/main/main.gd`。見学シナリオは`scripts/ui/pixel_tower_demo.gd`、出力処理は`tests/ui/capture_pixel_tower.gd`にある。回帰テスト`tests/ui/test_pixel_tower.gd`は実受付の建設・完工を経由して、ズームと移動後の占有セル命中、表裏の選択、地下を含む座標往復、停止時アニメーション、描画操作前後のシミュレーションダイジェスト不変を確認する。 + +接客・宿泊サービス中の客は、経路上のアクセスセルから施設内の利用位置へ描画上だけ投影する。経路探索、権威セル、保存状態は変更しない。 diff --git a/docs/PLAYTEST_GUIDE.md b/docs/PLAYTEST_GUIDE.md new file mode 100644 index 0000000..5bc7ed1 --- /dev/null +++ b/docs/PLAYTEST_GUIDE.md @@ -0,0 +1,58 @@ +# 試遊ガイド + +このガイドは、現在のプロトタイプで画面操作とSimulation連動を短時間で確認するためのものです。 + +## 起動 + +Godot 4.7系を用意し、プロジェクト直下で次を実行します。 + + make editor + +Godotが開いたら右上の実行ボタンを押します。エディターを介さず起動する場合は次を使います。 + + make run + +## 基本操作 + +| 操作 | マウス/キー | +|---|---| +| 施設を選ぶ | 左側の建設パレット | +| 配置候補を確認・建設 | 地図上へ移動/左クリック | +| 施設の詳細を表示 | 建設選択を解除して施設を左クリック | +| 施設を回転 | `R` | +| 建設選択を解除 | `Esc` | +| 地下掘削モード | 画面下の「掘削」、または `E` | +| 雇用候補を開く/閉じる | 左側の「候補を見る」、または `H` | +| FRONT/BACK切替 | 画面下のボタン、または `Tab` | +| 一時停止/再開 | 画面下のボタン、または `Space` | +| 速度変更 | `1`、`2`、`4` | +| 手動保存/読込 | 画面右下の「保存」「読込」 | + +## 最初の確認手順 + +1. 一時停止した状態で「受付」を選び、中央の境界階へ配置します。 +2. 「一般客室」を地上側、「大衆食堂」を空いているFRONTセルへ配置します。 +3. `4`で時間を進め、工事表示が完成施設へ変わることを確認します。 +4. `Esc`で建設を解除し、完成施設を選びます。営業状態、待機列、サービス数、配置人員が左側へ表示されます。 +5. `Tab`でBACKへ切り替え、「倉庫」を配置します。FRONTとBACKの占有が別管理であることを確認します。 +6. 「保存」を押し、建設や時間を進めてから「読込」を押します。保存時点へ戻れば成功です。 + +`H`で雇用候補を開くと、20人の氏名、役割、勤務帯、契約金、日給、接客・運営・戦闘・裏社会の技能を比較できます。「雇用する」を押すと契約金が一度だけ支払われ、候補は雇用済み表示へ変わります。時間進行中に押した場合は次のCommand処理で確定し、処理中の二重送信は行いません。未払給与または破綻状態では新しい契約を拒否し、保存後に再起動しても過去の操作IDと衝突せず再試行できます。 + +世界樹Rank 2へ到達すると「掘削」を選べます。地下の候補へカーソルを合わせると費用、資材、所要時間、入口接続、支保条件を確認でき、クリックするとFRONT/BACKを同時に掘削します。Rank不足や未払給与などで開始できない場合は、理由を画面へ表示します。 + +建設パレットは上下にスクロールでき、MVP施設16種をすべて選べます。客用通路、業務用通路、従業員ドアは完成時に実際のROUTE/職員専用経路へ変換されます。客用/業務用昇降機は安全な階間経路が未実装のため、現時点では資源を消費せず建設を拒否します。 + +配置できない場合は、範囲外、ゾーン不一致、レイヤー不一致、重なり、支持不足、資源不足のいずれかが画面へ表示されます。 + +## 自動検証 + +全Systemと操作画面の回帰試験: + + make test + +エディター読み込みとメイン画面の起動確認: + + make check + +現在はSystem実装と操作可能な運営画面を検証する段階です。最終アート、音響、物語コンテンツ、全施設、完全なチュートリアル、製品版バランスは今後の制作範囲です。 diff --git a/docs/SYSTEM_SPECIFICATION.md b/docs/SYSTEM_SPECIFICATION.md new file mode 100644 index 0000000..c9c8b71 --- /dev/null +++ b/docs/SYSTEM_SPECIFICATION.md @@ -0,0 +1,2519 @@ +# The Tree 詳細システム仕様書 + +- 文書ID: SDS-CORE-001 +- 版: 0.1.1 +- 状態: 実装設計基準案 +- 対応ゲーム仕様: GDD-CORE-001 v0.1.1 +- 対象: Godot 4.7系/GDScript +- 最終更新日: 2026-08-27 + +## 0. 文書の目的 + +GAME_SPECIFICATION.mdが「何を遊ばせるか」を定義するのに対し、本書は「内部でどの順に、どのデータを、どの状態へ変化させるか」を定義する。 + +優先順位: + +1. GAME_SPECIFICATION.md +2. 本書 +3. DESIGN_DECISIONS.md +4. ARCHITECTURE.md +5. 実装とデータ + +本書内の調整値はResourceへ外出しする。状態遷移、不変条件、処理順はセーブ互換性に関わる固定仕様として扱う。 + +--- + +# 1. システム構成 + +## 1.1 実行時サービス + +| サービス | 責務 | 他サービスから直接変更可能か | +| --- | --- | --- | +| SimulationKernel | 時間、処理順、コマンド実行 | 不可 | +| WorldService | セル、占有、支持、掘削 | Command経由のみ | +| PathService | 経路グラフ、探索、到達性 | 読み取り中心 | +| ConstructionService | 建設、解体、修理ジョブ | Command経由のみ | +| FacilityService | 稼働状態、予約、行列、サービス | Kernelからのみ | +| InventoryService | 在庫、予約、搬送要求 | Transaction経由のみ | +| GuestService | 客生成、欲求、意思決定、状態 | Kernelからのみ | +| StaffService | シフト、勤務、タスク、疲労 | Kernelからのみ | +| RelationshipService | 関係値、盾、段階イベント | Event経由のみ | +| EconomyService | Gold、台帳、日次決算、未払金 | Transaction経由のみ | +| AuthorityService | Heat、監査、証拠、処罰 | Event経由のみ | +| EventService | 条件評価、イベント排他、選択肢 | Kernelからのみ | +| SaveService | スナップショット、移行、整合性 | セーブ境界のみ | +| NotificationService | 通知集約、ログ、場所リンク | 状態変更不可 | + +UIノードはサービスの状態を読み、Commandを発行する。UIから配列やDictionaryを直接書き換えない。 + +## 1.2 静的データと実行時データ + +### 静的データ + +施設定義、客アーキタイプ、職業、種族、特性、アイテム、研究、イベント、キャラクター定義。ゲーム中に値を変更しない。 + +### 実行時データ + +建設済み施設、生成済みユニット、在庫、関係値、台帳、イベント状態。セーブ対象。 + +静的定義は一意IDで参照し、実行時データにResourceパスやノード参照を永続化しない。 + +## 1.3 ID規則 + +| 種類 | 形式 | 例 | +| --- | --- | --- | +| 施設定義 | facility.カテゴリ.名称 | facility.food.cafeteria | +| 施設個体 | fac-連番 | fac-00001234 | +| ユニット | unit-連番 | unit-00004567 | +| アイテム | item.カテゴリ.名称 | item.food.monster_meat | +| イベント | event.章.名称 | event.ch01.first_inspection | +| 特性 | trait.名称 | trait.night_owl | +| 研究 | research.分野.名称 | research.logistics.auto_order | + +表示名を変更してもIDは変更しない。連番はセーブ内で単調増加し、削除個体のIDを再利用しない。 + +--- + +# 2. シミュレーション核 + +## 2.1 時刻表現 + +- absolute_minute: int64。Day 1 00:00を0とする。 +- ゲーム開始: absolute_minute 360、Day 1 06:00。 +- day = floor(absolute_minute ÷ 1440) + 1。 +- minute_of_day = absolute_minute mod 1440。 +- 表示時刻だけを時・分へ変換する。 +- 日付、シフト、期限の比較に浮動小数点を使用しない。 + +## 2.2 実時間変換 + +x1ではゲーム内1分を現実0.5秒で進める。 + +- x1: 2ゲーム分/現実秒 +- x2: 4ゲーム分/現実秒 +- x4: 8ゲーム分/現実秒 + +deltaをaccumulatorへ加算し、0.5/speed秒ごとに1分Tickを実行する。 + +## 2.3 Tick制御 + +- 1フレームで処理するTickは最大8。 +- 未処理Tickは次フレームへ持ち越し、飛ばさない。 +- backlogが120ゲーム分を超えた場合は自動一時停止し、性能警告を表示する。 +- 一時停止中はaccumulatorを増やさない。 +- ロード直後の最初のフレームではTickを進めない。 +- オフライン中の時間経過は行わない。 +- 1フレーム内の途中TickがPauseを要求した時点で、同フレームに積まれた残りTickを実行しない。 +- 未解決の日次境界、外部rollback隔離、破綻などのhard stopは速度変更や手動stepで迂回できず、Tick開始時刻を維持する。 + +## 2.4 1分Tickの処理順 + +1. 実行時刻に達したCommandを確定する。 +2. 時刻境界処理を行う。 +3. Mana生成、消費、給電停止を確定する。 +4. 建設、修理、掘削の進捗を更新する。 +5. 物流の受け渡しと在庫予約を確定する。 +6. 完了した施設サービスを確定する。 +7. Health、Stamina、Stress、欲求、環境値を更新する。 +8. 事故、関係、監査、物語イベントを判定する。 +9. 施設行列を繰り上げる。 +10. 対象ユニットの意思決定を行う。 +11. 集約値と通知用Snapshotを更新する。 + +同じ段階内では、原則としてinstance_idの昇順で処理する。 + +通常進行TickのCommand段階はClock加算より先に実行するが、そのCommandとCommand起因のLedger、Service、DomainEventには到達予定の`absolute_minute + 1`をeffective minuteとして一貫して渡す。一時停止中に即時確定するUI Commandだけは現在時刻を使う。06:00予定Commandを終了済みの05:59以前の営業期間へ混入させない。 + +時刻境界で外部Domain処理が失敗した場合は、Clockをその境界分に固定し、後続のMana~Snapshot段階を保留する。確認・再試行が成功したときだけ、保留した同一分の後続段階を各1回実行する。失敗前のCommand通知を取り消したり同じCommandを再実行したりしない。 + +## 2.5 時刻境界 + +| 時刻 | 処理 | +| --- | --- | +| 00:00 | 暦日の日付更新 | +| 05:00 | 昼番の出勤準備 | +| 06:00 | 前営業日のService確定、日次決算、昼番開始、個人AP回復、監査抽選、朝イベント、オートセーブ予約 | +| 17:00 | 夜番の出勤準備 | +| 18:00 | 夜番開始 | +| 各時00分 | Mana集計、流行、定期効果 | + +06:00境界は、保留Service確定、Ledger集計・照合、未払/破綻判定、昼番開始、AP回復、監査抽選、朝Event、Autosave予約の順。途中で外部Domain処理が失敗した場合は同じ06:00に固定し、完了済み段階を再実行しない。 + +## 2.6 決定論的乱数 + +乱数は用途別Streamに分ける。 + +- world +- guest_spawn +- guest_decision +- staff +- relationship +- authority +- event +- loot + +乱数入力はworld_seed、stream_id、entity_id、local_counterから生成する。Godotの汎用hash関数をセーブ互換性へ使用せず、固定アルゴリズムで64bit値を生成する。 + +各Streamはlocal_counterを保存する。UI表示、アニメーション、パーティクルはゲーム判定用Streamを消費しない。 + +## 2.7 Snapshot + +UIは毎フレームの生データではなく、Tick完了後の読み取り専用Snapshotを参照する。 + +Snapshotに含める: + +- 時刻と速度 +- 資源と全体指標 +- 表示範囲内のユニット位置と状態 +- 施設状態、行列、予測値 +- 通知差分 + +Snapshot生成中にゲーム状態を変更しない。 + +--- + +# 3. CommandとTransaction + +## 3.1 Command + +プレイヤー操作は次の共通形式で発行する。 + +| フィールド | 型 | 内容 | +| --- | --- | --- | +| command_id | int64 | セーブ内単調増加 | +| issued_at | int64 | 発行absolute_minute | +| execute_at | int64 | 実行予定時刻 | +| sequence | int64 | 同時刻の順番 | +| type | StringName | コマンド種別 | +| source | StringName | UI、イベント、デバッグ | +| payload | Dictionary | IDと値だけを持つ | + +## 3.2 Command適用 + +- 一時停止中のCommandはexecute_atを現在時刻とし、再開前でも確定可能。 +- Commandは発行時の事前検証と、実行時の最終検証を行う。 +- 事前検証成功後に状況が変わった場合、実行時に失敗できる。 +- 失敗時は状態を一切変更せず、CommandResultを返す。 +- 同時刻Commandはsequence昇順。 +- Undoは建設予約、シフト編集、未実行ポリシー変更だけに提供する。 +- 受理済みCommandは処理完了後も`command_id`と`idempotency_key`の対応を保存し、pending/in-flight/processedの全既知IDと1対1で照合する。処理済み結果だけを残して元の冪等Keyを欠落させてはならない。 + +## 3.3 標準Command + +- BuildFacility +- CancelConstruction +- DemolishFacility +- ExcavateCells +- RepairFacility +- SetFacilityPolicy +- OpenFacility +- CloseFacility +- AssignStaff +- RemoveStaff +- SetShiftTemplate +- SetOwnerPresence +- QueuePersonalAction +- StartResearch +- StartInvestment +- StartQuest +- ChooseEventOption +- ScheduleInspectionResponse + +## 3.4 CommandResult + +| 値 | 意味 | +| --- | --- | +| OK | 成功 | +| INVALID_PAYLOAD | データ不足 | +| NOT_FOUND | 対象IDなし | +| NOT_UNLOCKED | 未解禁 | +| INSUFFICIENT_RESOURCE | 資源不足 | +| INVALID_LOCATION | 場所不正 | +| OCCUPIED | セル占有 | +| NO_ACCESS | 接続不能 | +| CONFLICT | シフトや予約競合 | +| LOCKED_BY_EVENT | イベント中 | +| CONTENT_DISABLED | 設定で無効 | +| STATE_CHANGED | 発行後に前提が変化 | + +UIへはコード、対象、短い説明、解決候補を返す。 + +## 3.5 Transaction + +複数状態を同時に変更する処理はTransactionとして実行する。 + +例: 食事完了 + +1. 予約食材と料理ポイントを確認。 +2. 客のGoldを確認。 +3. 在庫を消費。 +4. 客のGoldを減らす。 +5. 宿のGoldを増やす。 +6. 台帳へ記録。 +7. 欲求と満足を変更。 +8. 全て成功した場合だけCommit。 + +途中失敗時は全変更を破棄する。Gold、在庫、関係値を部分適用しない。 + +--- + +# 4. Worldデータ + +## 4.1 MapBounds + +論理座標はXが左右、Yが上下。Y正方向が地上。 + +| Rank | X範囲 | 地上Y | 地下Y | +| ---: | --- | --- | --- | +| 1 | -12~12 | 0~6 | 使用不可 | +| 2 | -18~18 | 0~10 | -1~-4 | +| 3 | -24~24 | 0~16 | -1~-8 | +| 4 | -32~32 | 0~24 | -1~-14 | +| 5 | -40~40 | 0~32 | -1~-20 | +| 6 | -48~48 | 0~42 | -1~-28 | +| 7 | -60~60 | 0~56 | -1~-40 | + +範囲拡張で既存座標を移動しない。 + +### 4.1.1 世界樹Rankの昇格境界 + +Rankは1段階ずつだけ上げ、低下と飛び級を許可しない。現在Faithを研究で消費しても進行が巻き戻らないよう、Rank条件は`faith_lifetime`を参照する。`guest_lifetime`も退店や個体破棄では減らさない。 + +| 対象Rank | 機械条件 | +|---:|---| +| 2 | Reputation 25以上、現在資産10,000G以上 | +| 3 | 累計Faith 100以上、累計客300以上 | +| 4 | Reputation 45以上、現在資産50,000G以上、地上・地下に稼働施設各1以上 | +| 5 | 累計Faith 500以上、重要Quest完了、地上・地下に稼働施設各1以上 | +| 6 | ReputationまたはUnderground Influenceの一方が65以上、地上・地下に稼働施設各1以上 | +| 7 | 累計Faith 1,500以上、Reputation 60以上、Underground Influence 40以上、主要章完了、地上・地下に稼働施設各1以上 | + +昇格Commandは、条件値と不足理由を全件返してから次の順で原子的に実行する。 + +1. 現在Rank、GameState、World、Pathを退避する。 +2. 対象RankのMapBoundsへWorldを非破壊拡張する。 +3. 新規境界Cellを初期化し、Path Graphを再構築する。 +4. GameStateとProgressionのRankを記録する。 +5. 全て成功した場合だけ`WorldTreeRankAdvanced`を発行する。 + +2~4のいずれかが失敗した場合は退避状態へ戻す。Rollback自体が失敗した場合は昇格境界を隔離し、再送による二重拡張を止める。同じ`request_id`と対象Rankの再送は記録済み結果を返し、同じIDを別Rankへ使った場合は競合として拒否する。 + +## 4.2 Cell + +各X,YはSpatialCellを持ち、その中にFRONTとBACKのLayerCellを持つ。 + +SpatialCell: + +- x、y +- zone +- terrain_id +- hardness 0~3 +- excavated +- structural_anchor +- mana_node +- reserved_by_job + +LayerCell: + +- facility_instance_id +- corridor_id +- decoration_ids +- utility_flags +- cleanliness 0~100 +- beauty 0~100 +- noise 0~100 +- security 0~100 +- concealment 0~100 +- temporary_hazard_ids + +存在しないセルを暗黙生成しない。MapBounds内だけ生成する。 + +## 4.3 Zone判定 + +- y > 0: ABOVE_GROUND +- y = 0: BOUNDARY +- y < 0: UNDERGROUND + +施設定義zoneがANYの場合は全Zoneへ配置できる。FRONT、BACKはZoneとは独立。 + +複数Cell施設のZone判定は次で固定する。 + +- ABOVE_GROUND/UNDERGROUNDは占有する全Cellが指定Zone内でなければならない。 +- BOUNDARYは配置anchorが`y = 0`であり、残りのFootprintが地下へはみ出さないことを要求する。これにより、境界を入口とする受付などは地上側へ広がれる。 +- ANYはZoneを制限しない。ただしMapBounds、Layer、占有、地下Cellの掘削条件は別に全Cellへ適用する。 + +## 4.4 占有 + +- 一つのLayerCellへ主要施設は一つ。 +- 通路と主要施設が共存できるかはfacility.pass_throughで決める。 +- 壁内設備はutility_flagsへ格納し、施設と共存できる。 +- 建設中セルはjobが予約し、別建設を拒否する。 +- 一つの施設は全占有セルをinstance側にも保存する。 + +Cell側とinstance側の占有情報が一致しない場合はセーブ破損として扱い、自動修復ログを残す。 + +## 4.5 支持 + +地上建築は離散的な支持Graphで検証する。 + +- 世界樹幹と天然枝は無限支持Anchor。 +- 人工床は1セル当たり支持容量2。 +- 強化床は容量5。 +- 支柱は接続Componentへ容量10を追加。 +- 小型施設は1セル当たり重量1。 +- 中型施設は重量2。 +- 大型・重量施設は重量3。 + +接続Componentの合計支持容量が合計重量以上でなければ建設不可。MVPでは稼働中の建物が突然崩壊せず、損傷で支持不足になった場合は該当施設をBROKENにして退避させる。 + +## 4.6 掘削 + +地下のSpatialCellを使用する前にexcavatedをtrueへする。 + +掘削基準: + +| Hardness | 費用 | 時間 | +| ---: | ---: | ---: | +| 0 | 20G/1M | 30分 | +| 1 | 50G/1M | 60分 | +| 2 | 100G/2M | 120分 | +| 3 | 200G/4M | 240分 | + +- 掘削済みセルに隣接するセルだけ掘削できる。 +- 地下入口からの接続がない孤立掘削は不可。 +- 掘削すると同じX,YのFRONTとBACKが利用可能になる。 +- Hardness 2以上の空間を幅4セル以上連続させる場合、支保設備を要求する。 +- 掘削中断時は進捗を保持する。 + +## 4.7 環境値更新 + +環境Sourceは中心値、半径、距離減衰、遮蔽タグを持つ。 + +更新手順: + +1. 変更Source周辺だけDirty領域に登録。 +2. 基礎値50から加算・減算。 +3. 壁、扉、防音、Layer境界の減衰を適用。 +4. 0~100へclamp。 +5. 変更量1未満は通知しない。 + +全マップを毎分再計算しない。建設、解体、汚れ、イベントの影響範囲だけ更新する。 + +--- + +# 5. 建設システム + +## 5.1 PlacementReport + +建設プレビューは次を返す。 + +- can_build +- error_codes +- occupied_cells +- unsupported_cells +- access_points +- mana_connection +- total_cost +- build_time +- predicted_environment_delta +- predicted_operating_cost +- predicted_heat + +エラーは複数同時表示する。 + +## 5.2 BuildFacility + +1. 定義と解禁を確認。 +2. 回転と原点から占有セルを算出。 +3. Zone、Layer、掘削、占有、支持を検証。 +4. 通路・Manaの接続可能性を検証。 +5. GoldとMaterialsの25%をDepositとして支払う。 +6. セルをjob_idで予約。 +7. ConstructionJobをPLANNEDで生成。 +8. 建築担当が到着すると残額を支払いCONSTRUCTINGへ。 +9. 進捗100%でFacilityInstanceを生成。 +10. 占有、PathGraph、環境Dirty領域を更新。 + +残額不足の場合はPLANNEDのまま停止し、1日経過で警告する。 + +## 5.3 ConstructionJob + +- job_id +- definition_id +- origin、rotation、layer +- reserved_cells +- progress 0~10000 +- state +- assigned_builder_ids +- deposit_paid +- remaining_cost +- created_at +- started_at + +進捗は整数basis pointで保存する。 + +ProgressPerMinute = +基礎建築速度 +× 作業員Operation倍率 +× 難易度倍率 +× 到達性倍率 + +## 5.4 キャンセル + +- PLANNED: Depositを全額返還、予約解除。 +- CONSTRUCTING 25%未満: 未消費Materialsの75%返還。 +- 25~74%: 未消費Materialsの50%返還。 +- 75%以上: Materialsの25%返還。 +- Goldの残額は返還しない。 + +## 5.5 解体 + +- 営業中、利用中、在庫ありでは即時解体不可。 +- CloseAndDemolishは新規受付を停止し、利用終了、在庫搬出後に開始。 +- EmergencyDemolishは客と在庫を安全地点へ移し、Reputationと損失ペナルティを受ける。 +- 解体完了で建設Materialsの25%を返す。 +- PathGraphを更新し、到達不能になった施設をNO_ACCESSへする。 + +## 5.6 施設移動 + +完成施設の直接ドラッグ移動は実装しない。BlueprintCopyで設定をコピーし、解体と再建築を行う。 + +--- + +# 6. Pathと交通 + +## 6.1 Graph + +FRONTとBACKに別の歩行Graphを持つ。従業員ドア、業務窓口、昇降機がGraph間または階間Edgeを作る。 + +完成した通路設備は主要施設占有を歩行可能とみなすのではなく、同一Cellの`ROUTE`副占有からPath Graphへ投影する。客用通路はFRONT、業務用通路はBACKに限定する。従業員ドアはBACK側の設置Cellと同座標のFRONT Cellを、`staff`利用権限付き双方向Connector、Cost 120で接続する。投影・撤去はROUTE、Path、Connector、投影履歴を一つの原子的境界として更新する。 + +昇降機は運行Connectorだけでなく、同階の通常隣接Edgeを通行不能にできる垂直Topologyを先に要求する。このTopologyがない段階では、待ち時間や停止状態を通常Edgeで迂回させないため、投影要求を無変更で拒否する。 + +Node: + +- cell_key +- allowed_unit_types +- base_capacity +- current_density +- temporary_block + +Edge: + +- from、to +- movement_type +- base_cost +- allowed_tags +- dynamic_cost + +## 6.2 A* + +コストは整数Cost Unitで計算する。 + +| 要素 | Cost | +| --- | ---: | +| 横1セル | 100 | +| 従業員ドア | 120 | +| 階段1階 | 150 | +| 清潔ペナルティ | (100 - cleanliness) ÷ 2 | +| 混雑 | max(0, density - capacity) × 75 | +| 危険 | max(0, 50 - security) | +| 禁止に近い忌避 | 0~10,000 | + +昇降機Edgeは予測待ち時間と移動時間をCostへ変換する。1ゲーム分をCost 100として扱う。 + +Heuristicはマンハッタン距離×100。動的コストを含めず、過大評価しない。 + +## 6.3 経路キャッシュ + +次の組み合わせで短期Cacheする。 + +- start_region +- destination_access_id +- unit_access_profile +- graph_revision + +建設、解体、扉状態変更でgraph_revisionを増やす。混雑だけでは全Cacheを無効化せず、移動中の再評価条件に使う。 + +## 6.4 再探索条件 + +- 次Edgeが閉鎖 +- 予測到着が初期予測の2倍超 +- 60ゲーム分進行なし +- 目的施設が停止 +- より近い同カテゴリ施設の待ち時間が30分以上短い + +再探索は同一個体につき15ゲーム分に一度まで。連続再探索による振動を防ぐ。 + +## 6.5 混雑表現 + +通路セルに硬い一人制限を設けない。複数ユニットを許容し、密度超過で速度を落とすSoft Collisionを採用する。 + +SpeedMultiplier = clamp(0.25, 1.0, capacity ÷ max(capacity, density)) + +ユニット表示は局所的にずらし、完全重なりを避ける。経路判定は中心セルで行う。 + +## 6.6 昇降機 + +CabinState: + +- IDLE +- MOVING_UP +- MOVING_DOWN +- DOOR_OPEN +- OUTAGE + +Request: + +- request_id +- unit_id +- floor_from +- floor_to +- direction +- priority +- enqueued_at + +制御: + +1. 現在方向の進行先に要求がある限り方向を維持。 +2. 進行先要求がなく、反対方向に要求があれば反転。 +3. 同一階はpriority、enqueued_at、unit_idの順。 +4. 救護、警備緊急出動、主人公、VIP、通常の順でpriorityを付ける。 +5. 通常客を60ゲーム分以上待たせる場合、VIPより先に乗せて飢餓を防ぐ。 + +扉開閉2分、乗降は一人1分、階移動は施設定義値。容量超過は乗車させない。 + +## 6.7 到達不能 + +経路が見つからない場合: + +1. 目的予約を解除。 +2. 代替施設を検索。 +3. 安全な待機地点または出口を検索。 +4. すべて失敗した場合、直近の有効通路へ復旧移動。 +5. Warningログへ元座標、目的、graph_revisionを記録。 + +復旧移動はプレイヤー利益を発生させず、関係イベントも進めない。 + +--- + +# 7. Facility内部 + +## 7.1 FacilityDefinition + +詳細なフィールド型、Schema version、Definition/Instance/日次履歴の所有境界は`FACILITY_DATA_DESIGN.md`を正本とする。既存定義はSchema version 1で互換読込し、version 2はAccess、営業時間、Upgrade、Policy、成人向け安全境界に加えて、サービス種別ごとの時間・品質・価格・処理単位・欲求効果を必須検証する。 + +必須フィールド: + +- id、display_name、category +- allowed_zones、layer、footprint、rotations +- weight_per_cell、pass_through +- build_gold、build_materials、build_minutes +- maintenance_gold、mana_draw、power_priority +- staff_min、staff_max、required_roles、primary_skill +- capacity、queue_capacity +- service_types、default_service_type、service_profiles +- base_service_minutes、base_quality、base_price +- inventory_inputs、inventory_outputs +- access_point_offsets、staff_door_offsets +- environment_sources +- heat_per_service +- policy_schema +- unlock_rank、research_requirements +- adult_content、content_replacement_id + +Definition読込時にvalidateを行い、必須値不足はプロジェクト起動を失敗させる。 + +## 7.2 FacilityInstance + +- instance_id、definition_id +- origin、rotation、occupied_cells +- level +- state、state_reasons +- durability +- local_inventory_id +- assigned_staff_by_shift +- active_staff_ids +- queue_entries +- active_services +- policy_current +- policy_pending +- statistics_today +- lifetime_statistics +- created_at + +## 7.3 状態評価 + +優先順位: + +1. BROKEN +2. CONSTRUCTING +3. CLOSED +4. NO_ACCESS +5. NO_POWER +6. NO_STAFF +7. DEGRADED +8. ACTIVE + +内部では複数reasonを保持する。主状態が変化したときだけ通知し、同じ警告を毎分出さない。 + +## 7.4 Staff算入 + +Staffが次を全て満たす場合だけactive_staffへ算入する。 + +- WORKING状態 +- 施設access pointへ到着済み +- 対応シフト +- Health 1以上、Stamina 1以上 +- 必要Roleまたは代替許可 +- 他施設に排他的に算入されていない + +staff_min未満では新規サービスを開始しない。進行中サービスは安全に終了する。 + +## 7.5 ServiceRequest + +- request_id +- guest_ids +- facility_id +- service_type +- created_at +- quoted_price +- policy_snapshot +- reserved_inventory_ids +- preference_match +- expected_quality +- expected_wait +- priority + +価格とポリシーはRequest作成時に固定する。行列中に値上げして既存客へ適用しない。 + +## 7.6 QueueEntry + +標準はpriority、enqueued_at、request_idの順。FIFO施設ではpriorityを全て0にする。 + +- queue_capacity超過時は予約を拒否する。 +- 入列時に予測待ち時間を客へ保存する。 +- 実待ちが予測の2倍を超えた時点で再意思決定を許可する。 +- 客が離脱したEntryは即時削除。 +- Entryのguest_idが存在しない場合は破損Entryとして削除しログを残す。 + +## 7.7 ServiceInstance + +- service_id +- request_id +- started_at、finish_at +- guest_ids、staff_ids +- quality_snapshot +- price_snapshot +- inventory_reservations +- interruption_policy + +開始時に品質、時間、価格、必要在庫を確定する。通常のStaff交代は進行中サービスを完了してから行う。 + +## 7.8 中断 + +| 原因 | 処理 | +| --- | --- | +| プレイヤー通常閉鎖 | 進行中は完了 | +| Mana停止 | 安全中断し、再開可能 | +| Staff気絶 | 代替Staffを5分待ち、いなければ失敗 | +| 暴動・火災 | 即時避難、失敗 | +| 客の任意離脱 | 利用割合に応じ部分精算 | +| 施設破損 | 即時停止、原則返金 | + +返金と在庫返却はTransactionで行う。消費済み消耗品は返却しない。 + +## 7.9 完了 + +1. guestとstaffの存在を確認。 +2. 予約在庫を消費。 +3. 支払いTransactionをCommit。 +4. 欲求、満足、依存、疲労を変更。 +5. 汚れ、騒音、Heatを発生。 +6. 統計を更新。 +7. GuestをDECIDINGへ。 +8. 次Queueを繰り上げ対象にする。 + +## 7.10 宿泊 + +客室は通常Serviceと異なる長期占有を行う。 + +- チェックイン時にbed_slotを予約。 +- 基本1泊は当日20:00~翌08:00。 +- 遅いチェックインでも08:00までを1泊とする。 +- 延泊判断は翌06:00。 +- 清掃完了まで次客へ割り当てない。 +- 客が外出中でもbed_slotを保持する。 +- 強制退店時は所有物をLostAndFoundへ移す。 + +## 7.11 Policy + +Policy値は型、範囲、解禁Level、変更猶予を定義する。 + +- 価格: 即時。ただし新規Requestのみ。 +- 客層制限: 新規入場のみ。 +- 混浴など運営形式: 利用者0の時だけ変更。 +- 高危険営業: 次の30分境界で適用。 +- 避妊等の成人向けPolicy: Service開始時Snapshotへ保存。 + +Policy変更の予測差分をUIへ返す。 + +--- + +# 8. Inventoryと物流 + +## 8.1 ItemDefinition + +- id、category +- max_stack +- unit_weight +- quality_range +- shelf_life_minutes +- purchase_price、base_sell_price +- legal_status +- storage_tags +- consumption_tags + +## 8.2 InventoryStack + +- stack_id +- item_id +- quality +- quantity +- reserved_quantity +- created_at +- expires_at +- owner_inventory_id + +不変条件: + +- quantityは0以上。 +- reserved_quantityは0以上quantity以下。 +- quantity 0のStackはTick終了時に削除。 +- 期限なしはexpires_atを-1とする。 + +## 8.3 Reservation + +- reservation_id +- stack_id +- quantity +- requester_id +- purpose +- created_at +- expires_at + +予約はInventoryServiceだけが作成・解放する。予約分は他Requestが利用できない。 + +## 8.4 LogisticsRequest + +- request_id +- source_inventory_id +- destination_inventory_id +- item_requirements +- priority 0~100 +- created_at +- deadline +- reserved_stack_ids +- assigned_carrier_id +- state +- retry_count + +State: + +OPEN、RESERVED、ASSIGNED、PICKING_UP、DELIVERING、COMPLETED、BLOCKED、CANCELLED。 + +## 8.5 Dispatcher + +未割当Requestごとに候補運搬員を評価する。 + +DispatchScore = +priority × 10 +- sourceまでの移動分 × 3 +- 現在荷物の迂回分 × 2 ++ Role適性 ++ deadline危険度 + +最高Scoreを割り当てる。同一運搬員へ容量を超えて割り当てない。 + +## 8.6 運搬 + +- 標準運搬容量は重量10。 +- Operation 10ごとに容量+1、最大20。 +- source到着時に予約在庫を荷物Inventoryへ移す。 +- destination到着時に施設Inventoryへ移す。 +- 経路切断時は別経路を試し、3回失敗でBLOCKED。 +- 運搬員気絶時は荷物をその場のCargoObjectとして残す。 +- CargoObjectは別運搬員が回収できる。 + +## 8.7 期限切れ + +- expires_at到達時、未予約分を廃棄物へ変換。 +- 予約中StackはService開始前に期限を再確認。 +- 期限切れ品の提供を許すPolicyは明示的な高危険設定とし、Quality、事故、Heatへ影響。 +- 自動廃棄にはゴミ搬送Requestを発行する。 + +## 8.8 自動発注 + +施設または倉庫ごとに以下を設定する。 + +- item_id +- reorder_point +- target_quantity +- max_unit_price +- preferred_supplier + +available_quantityがreorder_point未満なら、target_quantityとの差を発注する。未到着注文もavailableへ加算し、重複発注を防ぐ。 + +--- + +# 9. Mana + +## 9.1 ManaNetwork + +Mana接続は歩行Graphと別Networkで管理する。 + +- Source: 世界樹、Mana炉、蓄電設備 +- Consumer: 施設、昇降機、照明、ワープ +- Connector: 配線、結晶中継 +- Storage: 現在値と容量 + +## 9.2 供給 + +10ゲーム分ごとに、Network Component単位で計算する。 + +NetMana = +生成量 +- 必須Consumer +- 通常Consumer +- 贅沢Consumer + +不足時はpriorityの低いConsumerから停止する。同priorityではfacility_id昇順で安定化する。 + +## 9.3 Brownout + +- 供給率75%以上: DEGRADED、処理速度低下。 +- 75%未満: NO_POWER。 +- 医務、非常灯、避難用昇降機は必須priority。 +- 停電から復旧後、施設は次の1分Tickで再評価する。 +- 電源のON/OFF振動を防ぐため、停止後10分の最小保持時間を設ける。 + +--- + +# 10. 客AI + +## 10.1 GuestDefinition + +客種別の静的定義は次を持つ。 + +| 項目 | 型 | 内容 | +|---|---|---| +| guest_type_id | StringName | 種別ID | +| tags | Array[StringName] | 一般、富裕、冒険者、行政関係者など | +| wealth_min/max | int | 初期所持金範囲 | +| base_patience | int | 標準待機許容分 | +| risk_tolerance | int | 0〜100 | +| preference_weights | Dictionary | 施設タグ別選好 | +| schedule_profile | StringName | 来訪時間帯 | +| group_size_weights | Dictionary | 1〜4人の重み | +| legal_sensitivity | int | 違法性への忌避 | +| quality_sensitivity | int | 品質への反応 | + +成人向け区画を利用できる客Definitionはadult_verified=trueを必須とする。未設定またはfalseの客は候補施設抽出段階で完全に除外する。 + +## 10.2 GuestRuntime + +客個体は最低限次を保持する。 + +- guest_id +- guest_type_id +- group_id +- wallet_gold +- current_cell +- state +- state_entered_at +- target_facility_id +- current_service_id +- needs: hunger、sleep、hygiene、medical、leisure、intimacy +- satisfaction_total +- satisfaction_samples +- patience_remaining +- debt_gold +- suspicion +- addiction_by_tag +- visited_facility_ids +- path_handle +- planned_exit_at + +Need、満足度、疑念、依存度は0〜100で固定小数点整数とする。 + +## 10.3 入場生成 + +入場候補数は30ゲーム分ごとに算出する。 + +ExpectedArrivals = +基礎流入 +× 時間帯係数 +× 評判係数 +× 収容余力係数 +× イベント係数 +× Heat抑制係数 + +端数は確率抽選ではなく、流入種別ごとのremainderへ繰り越す。これによりセーブ・ロード後も人数が一致する。 + +生成条件: + +1. 正面入口が到達可能。 +2. 建物内の有効客数がpopulation_cap未満。 +3. 客種別に適合する最低1施設が存在する。 +4. 閉館または避難状態でない。 + +生成失敗は消費せず、次の評価周期へ持ち越す。ただし3周期連続失敗したgroupは破棄してログを残す。 + +## 10.4 Need更新 + +各Needは1ゲーム分ごとにDefinitionのrateで増減する。正式IDは次の6種だけとする。 + +- hunger: 移動中+2、待機中+1、食事Serviceで減少。 +- sleep: 移動中+2、作業的活動中+1、宿泊・休憩Serviceで減少。 +- hygiene: 整数Context `dirt` 0〜100を加算し、浴場・洗面Serviceで減少。 +- medical: 整数Context `injury` と `illness` 0〜100を加算し、診療Serviceで減少。 +- leisure: 無刺激時+1、娯楽Serviceで減少。 +- intimacy: `is_night=false`では整数Context `intimacy_day`、`is_night=true`では`intimacy_night`を加算する。Context増加は`adult_verified=true`のDefinitionにだけ適用する。 + +`need_effects`は上記6 IDの部分Mapとし、値は符号付き整数だけを許す。不明ID、旧ID、暗黙の数値変換を拒否し、更新前の状態を維持する。複数効果は全て加算してから各Needを一度だけ0〜100へClampする。 + +`safety`はNeedではない。危険は候補の`risk`/`risk_level`と`risk_tolerance`による除外・Utility罰、重大事故・戦闘・避難命令によるFLEEING等の強制状態遷移で扱う。`social`もNeedではなく、`preference_weights`、施設タグ、`group_fit`、同行者・会話・関係Eventの文脈で扱う。危険や交流の値をMedicalへ加算してはならない。 + +Need更新には上限・下限Clampを必須とし、複数効果は加算してから一度だけClampする。 + +### 10.4.1 GuestService保存互換 + +GuestService Snapshot Version 2はDefinitionの`need_rates`と各Guestの`needs`に、正式6 IDが過不足なく一度ずつ存在することを要求する。未知Key、欠落Key、旧新混在、非整数値はSnapshot全体を原子的に拒否する。 + +Version 1の旧5 IDは読込境界でだけ次へ純粋変換する。 + +| Version 1 | Version 2 | +|---|---| +| hunger | hunger | +| rest | sleep | +| fun | leisure | +| social | intimacy | +| safety | medical | +| なし | hygiene = 0 | + +この`safety → medical`は既存Saveを失わないための保守的な一度限りの移行であり、新規データ上の意味同一性を示さない。Version 1 Mapも旧5 IDの完全一致を要求し、混在Mapを推測で修復しない。移行成功後はVersion 2だけを出力する。 + +## 10.5 状態機械 + +標準状態: + +SPAWNING → ENTERING → DECIDING → MOVING → QUEUING → RECEIVING_SERVICE → DECIDING + +退出系: + +任意状態 → LEAVING → DESPAWNING + +例外系: + +- MOVING → REPLANNING: 経路無効。 +- QUEUING → ABANDONING: 忍耐切れ。 +- RECEIVING_SERVICE → INTERRUPTED: 施設停止。 +- 任意状態 → FLEEING: 重大事故、戦闘、避難命令。 +- 任意状態 → INCAPACITATED: 体力0。 + +状態遷移は同一Tick中に最大4回までとする。超過時はDECIDINGで停止し、AI_LOOP_GUARDを記録する。 + +## 10.6 行動候補抽出 + +DECIDING時は次の順で候補を作る。 + +1. 緊急退出、治療、避難。 +2. 未払い、予約、同行者などの強制行動。 +3. Needを解消する施設。 +4. 任意消費、観光、交流。 +5. 退出。 + +施設候補は次を全て満たす必要がある。 + +- 客の利用権限とContent Filterに適合。 +- 営業中または到着予測時刻に営業予定。 +- 入口まで到達可能。 +- 予想総額を支払えるか、明示的に信用取引を許す。 +- 見積待ち時間がHard Limit未満。 +- 客が拒否する危険度、違法度、品質下限に抵触しない。 + +## 10.7 Utility評価 + +候補aの効用は整数で計算する。 + +Utility(a) = +NeedRelief × 8 ++ Preference × 5 ++ ExpectedQuality × 3 ++ Novelty × 2 ++ GroupFit × 2 +- PriceBurden × 5 +- TravelMinutes × 2 +- WaitMinutes × 3 +- Risk × RiskSensitivity +- RepetitionPenalty + +同点時は次の順で決める。 + +1. 緊急度が高い。 +2. 移動分が短い。 +3. facility_id辞書順。 + +最適候補のUtilityがexit_threshold未満なら退出を選ぶ。ランダムな揺らぎを使う場合もguest_decision Streamから-5〜+5だけ加算し、候補ごとの評価順に依存しないよう候補ID順で乱数を割り当てる。 + +## 10.8 移動と再判断 + +- MOVING開始時にPathRequestを発行する。 +- ETAがplanned_exit_atを越える施設は選択しない。 +- 入口到着時に営業、価格、Queue、権限を再検証する。 +- 再検証失敗時は料金を引かずDECIDINGへ戻す。 +- REPLANNINGが3回失敗した場合、案内地点、入口、緊急退場地点の順で復旧する。 + +## 10.9 Queue忍耐 + +初期忍耐: + +Patience = +base_patience ++ 快適設備補正 ++ 案内品質補正 +- 混雑補正 +- fatigue補正 +- 過去の失敗補正 + +待機1分ごとに1減る。列が進んだ、案内を受けた、無料補償を得た場合は回復できる。0到達時、Queueから原子的に除外しABANDONINGへ遷移する。 + +## 10.10 支払いと満足度 + +Service開始時に事前決済、完了時に後払いを行う。施設Definitionで方式を固定する。 + +SatisfactionSample = +提供品質 ++ 接客品質 ++ 環境快適度 ++ 価格納得度 +- 待ち時間罰 +- 中断罰 +- 危険・目撃罰 + +結果は0〜100にClampし、客の累積満足度と施設の直近評価へ記録する。返金は元取引IDを参照する逆取引とし、履歴を消さない。 + +## 10.11 借金・依存・事故 + +- 信用取引を許す施設だけがwalletを超える決済を行える。 +- debt_goldが客別上限を超える追加提供は禁止。 +- 依存度は対象tagのService完了時だけ増え、時間と治療で減る。 +- 高依存はUtilityと忍耐へ影響するが、必ずContent Filterと権限判定の後に評価する。 +- 意識喪失、暴力、未払い、違法行為はIncidentを生成し、施設内で直接解決しない。 + +## 10.12 退出 + +退出理由: + +- 予定時刻到達 +- 所持金不足 +- Need充足 +- 不満、危険、避難 +- 宿泊終了 +- 行政措置 + +出口到達時に客サマリーを集計し、未解決の借金・事件・予約だけを永続データへ移す。通常の個体RuntimeはDESPAWNING後に解放する。 + +--- + +# 11. 従業員AI + +## 11.1 StaffDefinitionとRuntime + +静的定義: + +- role_id +- permitted_task_tags +- base_wage +- skill_growth_table +- uniform_tags +- legal_permissions +- equipment_requirements + +個体Runtime: + +- staff_id +- role_id +- current_cell +- assigned_facility_id +- shift_id +- state +- current_task_id +- skills +- fatigue +- stress +- morale +- loyalty +- health +- carried_inventory_id +- path_handle +- absence_reason + +各心理値は0〜100、skillは0〜999とする。 + +## 11.2 シフト + +標準シフト: + +- DAY: 06:00〜18:00 +- NIGHT: 18:00〜06:00 +- FLEX: 開始・終了を個別指定 + +日付を跨ぐシフトは開始日のshift_instance_idで管理する。開始30分前から通勤状態に入り、勤務地点へ向かう。 + +一日だけの変更はScheduleOverrideとして保存し、基礎契約を書き換えない。 + +## 11.3 勤務開始判定 + +勤務開始条件: + +1. 生存し、拘束・重傷・休職でない。 +2. シフト時刻内。 +3. 勤務入口または担当施設へ到達可能。 +4. 必須装備を所持またはロッカーから取得可能。 + +遅刻はshift開始から15分で確定し、原因をPATH_BLOCKED、NO_EQUIPMENT、PERSONAL、INCIDENTに分類する。 + +## 11.4 Task + +Taskは次を持つ。 + +- task_id +- task_type +- priority: 0〜100 +- created_at +- deadline_at +- target_entity_id +- target_cell +- required_role_tags +- required_skill_min +- required_items +- estimated_minutes +- interrupt_level +- state +- assigned_staff_id +- retry_count + +Task State: + +OPEN → RESERVED → MOVING → WORKING → COMPLETED + +例外はBLOCKED、CANCELLED、FAILED。 + +## 11.5 Dispatcher + +5ゲーム分ごと、または高優先Task発生時に実行する。 + +StaffTaskScore = +task.priority × 10 ++ skill_fit × 4 ++ role_fit × 100 +- travel_minutes × 3 +- fatigue × 2 +- current_task_interrupt_cost +- overtime_penalty + +割当順はpriority降順、deadline昇順、task_id昇順。Staff側同点はstaff_id昇順とする。 + +予約はTaskとStaffの両方を一つのTransactionで更新する。必要物資のReservationに失敗したTaskはBLOCKEDへ送る。 + +## 11.6 割込み + +割込みLevel: + +- 0: 割込み不可。治療、決闘、危険物処理など。 +- 1: 緊急Taskだけ割込み可。 +- 2: 同等以上のpriorityで割込み可。 +- 3: 常時再配分可。 + +割込み時は作業進捗をTask定義に従い保持または破棄し、使用前物資だけReservationへ戻す。移動済み物資を瞬間的に元在庫へ戻してはならない。 + +## 11.7 状態機械 + +OFF_DUTY → COMMUTING → AVAILABLE → MOVING_TO_TASK → WORKING → AVAILABLE + +補助状態: + +- BREAKING +- EATING +- SLEEPING +- SEEKING_MEDICAL +- FLEEING +- INCAPACITATED +- STRIKE +- DETAINED + +健康・安全状態は業務状態より優先する。シフト終了時も、interrupt_level 0のTaskは安全な中断点まで継続する。 + +## 11.8 疲労・ストレス・士気 + +- fatigue: 勤務、移動、徹夜で増加し、睡眠・休憩で減少。 +- stress: 事故、危険、過密、未払い、関係悪化で増加。 +- morale: 賃金、食事、居住、成功、管理方針で変化。 +- loyalty: 長期的な待遇と関係イベントで変化し、短時間では急変させない。 + +閾値: + +- fatigue 70: 作業速度-20%。 +- fatigue 90: 危険Task禁止、休憩Taskを強制。 +- stress 80: 事故率上昇、退職検討。 +- morale 20未満かつ賃金滞納: STRIKE候補。 + +## 11.9 Skillと品質 + +作業品質: + +WorkQuality = +基礎品質 ++ 対応Skill補正 ++ 設備補正 ++ 士気補正 +- fatigue罰 +- stress罰 +- 難度罰 + +Skill経験はTask完了時にのみ付与する。放置による無限訓練を防ぐため、同一低難度Taskから得られる一日経験に上限を置く。 + +## 11.10 欠勤・退職・救助 + +- health 0はINCAPACITATEDとなり、医療または救助Taskを生成。 +- 入口への経路が断たれた勤務者は位置を保ったまま救助対象とする。 +- 退職判定は日次精算後に行い、勤務中に突然削除しない。 +- 退職者は所持品返却、未払い計上、関係ロック解除を完了してから退場する。 +- 行方不明のStaff参照がTaskに残った場合、Reservationを解除してOPENへ戻す。 + +--- + +# 12. 関係性システム + +## 12.1 RelationshipState + +関係主体A・Bごとに次を保持する。 + +- relationship_id +- actor_a_id +- actor_b_id +- affinity: -100〜100 +- trust: 0〜100 +- fear: 0〜100 +- obligation: -100〜100 +- jealousy: 0〜100 +- shield_current: 0〜shield_max +- shield_max +- route_lock +- last_event_at +- daily_attack_count +- flags + +actor_a_idとactor_b_idは辞書順に正規化し、同じ組み合わせの重複生成を禁止する。方向性が必要な値はa_to_b、b_to_aの子構造へ分ける。 + +## 12.2 Shield + +Shieldは関係イベントへ到達するための保護値で、直接affinityを意味しない。 + +- shield_maxは対象者の地位、警戒、組織保護、進行度から算出。 +- shield_currentは一日の自然回復、警護、イベントで増減。 +- 0未満にはならず、shield_maxを超えない。 +- 0到達はイベント解禁条件の一つであり、自動成功ではない。 + +ShieldAttackはCommandとして実行し、Action Point、費用、対象可視性、Cooldownを検証する。 + +## 12.3 Shield攻撃結果 + +Damage = +action.base_damage ++ owner_stat_bonus ++ intel_bonus ++ event_bonus +- target_resistance +- repeated_action_penalty + +最低0、最大はaction.max_damage。結果はShieldへ適用した後、Heat、関係値、反撃Triggerを同一Transactionで記録する。 + +同じActionの一日使用回数には上限を設ける。反復ペナルティは1回目0%、2回目25%、3回目50%、4回目以降75%。 + +## 12.4 閾値とイベント解禁 + +イベント条件は例として次をAND/ORで組み合わせる。 + +- shield_current <= N +- affinity >= N +- trust >= N +- fear >= N +- 指定flagの有無 +- 時間帯、場所、施設状態 +- 先行イベント完了 +- actorが利用可能 + +閾値を跨いだ瞬間に即時再生せず、EventServiceへ候補を登録する。これにより割込み禁止場面と同時発火を制御する。 + +## 12.5 Route Lock + +route_lock: + +- NONE +- SOFT: 警告を出すが別ルートを進められる。 +- EXCLUSIVE_PENDING: 確定確認待ち。 +- EXCLUSIVE_LOCKED: 排他的ルート確定。 +- CLOSED: 対象ルート終了。 + +排他確定は明示的な確認Commandまたは仕様に明記された不可逆イベントでのみ行う。Lock変更はセーブされ、ロード時に整合性検査する。 + +## 12.6 Event同時実行制御 + +- 同一actorは原則1つのforeground eventだけに参加できる。 +- background eventは状態値だけを変更し、台詞UIを占有しない。 +- Eventがactorを確保する際はactor_id昇順でLockを取得する。 +- Lock取得失敗時は開始せずQueueへ戻す。 +- Event終了、取消、ロード復旧でLockを必ず解放する。 + +## 12.7 所有者Action + +所有者の対人Actionは次の共通形式を持つ。 + +- action_id +- AP cost +- Gold cost +- required_location +- target_filter +- cooldown +- risk +- Shield/affinity/trust/fear効果 +- success formula +- failure consequences + +Action実行時に結果Previewを表示するが、秘密補正や乱数結果そのものは開示しない。 + +## 12.8 対決 + +対決はラウンド制のCommand列として処理する。 + +Initiative = speed + situational_bonus + rng(0..9) + +HitScore = skill + equipment + status - target_defense + +Damage = max(0, base_damage + margin_bonus - armor) + +- 各ラウンド開始時に参加者が有効か確認。 +- 戦闘不能、降伏、逃走、規定ラウンド到達で終了。 +- 結果はhealth、関係、Heat、Incidentへ記録。 +- 演出中にSimulationを停止しても、決定済み結果は二重適用しない。 + +## 12.9 Content Filter + +Content Filterは表示だけでなく候補抽出、イベント選定、施設利用権限へ適用する。 + +- NONE: 制限対象コンテンツを許可。 +- FADE: 行為詳細を省略し、結果だけ処理。 +- REPLACE: 非性的・非残虐な代替イベントへ置換。 +- DISABLE: 候補自体を生成しない。 + +REPLACE対象のEventDefinitionはreplacement_event_idを必須とする。置換先が欠落した場合はDISABLEとして安全側に倒す。 + +すべての登場人物は成人として定義し、年齢不明・未確認の人物を成人向け候補へ含めない。 + +--- + +# 13. 経済・台帳 + +## 13.1 LedgerEntry + +Goldの増減は全てLedgerEntryを経由する。 + +- transaction_id +- absolute_minute +- debit_account +- credit_account +- amount +- category +- source_entity_id +- related_transaction_id +- memo_key + +amountは正整数。取消は削除ではなく、debitとcreditを逆転した新規Entryを作る。 + +## 13.2 Account + +最低限のAccount: + +- PLAYER_CASH +- GUEST_WALLET:{guest_id} +- STAFF_PAYABLE:{staff_id} +- STAFF_WALLET:{staff_id} +- FACILITY_REVENUE:{facility_id} +- SUPPLIER_PAYABLE +- TAX_PAYABLE +- DEBT_RECEIVABLE:{actor_id} +- SYSTEM_SINK +- SYSTEM_SOURCE + +SYSTEM_SOURCE/SINKは初期化、生成、破棄用途に限り、通常運用の収支を隠す目的で使わない。 + +## 13.3 不変条件 + +- 各transactionのDebit合計とCredit合計は一致。 +- Gold残高を直接代入しない。 +- amount 0のEntryを作らない。 +- 同じidempotency_keyのCommandは一度だけ記帳。 +- 負残高を許すAccountはDefinitionで明示。 + +## 13.4 Service決済 + +事前決済: + +1. 料金を再計算。 +2. 支払能力確認。 +3. 在庫Reservation確認。 +4. Ledger記帳。 +5. Service開始。 + +4または5が失敗した場合はTransaction全体をRollbackする。 + +後払いはService完了後に請求する。支払不能時は許可された信用枠へ振替え、不可ならIncidentを作る。完了したService効果を巻き戻してはならない。 + +## 13.5 価格 + +FinalPrice = +base_price +× policy_multiplier +× demand_multiplier +× quality_multiplier +× event_multiplier + +各Multiplierは百分率整数で保持し、計算途中は切り捨てず最後に四捨五入する。最低価格0、上限はDefinitionのmax_price。 + +価格変更は新規QueueEntryにだけ適用し、既に表示・予約された見積にはquote_expires_atまで旧価格を保証する。 + +## 13.6 賃金 + +- 出勤分数、Role単価、残業、危険手当を日次で集計。 +- 06:00の日次締めで、直前の営業日06:00~翌05:59分をSTAFF_PAYABLEへ計上。 +- 支払可能なら自動支給し、不足分はarrearsへ残す。 +- 滞納はmorale、stress、退職、監査へ影響。 +- 退職しても未払い債務は消去しない。 + +## 13.7 日次締め + +営業日は06:00から翌日05:59までとし、日次締めは翌06:00に次の順で行う。 + +1. 前日未確定Serviceの確定。 +2. 売上、返金、仕入、賃金、税、罰金の集計。 +3. Account残高照合。 +4. 施設別損益生成。 +5. 異常Entry検出。 +6. DaySummary保存。 + +照合差額が0でなければ自動補正せず、SimulationをPauseしてLEDGER_IMBALANCEを通知する。 + +MVPの日次分類は`revenue`、`refunds`、`cost_of_goods`、`wages`、`maintenance`、`utilities`、`taxes_and_interest`、`fines`、`construction_cash_flow`に固定する。通常の日給はStaffの当日発生額を`wages`へ一度だけ計上し、過年度・前日からの未払返済を再度費用化しない。雇用契約時の`STAFF_SIGNING_COST`は日給と分離して`cost_of_goods`へ計上する。 + +`OPERATING_REFUND`は客への返金ではなく運営費の戻しであるため、同一期間の運営費を相殺する。相殺額が期間内運営費を超える場合、超過分だけをexpense recoveryとして`revenue`へ入れ、`OPERATING_REFUND_EXCEEDS_PERIOD_COST`を異常欄へ残す。客への`REFUND`は`refunds`へ計上する。分類表にないcategoryは暗黙推測せず締めを失敗させる。 + +## 13.8 需要 + +需要は施設タグ×客種別×時間帯で管理する。 + +DemandIndex = +基礎需要 ++ 評判補正 ++ 未充足Need補正 ++ イベント補正 +- 直近価格上昇罰 +- 待ち時間罰 + +0〜200へClampする。需要は流入人数とUtilityへ影響するが、客の所持金を増やさない。 + +--- + +# 14. 行政・監査・防衛 + +## 14.1 AuthorityState + +- heat: 0〜100 +- suspicion: 0〜100 +- evidence_points: 0以上 +- inspection_level: 0〜5 +- next_inspection_window +- active_case_ids +- fines_due +- known_illegal_facility_ids + +Heatは目立つ行為への短期反応、suspicionは継続的な疑い、evidenceは監査中に確保された証拠とする。 + +## 14.2 Heat更新 + +HeatDeltaはIncident、違法施設稼働、騒音、目撃、賄賂失敗、戦闘から発生する。日次で自然減衰するが、active_caseがある間は最低値を下回らない。 + +同一Incidentを複数の検知器が報告してもincident_idで重複排除する。隠蔽ActionはHeatを直接0にせず、証拠または公開度へ作用する。 + +## 14.3 監査抽選 + +06:00に、監査Window内かつ監査未実行なら評価する。 + +InspectionChance = +base_chance ++ heat × heat_weight ++ suspicion × suspicion_weight ++ overdue_case_bonus +- reputation_protection + +0〜95%へClampする。確率判定にはauthority Streamを使い、失敗しても次回Windowを更新する。 + +## 14.4 Inspection State + +SCHEDULED → ARRIVING → CHECK_IN → PATROLLING → REVIEWING → RESOLVING → DEPARTING → CLOSED + +例外: + +- BLOCKED_ENTRY +- INTERRUPTED +- HOSTILE +- CANCELLED + +監査官は通常客と別Entityだが、同じPathServiceを使う。瞬間的な全マップ走査は禁止する。 + +## 14.5 巡回経路 + +監査官は次を候補地点にする。 + +- 申告対象施設 +- 入口から視認できる異常 +- suspiciousタグ付き施設の推定位置 +- Incident発生地点 +- 帳簿・倉庫・従業員設備 + +PatrolUtility = +suspect_score × 5 ++ evidence_potential × 4 +- travel_minutes × 2 +- time_budget_penalty + +検査時間には上限を置き、全候補を必ず回るとは限らない。 + +## 14.6 視認と証拠 + +発見には次のいずれかが必要。 + +- 対象Cellへ到達し検査する。 +- 公開Layerから視線が通る。 +- 信頼できる証言・帳簿矛盾がある。 +- 事前にknown_illegalとして特定済み。 + +壁越し・階層越しに無条件発見しない。証拠はevidence_id、source、severity、facility_id、expires_atを持つ。 + +## 14.7 解決 + +監査結果は証拠点と違反Definitionから決める。 + +- NO_ACTION +- WARNING +- FINE +- TEMPORARY_CLOSURE +- CONFISCATION +- ARREST_ATTEMPT +- RAID_ESCALATION + +罰金はLedgerへ、営業停止はFacility状態へ、押収はInventory Transactionへ記録する。結果適用後に監査Eventを再実行して二重処罰しないようresolution_idを保存する。 + +## 14.8 プレイヤー対応 + +対応Action: + +- 書類提示 +- 施設停止・是正 +- 交渉 +- 賄賂 +- 隠蔽 +- 退去要求 +- 抵抗 + +各Actionは成功率、必要資源、失敗時Heat、法的結果をDefinitionで持つ。選択肢非表示ではなく、実行不能理由を表示する。 + +## 14.9 防衛 + +Raidまたは襲撃はWave単位で処理する。 + +- threat_strength +- entry_points +- objectives +- retreat_threshold +- max_duration + +DefenseScore = +警備員戦力 ++ 設備防御 ++ 地形補正 ++ 指揮補正 +- 疲労 +- 複数入口罰 + +勝敗だけの一括抽選にせず、参加者、扉、設備、目的物の状態をラウンドごとに更新する。ただし画面外の小規模衝突は同じ式を使った集約解決を許す。 + +--- + +# 15. Event・Notification + +## 15.1 EventDefinition + +- event_id +- category +- priority +- conditions +- participants +- location_rule +- start_window +- expires_after +- foreground +- interrupt_policy +- choices +- automatic_actions +- replacement_event_id +- once_scope + +once_scopeはNONE、PER_DAY、PER_ACTOR_PAIR、PER_SAVEを持つ。 + +## 15.2 Condition + +Conditionはデータで表現し、任意コード実行を許さない。 + +対応Operator: + +- EQ、NE、LT、LTE、GT、GTE +- HAS_FLAG、NOT_HAS_FLAG +- IN_RANGE +- ALL、ANY、NOT +- ENTITY_EXISTS +- PATH_EXISTS +- TIME_IN_WINDOW + +Condition評価は副作用を持たず、同じSnapshotに対して同じ結果を返す。 + +## 15.3 Action + +Event Action: + +- SET_FLAG +- ADD_RELATIONSHIP_VALUE +- DAMAGE_SHIELD +- ADD_LEDGER_ENTRY +- ADD_OR_REMOVE_ITEM +- CREATE_TASK +- CREATE_INCIDENT +- SET_FACILITY_STATE +- TELEPORT_ENTITY_FOR_RECOVERY +- SCHEDULE_EVENT +- SET_ROUTE_LOCK + +複数ActionはEvent Transactionで適用する。任意Action失敗時の方針をROLLBACK_ALL、SKIP_FAILED、BRANCH_FAILUREからDefinitionに指定する。 + +## 15.4 Queue + +Event候補はpriority降順、期限昇順、event_instance_id昇順で並べる。 + +- foregroundは一度に1件。 +- 緊急Eventは通常Eventを安全点で中断できる。 +- 中断不可Event中は期限を最大60分延長できる。 +- 期限切れEventはfallback actionを実行するか、理由を記録して破棄。 +- 同一Eventの候補再登録にはdeduplication_keyを使う。 + +## 15.5 Choice + +Choiceは表示文、条件、Cost、効果Preview、Actionを持つ。 + +選択時に条件とCostを再検証する。UI表示後に資金やactor状態が変化した場合、実行せず最新理由を返す。 + +選択確定にはchoice_instance_idを使い、二重クリック・再送でActionを重複適用しない。 + +## 15.6 Notification + +Notification: + +- notification_id +- severity: INFO、WARNING、CRITICAL +- category +- title_key +- body_key +- entity_id +- world_cell +- created_at +- expires_at +- count +- read +- actions + +同一category、entity_id、10ゲーム分以内の通知はまとめ、countを増やす。CRITICALは自動Pause Policyの対象にできる。 + +通知の位置Actionは対象が消えていても最後の既知位置を表示し、無効参照でUIを閉じない。 + +## 15.7 自動Pause + +設定可能な自動Pause対象: + +- 監査開始 +- Raid開始 +- 従業員死亡・重傷 +- Ledger不整合 +- 主要施設停止 +- 関係Route確定前 +- セーブ失敗 + +Pause中もUI Commandは受け付けるがSimulation Tickは進めない。 + +--- + +# 16. セーブ・ロード・移行 + +## 16.1 保存単位 + +セーブはDirectory単位とし、最低限次を含む。 + +- manifest.json +- world.bin +- thumbnail.png +- journal.log + +manifest: + +- format_version +- game_version +- save_id +- display_name +- created_at_real +- updated_at_real +- absolute_minute +- day +- seed +- checksum_world +- content_pack_ids +- enabled_filter_profile + +world.binは圧縮バイナリで、Runtime State、ID Counter、RNG Stream State、Event Queue、Ledger、未完了Transaction情報を持つ。 + +## 16.2 保存対象 + +必須: + +- Simulation時刻と速度 +- World Cellと構造Graph +- Facility、Inventory、Reservation、物流Request +- 客の永続対象と現在建物内にいる全GuestRuntime +- StaffRuntime、Task、ScheduleOverride +- Relationship、Route Lock +- LedgerとAccount残高Cache +- Authority、Incident、Evidence +- Event QueueとActor Lock +- RNG Streamのstate/counter +- 通知と設定 + +Path Cache、描画Node、UI選択、再計算可能な集計値は保存しない。 + +## 16.3 原子的保存 + +1. SimulationをTick境界でPause。 +2. 不変条件を軽量検査。 +3. SaveSnapshotを作る。 +4. 一時Directoryへ全Fileを書き出す。 +5. world.binのChecksumを計算しmanifestへ記録。 +6. 一時Saveを読み戻してHeader検証。 +7. 既存Saveをbackupへ移動。 +8. 一時Saveを正式名へRename。 +9. 成功後にbackupを世代管理。 + +失敗時は既存Saveを保持し、一時Directoryを診断用に残す。自動で壊れたSaveを上書きしない。 + +## 16.4 Autosave + +- 日次06:00直後。 +- 重要Event開始前。 +- 重要Event解決後。 +- ユーザー設定の実時間間隔。 + +Autosaveは3世代を標準とする。foreground eventのChoice待ちでも保存可能だが、choice_instance_idとActor Lockを含める。 + +## 16.5 Load + +1. manifest構文とformat_version確認。 +2. Checksum確認。 +3. 必要Content Pack確認。 +4. Migration適用。 +5. Runtime ObjectをID順で生成。 +6. 参照を解決。 +7. 不変条件検査。 +8. 派生GraphとCacheを再構築。 +9. 1分進めずSnapshotを公開。 + +Load中にSimulation Tickを実行してはならない。 + +## 16.6 Migration + +Migrationはformat_version NからN+1への純粋変換として実装し、途中Versionを飛ばさない。 + +- 元Saveを変更しない。 +- Migration前Backupを作る。 +- 各Stepに入力・出力Schema Testを持つ。 +- Definition ID変更はalias tableで解決。 +- 削除Contentは安全な代替Definitionへ変換する。 + +Migration失敗時は対象Saveを読取専用として残し、失敗Stepと不足IDを報告する。 + +## 16.7 復旧 + +軽微な不整合だけ自動修復する。 + +自動修復可能: + +- 消滅Entityを参照するUI Selectionの破棄。 +- 存在しないPath Cacheの再生成。 +- orphan TaskのOPEN復帰。 +- 終了Eventの残存Actor Lock解除。 +- 派生Account残高CacheのLedgerからの再構築。 + +自動修復禁止: + +- 不足Goldの生成。 +- 欠落Facilityの無条件削除。 +- Ledger Entryの書換え。 +- Route Lockの推測変更。 +- Content Filterを弱める変更。 + +修復内容はjournal.logへ記録し、ロード後にユーザーへ要約する。 + +--- + +# 17. データ定義・検証 + +## 17.1 データ配置 + +実装時は概ね次の単位でDefinitionを分離する。 + +- data/facilities +- data/items +- data/guests +- data/staff_roles +- data/tasks +- data/actions +- data/events +- data/incidents +- data/authority +- data/balance +- data/content_filters + +ファイル形式はGodot ResourceまたはJSONのどちらかに統一し、同種Definitionで混在させない。MVPでは型検査しやすいResourceを優先する。 + +## 17.2 共通Schema規則 + +- IDは小文字snake_caseのASCII。 +- 表示文は直接埋め込まずlocalization keyを持つ。 +- 数値には単位を名前または説明で明示。 +- 時刻はabsolute_minuteまたはday_minute。実秒と混ぜない。 +- 割合は0〜100の整数、倍率は百分率整数を標準とする。 +- 任意参照は空文字、必須参照は欠落不可。 +- 配列順が結果に影響する場合は仕様に順序を明記する。 +- Dictionary反復順へSimulation結果を依存させない。 + +## 17.3 起動時検証 + +DefinitionRegistry構築時に次を確認する。 + +1. ID重複がない。 +2. 必須項目が存在し型が一致。 +3. 数値が許容範囲内。 +4. 参照先IDが存在。 +5. Event依存Graphに意図しない循環がない。 +6. Recipeの入出力が空でない。 +7. Facilityの入口、Zone、Layer、必要Staffが定義済み。 +8. replacement_event_idがContent Filter条件を満たす。 +9. 成人向け対象はadult_verifiedを持つ。 +10. Balance Tableに必要Keyが全て存在。 + +致命的Errorが1件でもあればNew GameとLoadを無効化し、定義名・項目・原因を一覧表示する。Warningは起動を許すが開発Buildで目立つ表示を行う。 + +## 17.4 Content Pack + +Content Packはpack_id、version、dependency、load_orderを持つ。 + +- 同一IDの上書きは明示override=trueが必要。 +- Core Definitionの安全条件を弱める上書きは禁止。 +- Saveに有効Pack一覧を保存。 +- 必須Pack不足時はLoadしない。 +- 任意装飾Pack不足はplaceholderへ置換できる。 + +## 17.5 Balance Table + +調整値はコードへ散在させず、用途別Tableへ集約する。 + +- time_balance +- economy_balance +- guest_balance +- staff_balance +- authority_balance +- relationship_balance +- construction_balance + +各Tableはbalance_versionを持つ。既存Saveへ新Balanceを適用する際、現在状態を再初期化せず、次回計算から新値を使う。 + +--- + +# 18. 整合性・例外処理 + +## 18.1 共通原則 + +- 失敗を無視して進めない。 +- ユーザー起因の失敗はResult Codeと修正方法を返す。 +- データ不整合は安全側へ停止し、原因IDを残す。 +- 例外復旧でGold、Item、Actorを無から生成しない。 +- 同じ復旧を何度実行しても結果が増殖しないよう冪等にする。 + +## 18.2 施設参照欠落 + +FacilityDefinitionが欠落したInstanceはMISSING_DEFINITIONとする。 + +- 稼働、入場、収入を停止。 +- footprintとInventoryは保持。 +- placeholder表示で位置を特定可能にする。 +- 削除または代替Definition選択をユーザーへ提示。 +- 自動売却しない。 + +## 18.3 在庫不整合 + +検査周期ごとに以下を確認する。 + +sum(reservations) <= stack.quantity + +違反時: + +1. 新規Reservationを停止。 +2. 期限切れ、消滅RequestのReservationを解除。 +3. なお超過なら作成時刻の新しいReservationからBLOCKEDへ戻す。 +4. quantity自体は増やさない。 +5. INVENTORY_RESERVATION_OVERFLOWを通知。 + +## 18.4 負在庫 + +Item減算前にTransactionで数量を再検証する。負数が検出された場合は減算を拒否し、直前のServiceまたは物流完了をFAILEDへする。起動時に負数が存在するSaveは自動で0へ丸めず、復旧画面を出す。 + +## 18.5 経路不能 + +- 客: 再判断後、出口または安全地点へ向かう。 +- 従業員: TaskをBLOCKEDへし、救助または建設通知を出す。 +- 運搬員: 荷物を保持して待機し、期限後に安全地点へ置く。 +- 監査官: 検査不能理由を結果へ記録し、入口へ戻る。 +- 施設: 入口到達不能ならUNREACHABLE。 + +Entityを壁越しに移す復旧は、明示的な緊急Teleportとログがある場合だけ許可する。 + +## 18.6 Stuck検出 + +移動Entityが次の条件を満たすとSTUCK候補。 + +- pathがあるのに10ゲーム分Cellが変化しない。 +- 予約したElevatorへ30分以上乗れない。 +- REPLANNINGを3回繰り返す。 + +復旧順: + +1. 現在Pathを破棄し再探索。 +2. 混雑Costを一時緩和。 +3. 隣接する到達可能Cellへ位置補正。 +4. 安全地点へ緊急Teleport。 + +3以降はWARNING、4はCRITICALログを残す。 + +## 18.7 Actor欠落 + +Event中のActorが死亡、退職、拘束、削除された場合、EventDefinitionのmissing_actor_policyに従う。 + +- CANCEL +- REPLACE_WITH_ROLE +- CONTINUE_WITHOUT +- BRANCH_FAILURE + +未定義時はCANCEL。Actor Lockと予約Costを解放し、既に確定したLedger Entryは逆取引で処理する。 + +## 18.8 Event失敗 + +Action列の途中失敗時はtransaction_policyに従う。Failure Branch自体が失敗した場合は全ActionをRollbackし、EventをERRORへしてSimulationをPauseする。無限再試行を避けるため自動Retryは最大1回。 + +## 18.9 Ledger不整合 + +Debit/Credit不一致、重複transaction_id、未知Account、残高Cache差異を検知する。 + +- 新規経済Commandを停止。 +- SimulationをPause。 +- Ledger原本は変更しない。 +- Cacheだけは再構築可能。 +- 原因範囲をtransaction_idで出力。 + +## 18.10 Clock過負荷 + +描画負荷により1Frameで8 Tickを超える未処理が生じたらbacklogへ積む。backlogが120ゲーム分を超えた時点で自動Pauseし、速度低下を提案する。Tickを捨てたり、複数分を一括して結果だけ適用したりしない。 + +## 18.11 Save失敗 + +- 容量不足: 一時Saveを保持し、必要容量を表示。 +- 権限不足: 保存先と権限を表示。 +- Checksum不一致: 正式化せず既存Saveへ戻す。 +- Serialize不能Entity: entity_idと型を記録し保存失敗。 +- Autosave失敗: 手動Saveを妨げないがCRITICAL通知。 + +## 18.12 無効Command + +Command実行中に対象が変化した場合、古いPreviewに基づき強行しない。CONFLICT_SNAPSHOT_VERSIONを返し、UIへ再取得を要求する。GoldやAPを先に引いていた場合は同Transaction内でRollbackする。 + +--- + +# 19. 公開インターフェース + +## 19.1 Service間規約 + +各Serviceは次の責務を分ける。 + +- query: Snapshotまたは読取専用Viewを返す。 +- validate: 副作用なしのValidationResultを返す。 +- command: CommandQueueへ投入する。 +- apply: SimulationKernelだけがTick中に呼ぶ。 +- event: 適用後の事実をDomainEventとして公開する。 + +UI、演出、音声からServiceの内部状態を直接変更しない。 + +## 19.2 DomainEvent + +共通項目: + +- domain_event_id +- event_type +- absolute_minute +- source_service +- entity_ids +- payload +- causation_command_id + +DomainEventは「起きた事実」であり、拒否可能な要求には使わない。Subscriberの処理順は固定し、同じEventを再配送しても二重適用しない。 + +一つのTransactionが複数DomainEventを生成する場合は、全EventのSchema、ID重複、既発行履歴との衝突を先に検証し、全IDと履歴を一括CommitしてからSubscriberへ配信する。不正な1件が含まれるBatchは通知を1件も配らず、履歴を変更しない。Subscriberが配信中に別Eventを発行した場合、そのEventは現在のBatch全件より後へ並べる。 + +Service固有signalはRuntime内部の投影補助であり、永続的な外部通知の正本にしない。UI、保存、実績、他Systemの不可逆処理はTick完了SnapshotまたはCommit済みDomainEventだけを購読する。これにより外部Domain rollback時に、途中signalを事実として二重処理しない。 + +## 19.3 主要DomainEvent + +- TIME_MINUTE_ADVANCED +- DAY_STARTED +- FACILITY_STATE_CHANGED +- QUEUE_ENTRY_ADDED +- SERVICE_STARTED +- SERVICE_COMPLETED +- INVENTORY_RESERVED +- LOGISTICS_REQUEST_CREATED +- TASK_ASSIGNED +- GUEST_ENTERED +- GUEST_LEFT +- STAFF_SHIFT_STARTED +- INCIDENT_CREATED +- RELATIONSHIP_THRESHOLD_CROSSED +- LEDGER_TRANSACTION_POSTED +- INSPECTION_STARTED +- EVENT_INSTANCE_READY +- SAVE_COMPLETED + +## 19.4 Snapshot Version + +SimulationSnapshotはsnapshot_versionを持つ。Command作成時のexpected_snapshot_versionと現在Versionが異なる場合でも、無関係な変更なら再検証後に実行できる。対象Entityのrevisionが変わっている場合はCONFLICTを返す。 + +各Entityはrevisionを持ち、状態変更Transactionごとに1増やす。 + +## 19.5 UI Projection + +UI向けProjection例: + +- TopBarProjection +- BuildPaletteProjection +- FacilityPanelProjection +- GuestDetailProjection +- StaffRosterProjection +- LogisticsProjection +- LedgerProjection +- AuthorityProjection +- NotificationProjection + +Projectionは表示用に整形済みの値と理由文Keyを返す。UI側で価格、稼働可否、成功率を再計算しない。 + +--- + +# 20. 観測・デバッグ・性能 + +## 20.1 構造化ログ + +Log項目: + +- real_timestamp +- absolute_minute +- severity +- subsystem +- code +- entity_ids +- command_id +- message +- context + +通常PlayではINFOを間引き、WARNING以上を保持する。個人名や台詞全文を診断Logへ不要に含めない。 + +## 20.2 Simulation Digest + +各ゲーム時間の終端に、次の正規化値からHashを生成する。 + +- absolute_minute +- Entity数と主要State +- Account残高 +- Inventory数量 +- RNG Stream Counter +- Event Queue ID +- ID Generator Counter + +同じ初期SaveとCommand列でDigest列が一致することを決定性Testに使う。 + +## 20.3 Debug Overlay + +開発Buildで切替可能にする。 + +- FRONT/BACK Path Graph +- 到達不能Cell +- Zone、Layer、footprint +- 構造支持Graphと危険箇所 +- Mana Networkと供給率 +- QueueとService Slot +- Inventory Reservation +- Guest Utility上位候補 +- Staff Task割当 +- Heat、視認、監査候補 +- Actor/Event Lock + +Overlay表示はSimulation状態を変更しない。 + +## 20.4 診断Command + +開発専用: + +- dump_entity entity_id +- trace_command command_id +- trace_guest_decision guest_id +- trace_task task_id +- validate_world +- validate_ledger +- rebuild_derived_graphs +- compute_digest +- export_balance_snapshot + +Release Buildでは任意状態変更Commandを無効化する。 + +## 20.5 性能目標 + +基準環境のMVP目標: + +- 1920×1080、60fpsを目標。 +- x1でSimulation CPU平均4ms/frame以下。 +- x4でSimulation CPUの95 percentile 12ms/frame以下。 +- 有効客200、従業員50、施設150、物流Request200を維持。 +- Path探索1件の95 percentile 2ms以下。 +- 30秒ごとのAutosaveを除き、Main Thread停止50ms未満。 +- 通常Save 5秒以内、Load 8秒以内。 + +性能不足時もSimulation結果を間引かず、表示更新頻度、Overlay、遠距離Animationを先に削減する。 + +## 20.6 更新頻度 + +| 処理 | 頻度 | +|---|---| +| Clock、Need、Service進行 | 1ゲーム分 | +| Staff Dispatcher | 5ゲーム分または緊急時 | +| Mana配分 | 10ゲーム分またはNetwork変更時 | +| Guest流入 | 30ゲーム分 | +| 構造安全再計算 | Dirty時 | +| 需要集計 | 60ゲーム分 | +| 日次金融締め、シフト・AP・監査・朝Event・Autosave | 06:00 | +| UI Projection | 状態変更時、最大実時間10回/秒 | + +全Entityを毎Frame走査しない。ServiceごとのActive Set、Dirty Set、期限Priority Queueを使う。 + +--- + +# 21. テスト仕様 + +## 21.1 Test階層 + +- Unit: 式、状態遷移、Validation、Migration。 +- Contract: Service公開InterfaceとResult Code。 +- Integration: 複数Serviceを跨ぐTransaction。 +- Scenario: ゲーム時間を進める一連のPlay。 +- Determinism: 同一入力のDigest一致。 +- Save/Load: Round Tripと破損復旧。 +- Load/Performance: 人数・施設上限付近。 + +## 21.2 Simulation Kernel + +- SYS-TIME-001: x1で実時間30秒後にゲーム内60分進む。 +- SYS-TIME-002: x2、x4でもTick欠落がない。 +- SYS-TIME-003: Pause中にabsolute_minuteが変わらない。 +- SYS-TIME-004: 23:59→00:00では暦日だけが変わり、05:59→06:00で前営業日の日次金融締め、シフト、AP、監査、朝Event、Autosaveが各一度だけ順序通りに行われる。 +- SYS-TIME-005: backlog 120分超で自動Pause。 +- SYS-TIME-006: catch-up中の最初のTickがPauseを要求すると、同Frameの残りTickを消費しない。 +- SYS-TIME-007: 未解決境界またはBANKRUPT中に速度変更・手動stepを行ってもClockと保留分が変わらない。 +- SYS-TIME-008: running中の予定Commandとpaused中の即時Commandが同じeffective minute契約を使い、06:00取引を終了済みの前営業日Ledger期間へ入れない。 +- SYS-TIME-009: 境界失敗後の再試行で、保留したMana~Snapshot段階だけを同一分に1回実行し、Commandと通知を二重適用しない。 +- SYS-RNG-001: 同Seed・同Command列のDigestが一致。 +- SYS-RNG-002: Guest乱数消費がAuthority結果へ影響しない。 + +## 21.3 建設・世界 + +- SYS-BLD-001: 重複footprintを拒否。 +- SYS-BLD-002: FRONT専用施設をBACKへ置けない。 +- SYS-BLD-003: 支持不足Placementに理由Cellを返す。 +- SYS-BLD-004: Build開始後に資材消費を二重実行しない。 +- SYS-BLD-005: 進捗0%取消は全額、50%超は定義率で返却。 +- SYS-BLD-006: 稼働中施設の即時移動を禁止。 +- SYS-WLD-001: Rank上昇時だけMap Boundsが広がる。 +- SYS-WLD-002: Dirty Region外のEnvironment結果が変化しない。 +- SYS-PRG-001: Rankを飛び級・低下できない。 +- SYS-PRG-002: 現在Faith消費後も`faith_lifetime`が減らず、達成済みRank条件が巻き戻らない。 +- SYS-PRG-003: Rank 4以降は地上・地下の稼働施設を各1以上要求する。 +- SYS-PRG-004: World拡張またはPath再構築失敗時にWorld、GameState、Progressionを全て戻す。 +- SYS-PRG-005: 同一昇格Requestの再送でWorldを二重拡張しない。 +- SYS-PRG-006: 保存された条件値、昇格履歴、派生MapBoundsの改ざんを原子的に拒否する。 + +## 21.4 経路・昇降機 + +- SYS-PATH-001: FRONT客がBACK専用Edgeを使わない。 +- SYS-PATH-002: 施設撤去後に該当Cacheだけ失効。 +- SYS-PATH-003: Elevator停止中に代替経路へ再探索。 +- SYS-PATH-004: 同時RequestのTie Breakが決定的。 +- SYS-PATH-005: 30分待ちでStuck復旧が発動。 +- SYS-PATH-006: 200客が同一Cellを通過しても永久Deadlockしない。 +- SYS-PATH-007: 客用/業務用通路の完成・撤去が対応LayerのROUTEとPathを同時に更新し、失敗時は双方を完全に戻す。 +- SYS-PATH-008: 従業員ドアが`staff`権限だけにFRONT↔BACK Cost 120を提供し、客には経路を開かない。 +- SYS-PATH-009: 垂直Topologyが未構成の昇降機投影は、ROUTE、Path、Connector、履歴を一切変更せず拒否する。 + +## 21.5 施設・在庫・物流 + +- SYS-FAC-001: NO_STAFFがOPENより優先される。 +- SYS-FAC-002: Queue離脱時にService Slotを消費しない。 +- SYS-FAC-003: 中断Serviceの返金が1回だけ記帳される。 +- SYS-INV-001: 予約合計が数量を超えない。 +- SYS-INV-002: 期限切れ予約品をServiceに使わない。 +- SYS-INV-003: source到着時の引取で所有Inventoryが切り替わる。 +- SYS-INV-004: 運搬員気絶時にCargoが複製されない。 +- SYS-LOG-001: 重複自動発注をしない。 +- SYS-LOG-002: BLOCKED Requestを解除後に再配分できる。 + +## 21.6 Guest AI + +- SYS-GST-001: 支払不能施設を候補から除外。 +- SYS-GST-002: adult_verifiedでない客を制限施設へ入れない。 +- SYS-GST-003: Utility同点をfacility_id順で解決。 +- SYS-GST-004: Queue忍耐0で原子的に離脱。 +- SYS-GST-005: 施設停止時にService中断処理を1回だけ実行。 +- SYS-GST-006: 退出後も借金・事件だけが永続化。 +- SYS-GST-007: 3回の経路失敗後に安全な退出復旧へ移る。 +- SYS-GST-008: 現行Snapshotのneeds/need_ratesが正式6 IDの完全一致でない場合、Live状態を変えず拒否する。 +- SYS-GST-009: Version 1の旧5 Needを決定的に移行し、Hygieneを0で補い、以後Version 2だけを出力する。 +- SYS-GST-010: DirtだけがHygiene、負傷・病気だけがMedicalへ入り、危険・交流ContextをMedicalへ混ぜない。 +- SYS-GST-011: Intimacyの昼夜Context増加は成人確認済みDefinitionにだけ適用する。 + +## 21.7 Staff AI + +- SYS-STF-001: 夜勤が日付を跨いでも同一Shiftとして集計。 +- SYS-STF-002: TaskとStaffの予約が片方だけ残らない。 +- SYS-STF-003: 割込み不可Taskを通常Taskが中断しない。 +- SYS-STF-004: fatigue 90以上で危険Taskを割り当てない。 +- SYS-STF-005: 消滅StaffのTaskがOPENへ戻る。 +- SYS-STF-006: 退職後も未払い賃金が残る。 + +## 21.8 関係性・Event + +- SYS-REL-001: Shieldが0未満にならない。 +- SYS-REL-002: 反復Actionへ段階Penaltyを適用。 +- SYS-REL-003: 排他Routeは確認前にLOCKEDにならない。 +- SYS-REL-004: 同一actorのforeground Eventが重ならない。 +- SYS-EVT-001: choice二重送信でActionを二重適用しない。 +- SYS-EVT-002: Actor欠落時にLockとCost予約を解放。 +- SYS-EVT-003: REPLACE先欠落時に安全にDISABLE。 +- SYS-EVT-004: Event Transaction失敗時に指定Policyで復旧。 + +## 21.9 経済・監査 + +- SYS-ECO-001: 全TransactionのDebitとCreditが一致。 +- SYS-ECO-002: 返金が元取引参照の逆取引になる。 +- SYS-ECO-003: 見積期限内は価格変更の影響を受けない。 +- SYS-ECO-004: 残高不足でも事前決済Serviceが開始しない。 +- SYS-AUT-001: 壁越しの違法施設を無条件発見しない。 +- SYS-AUT-002: 同一Incidentを証拠へ重複加算しない。 +- SYS-AUT-003: resolution_idにより罰金を二重適用しない。 +- SYS-AUT-004: 監査官が経路不能時に結果理由を残して退出。 + +## 21.10 Save・復旧 + +- SYS-SAV-001: Save→LoadでSimulation Digestが一致。 +- SYS-SAV-002: RNG Stream Counterが復元される。 +- SYS-SAV-003: 保存失敗時に以前のSaveが残る。 +- SYS-SAV-004: Checksum不一致Saveを実行しない。 +- SYS-SAV-005: N→N+2を順次Migrationする。 +- SYS-SAV-006: Load直後にゲーム時間が1分進まない。 +- SYS-SAV-007: orphan TaskとEvent Lockだけを安全に修復。 +- SYS-SAV-008: Content FilterがLoadで弱まらない。 + +## 21.11 負荷Scenario + +PERF-MVP-001: + +- 客200 +- 従業員50 +- 施設150 +- 開放Cell 20,000 +- 同時Queue 500 +- 未完了物流Request 200 +- Active Event 50 + +ゲーム内24時間をx4で進め、Crash、永続Deadlock、Ledger不整合、負在庫、120分超backlogがないこと。 + +## 21.12 Canonical MVP受入との対応 + +| GAME_SPECの受入 | 主なSystem Test | +|---|---| +| MVP-AC-01 14日完走 | SYS-TIME、SYS-GST、SYS-STFと14日Scenario | +| MVP-AC-02 混雑改善 | SYS-FAC、SYS-PATH、SYS-GSTと夕方混雑比較Scenario | +| MVP-AC-03 BACK動線の効率差 | SYS-PATH、SYS-LOG、SYS-STFと二層Layout比較Scenario | +| MVP-AC-04 地下営業の収益とHeat | SYS-ECO、SYS-FAC、SYS-AUTと14日収支比較Scenario | +| MVP-AC-05 配置リスクの事前予測 | SYS-REL、SYS-EVTと配置Preview UI Test | +| MVP-AC-06 Route確定前の警告 | SYS-REL-003、SYS-EVT-001〜004 | +| MVP-AC-07 100客+30従業員の描画性能 | PERF-MVP-001と実描画Frame Test | +| MVP-AC-08 Save/Load一致 | SYS-SAV-001〜008 | +| MVP-AC-09 二軸構造の理解 | チュートリアル観察Testと初見Playtest | + +--- + +# 22. 実装順序 + +## S0: 基盤 + +- DefinitionRegistryとValidation +- ID Generator +- SimulationKernel +- CommandQueueとTransaction +- Snapshot、DomainEvent、構造化Log +- 決定性Test Harness + +完了条件: 同じCommand列を24時間再生しDigestが一致する。 + +## S1: 世界・建設 + +- Cell、Zone、Layer +- Occupancy、支持Graph +- ConstructionJob +- FRONT/BACK Path Graph +- Elevator基礎 + +完了条件: 建設・取消・撤去後もPathと支持状態が整合する。 + +## S2: 施設・在庫・物流 + +- Facility State +- Queue、Service Slot +- Inventory、Reservation +- Logistics RequestとDispatcher +- Mana Network +- Ledger基礎 + +完了条件: 原料搬入からService完了・売上記帳まで一周する。 + +## S3: 客・従業員 + +- Guest流入、Need、Utility、退出 +- Staff Shift、Task Dispatcher +- 疲労、事故、救助 +- Notification Projection + +完了条件: 24時間無操作Simulationで客と勤務者がDeadlockしない。 + +## S4: 関係・Event + +- Relationship、Shield +- Event Condition、Action、Choice +- Actor Lock、Route Lock +- Content Filter + +完了条件: 分岐、保存、再開、置換が決定的に動く。 + +## S5: 行政・防衛 + +- Heat、Incident、Evidence +- 監査官AIと巡回 +- 結果適用 +- Raid、防衛 + +完了条件: 発見根拠を地図上で追跡でき、二重処罰がない。 + +## S6: 保存・運用 + +- Atomic Save +- Autosave世代管理 +- Migration +- Recovery +- Performance BudgetとProfiler + +完了条件: 全SYS-SAVとPERF-MVP-001を通過する。 + +## 22.1 依存関係 + +- S1はS0完了後。 +- S2はWorld、Transaction、Pathへ依存。 +- S3はS2のFacility、Inventory、Ledgerへ依存。 +- S4はS0のEvent基盤とS3のActorへ依存。 +- S5はS1のPath、S3のAI、S4のEventへ依存。 +- S6は各段階からSave対象登録を進め、最後に統合する。 + +機能を先に作ってSave対応を後回しにせず、各System追加時にSerialize、Digest、Round Trip Testを同時追加する。 + +--- + +# 23. MVP後へ延期する項目 + +次はInterfaceだけ予約し、MVP実装には含めない。 + +- 複数拠点の同時運営 +- Network Multiplayer +- User Modの任意Script実行 +- 全自動の施設レイアウト最適化 +- 物理演算による連続的な建物崩壊 +- 数千人規模の群衆Simulation +- 経済市場の他都市Simulation +- 完全なEvent Sourcingによる任意時点巻戻し +- Cloud Save競合Merge + +延期項目を理由にMVPのSave SchemaやService境界を過剰に一般化しない。 + +--- + +# 24. 実装開始時チェックリスト + +- [ ] 対象機能のDefinitionとRuntime Stateを分離した。 +- [ ] Command、Validation、Result Codeを定義した。 +- [ ] 同一Tick内の処理順を決めた。 +- [ ] Gold、Item、APをTransaction化した。 +- [ ] Path切断、Actor消滅、施設停止時の挙動を決めた。 +- [ ] Save対象と派生Cacheを区別した。 +- [ ] RNG StreamとTie Breakを固定した。 +- [ ] Content Filterと成人確認を候補抽出へ適用した。 +- [ ] DomainEventとNotificationを定義した。 +- [ ] Unit、Integration、Determinism、Round Trip Testを追加した。 +- [ ] Debug Overlayまたは構造化Logで原因を追跡できる。 +- [ ] GAME_SPECIFICATION.mdの受入条件へ紐付けた。 + +このチェックリストを満たさない機能は、画面上で動いていてもSystemとして完了扱いにしない。 diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md new file mode 100644 index 0000000..8c77750 --- /dev/null +++ b/docs/TEST_MATRIX.md @@ -0,0 +1,106 @@ +# 自動テスト対応表 + +この表は、仕様上の主要な不変条件をどのHeadless Testで検証しているかを示す。ゲームルールの正本ではなく、実装の回帰防止範囲を確認するための索引である。 + +## 実行 + +全件: + + make test + +段階別: + + make test-s0 + make test-s1 + make test-s2 + make test-s3 + make test-s4 + make test-s5 + make test-s6 + make test-mvp + make test-acceptance + make test-ui + make test-perf + make test-perf-full + +構文、Autoload、Main Sceneの読み込み確認: + + make check + +## 対応表 + +| 段階 | Test | 主な検証対象 | +|---|---|---| +| S0 | `tests/run_tests.gd` | Clock、GameState、基本Snapshot、Atomic Save | +| S0 | `tests/s0/test_definition_registry.gd` | Definition Schema、重複ID、参照検証 | +| S0 | `tests/s0/test_command_transaction.gd` | Command順序、処理後も残る受理ID/冪等Key履歴、旧Snapshot移行、厳密復元、Transaction rollback | +| S0 | `tests/s0/test_determinism.gd` | 24時間再生、RNG Stream、Digest一致 | +| S0 | `tests/s0/test_stable_id_generator.gd` | 安定ID、Save/Load後の連番 | +| S0 | `tests/s0/test_domain_events.gd` | Domain Event順序、再配送境界、Batch全件先行Commit、無変更拒否、再入配信順、配信中Restore拒否 | +| S0 | `tests/s0/test_runtime_integration.gd` | Runtime Command、Snapshot、復元原子性 | +| S1 | `tests/s1/test_world_service.gd` | Zone/Layer、Occupancy、支持、Rank拡張 | +| S1 | `tests/s1/test_path_service.gd` | 決定的A*、権限Edge、Cache失効 | +| S1 | `tests/s1/test_path_congestion.gd` | 密度Cost、Soft Collision、Cache低下側失効、Connector権限別Cost、Elevator Request一意性、停止時迂回、退場Cleanup、30分停滞 | +| S1 | `tests/s1/test_construction_service.gd` | Placement、費用、取消、完成、撤去、完成通知の保留/確定/破棄、通知中の再入拒否 | +| S1 | `tests/s1/test_excavation_service.gd` | 硬度別費用・時間、入口接続、支保、中断再開、二層解放、厳密Snapshot | +| S1 | `tests/s1/test_spatial_subslot_service.gd` | ROUTE/DECORATION/UTILITY独立共存、複数Cell、crosslink、revision、Path query、外部検証境界、厳密Snapshot | +| S1 | `tests/s1/test_route_subslot_path_adapter.gd` | base+ROUTEのPath投影、追加・置換・撤去の原子更新、到達性、revision、隔離、厳密Snapshot | +| S1 | `tests/s1/test_facility_infrastructure_projector.gd` | 完成した客用/業務用通路と職員扉の実Path投影、利用権限、安定ID、撤去、replay、crosslink、exact rollback、隔離、厳密Snapshot、未対応昇降機の無変更拒否 | +| S1 | `tests/s1/test_runtime_infrastructure_integration.gd` | Runtime v6、掘削29/30分境界、建設通知順、通路・職員扉完成/撤去、失敗時signalゼロ、Rank拡張、v5代理施設移行、破損Saveの原子的拒否 | +| S1 | `tests/s1/test_runtime_world_construction.gd` | World/Path/Construction統合とReplay | +| S2 | `tests/s2/test_facility_service.gd` | 状態優先、Queue、Slot、中断、原子的完了 | +| S2 | `tests/s2/test_inventory_logistics.gd` | FEFO、Reservation、Cargo所有権、入力fingerprint冪等性、ID Tombstone、Carrier予約負荷、原子Batch | +| S2 | `tests/s2/test_mana_network_service.gd` | 優先供給、Brownout、蓄電、復旧保持 | +| S2 | `tests/s2/test_economy_service.gd` | 複式Ledger、冪等取引、逆仕訳、残高監査 | +| S2 | `tests/s2/test_ledger_backed_resources.gd` | GameState資源とLedgerの原子的Adapter | +| S2 | `tests/s2/test_runtime_operations_cycle.gd` | 入荷からService売上までの完全Cycle | +| S3 | `tests/s3/test_guest_service.gd` | SYS-GST-001〜007、6欲求、旧5欲求移行、成人境界、Utility、退出復旧 | +| S3 | `tests/s3/test_staff_service.gd` | SYS-STF-001〜006、複数Shift、Task原子性、雇用区分、日給、退職者未払台帳 | +| S3 | `tests/s3/test_staff_payroll_operations.gd` | 当日日給と繰越債務、部分清算、退職後債務、日次冪等性、厳密履歴Snapshot | +| S3 | `tests/s3/test_runtime_actor_cycle.gd` | Guest/Staff/Facility/Inventory/Ledgerの24時間統合 | +| S3 | `tests/s3/test_runtime_actor_hardening.gd` | 実移動、残高予約、勤務終了、破損Snapshot拒否 | +| S3 | `tests/s3/test_runtime_path_traffic.gd` | Guest/StaffのPath密度投影、固定小数点混雑速度、30分停滞復旧、旧Runtime移行、Dispatcher相互照合 | +| S4 | `tests/s4/test_relationship_event_service.gd` | SYS-REL-001〜004、SYS-EVT-001〜004、限定Lock修復、Content Filter enum厳密復元 | +| S4 | `tests/s4/test_placement_risk_preview_service.gd` | 同一施設・Shift接触時間、Shield/Event候補、Route Lock警告、Content Filter不変、決定的回避案、fail-closed入力 | +| S4 | `tests/s4/test_runtime_relationship_events.gd` | Choice pause、Cost escrow、Actor欠落、exact Replay | +| S5 | `tests/s5/test_authority_service.gd` | SYS-AUT-001〜004、Heat、Evidence、Inspection | +| S5 | `tests/s5/test_defense_service.gd` | Wave、参加者、扉、設備、目的物、Skill、撤退、Snapshot | +| S5 | `tests/s5/test_runtime_authority_cycle.gd` | Path/LOS/Ledger/Authority統合とexact Replay | +| S5 | `tests/s5/test_runtime_defense_cycle.gd` | 全10 Command、専用RNG、Skill費用、Staff/Facility損害、Raid排他Lock、途中Save、原子rollback | +| S6 | `tests/s6/test_save_operations.gd` | Autosave世代・縮小、Migration、Checksum Recovery、package内symlink拒否、rename中断復旧 | +| S6 | `tests/s6/test_performance_budget.gd` | x1/x4、Path、Save/Loadの性能予算判定 | +| S6 | `tests/s6/test_runtime_save_lifecycle.gd` | 06:00日次保存、3世代、Recovery、読込原子性、限定修復、Content Filter統合 | +| MVP | `tests/mvp/test_operations_coordinator.gd` | 06:00営業日締め・朝境界、需要・自動来客、7日平均、未払・破綻、14日exact replay | +| MVP | `tests/mvp/test_mvp_content_catalog.gd` | 客8クラス×種族4種、雇用候補20人、ユニーク2人、成人境界、厳密Schema、全体fail-closed | +| MVP | `tests/mvp/test_progression_service.gd` | Rank 1~7条件、累計値、逐次昇格、原子的World境界、冪等性、rollback隔離、Snapshot | +| MVP | `tests/mvp/test_runtime_content_progression.gd` | 32 Guest定義、候補雇用・契約費・日給・Shift・Path、未払時の無変更拒否、累計値、Rank拡張、旧Runtime移行、Runtime v6、24時間再生 | +| MVP | `tests/mvp/test_runtime_operations_integration.gd` | 本番運営の明示有効化、06:00営業日境界、実来客、給与・未払・破綻、Runtime v6、14日運営境界再生 | +| MVP | `tests/mvp/test_runtime_operations_acceptance.gd` | 実Tick 24時間、選択待ち停止再開、Save・Command再送、部分給与、破綻、破綻後no-op、14日運営境界の連続/Day 7復元一致 | +| UI | `tests/ui/test_main_dashboard.gd` | 2解像度Layout、本番運営表示、雇用候補20人の表示と即時/予約雇用・二重送信防止・保存済み操作連番復元、座標変換、建設、掘削モード/Preview/Command、施設選択、前景Event Choice、手動Save/Load | +| PERF | `tests/performance/test_perf_mvp_001.gd` | 独立Service合成の24時間負荷Smoke。`PERF_MVP_FULL=1`で仕様上限の完全Fixture。Runtime全Domain・UI・実描画Frameは別受入 | + +## 横断的な合格条件 + +各System追加時は、少なくとも次を同じ段階のTestへ含める。 + +- 不正入力を副作用なしで拒否する。 +- 外部境界の途中失敗を原子的に巻き戻す。 +- 同じCommandまたは解決IDを再送しても二重適用しない。 +- Snapshotを完全往復でき、破損Snapshotの拒否が既存状態を変えない。 +- 同一Seed、同一Command列、同一開始Snapshotから同一Digestへ到達する。 +- Actor、Task、Reservation、Queue、Ledger、Event Lockに孤児参照を残さない。 + +性能Scenarioは機能回帰とは分離して測定する。時間超過時にもTickやEntityを間引いて合格扱いにはしない。 + +## MVP受入として未完了のScenario + +System単体の合格と、ゲームとしてのMVP合格を混同しないため、次は別の受入Scenarioとして追加する。 +数値境界、共通Fixture、A/B分岐条件は `MVP_ACCEPTANCE_SCENARIOS.md` を正本とする。 + +- 全Actor AI、Event、監査、Path移動を含む20,160回の実Tickで14ゲーム日を完走する。現在の14日自動試験は運営境界を加速しており、実Tick受入の代替にはしていない。 +- 夕方混雑を、施設追加前後と動線改善前後で同一Seed比較する。 +- FRONTだけの動線と適切なBACK動線を、物流時間・従業員移動・客待ち時間で比較する。 +- 健全営業と地下営業を、Heat対策費込みの14日利益で比較する。 +- ヒロイン/ライバルの配置変更前に、接触・Shield・EventリスクPreviewが変化することを確認する。 +- 100客、30従業員を表示した状態で基準PCの実Frame rateを測定する。 +- 初見Playtestで地上/地下とFRONT/BACKの違いを30分以内に説明できるか観察する。 diff --git a/docs/UI_OPERATION_SPECIFICATION.md b/docs/UI_OPERATION_SPECIFICATION.md new file mode 100644 index 0000000..781ee3c --- /dev/null +++ b/docs/UI_OPERATION_SPECIFICATION.md @@ -0,0 +1,96 @@ +# UI・操作仕様書 + +## 1. 目的 + +メイン画面を離れずに、宿の観察、問題発見、建築、改善までを完結できる経営シミュレーションUIとする。 + +操作感は、施設配置が人流へ影響し、その結果を画面上の混雑、待機列、稼働率として観察できることを中心に置く。外観は、暖色の木、革、真鍮、羊皮紙を使った日本製ファンタジーSLG調とし、写実的な西洋ストラテジー表現は避ける。 + +## 2. 画面の固定領域 + +| 領域 | 常時表示する情報 | 主な操作 | +|---|---|---| +| 上部 | 営業日、時刻、時間帯、来客、宿泊、資金、主要資源、評判、警戒 | 停止、通常、2倍、4倍 | +| 左側 | 従業員、施設、商品、探索、投資、帳簿 | 管理モード切替 | +| 中央 | 世界樹の断面、施設、客、従業員、経路、待機列 | 選択、建築、追跡、移動、拡大縮小 | +| 右側 | 選択対象の状態、容量、待機、担当者、清潔、当日売上 | 詳細表示、営業切替、担当変更 | +| 下部 | 建築カテゴリ、施設カード、費用、占有サイズ | 建築、回転、解体、取消 | +| 右下 | 現在の目標、重要通知 | 目標詳細、通知位置へ移動 | + +中央マップの可視面積を優先し、通常の管理パネルは画面幅の35%を超えて中央を覆わない。 + +## 3. 基本操作ループ + +1. 中央マップで宿の営業を観察する。 +2. 待機人数、赤い経路、警告通知から問題を発見する。 +3. 施設または人物を選び、右側で原因を確認する。 +4. 左側の施設ボタンまたは下部の建築カードから対策を選ぶ。 +5. 半透明の建築予告をマップ上へ直接配置する。 +6. 営業を再開し、待機時間と経路色の変化を確認する。 + +施設の建築、選択、情報確認のために別画面へ移動させない。人物編成、商品詳細、帳簿など一覧性が必要な機能だけを専用画面または大型パネルにする。 + +## 4. 建築操作 + +- 施設カード選択後、カーソル位置に半透明の完成予想を表示する。 +- 建築可能は緑、条件不足は黄、建築不可は赤で示す。 +- 建築不可の場合は、資金不足、接続不足、未掘削、占有衝突などの理由をカーソル付近へ一行表示する。 +- 同種の床、通路、壁、装飾はドラッグによる連続配置を許可する。 +- 右クリックまたはEscで建築を取り消す。 +- 回転後も建築カードの選択状態を維持する。 +- 解体前に対象、返金額、停止する経路を予告する。 +- 人物が利用中の施設は即時消去せず、退避完了後に解体する。 + +## 5. 観察表示 + +通常表示では装飾を優先し、問題が起きた場所だけ小さな待機表示を出す。管理表示では次のオーバーレイを切り替えられる。 + +| 表示 | 色と内容 | +|---|---| +| 混雑 | 緑=快適、黄=やや混雑、赤=混雑または通行不能 | +| 客経路 | 客用通路と主要目的地 | +| 従業員経路 | 裏通路、搬送経路、勤務先までの移動 | +| 清潔 | 施設と通路の清潔度ヒートマップ | +| 騒音 | 発生源と距離減衰 | +| 治安・警戒 | 犯罪、目撃、監査リスク | +| 物流 | 倉庫、予約在庫、搬送中の物資 | + +待機列は人数だけでなく推定待ち時間を保持する。通常は人数を表示し、選択または警告状態で推定時間を展開する。 + +## 6. 人物と施設の選択 + +- 客を選ぶと、現在の目的、行き先、経路、欲求、残り忍耐を表示する。 +- 従業員を選ぶと、勤務先、移動中か勤務中か、疲労、担当、次の休憩を表示する。 +- 施設を選ぶと、営業状態、容量、利用中、待機、担当者、清潔、在庫、当日売上を表示する。 +- 選択対象をもう一度押すと追跡を開始し、中央マップが対象へ緩やかに追従する。 +- 警告通知を押すと原因地点へ移動し、関係する施設または人物を選択する。 + +## 7. 時間進行と06:00決算 + +- 時間操作は時計の近くへ置き、停止、通常、2倍、4倍を一操作で切り替える。 +- 重要な選択肢、経営破綻、監査結果など即時判断が必要な場合だけ自動停止する。 +- 06:00に前営業日を締め、夜営業の結果まで含めて決算する。 +- 決算は画面全体を長時間塞がず、まず収入、支出、利益、前日差、警告だけを短く表示する。 +- 詳細帳簿は任意で開く。閉じると朝営業が動いている中央マップへ戻る。 + +## 8. 視覚ルール + +- 上位情報は太い木枠または革タブ、詳細情報は明るい羊皮紙パネルに載せる。 +- 正常は緑、注意は黄、危険は赤、地下固有情報は紫を基本色とする。 +- 小さな数値へ筆文字を使わない。日本語本文と数値は太く読みやすい書体を使う。 +- アイコンには可能な限り日本語ラベルまたはツールチップを併記する。 +- 成人向け施設は露骨な図像を常設せず、紫の照明、幕、宝石、夜の装飾で区別する。 +- 固有作品のボタン、アイコン、キャラクター、画面寸法を複製しない。 +- 背景画像、UI配色、背景とGridの合成率は`VisualThemeProfile`から取得し、Simulation、Save、施設Definitionへ画像Pathを保存しない。 +- 施設、管理カテゴリ、資源、共通操作のIconも`VisualThemeProfile`から安定IDで取得する。Icon Atlasの画像Pathと切り出し位置はTheme資源だけが保持し、Simulation、Save、施設Definitionへ保存しない。 +- Map上の完成施設、建設中施設、配置Preview、および右Inspectorは同じ施設IDのIconを共有する。建設中は暖色半透明、配置可能Previewは緑系半透明、配置不可Previewは赤系半透明とし、占有Cellの状態色と選択枠を隠さない。 +- Map上の客は客クラス、従業員は役割群に対応する成人キャラクターMarkerをThemeから取得する。同一Cellでは決定的な位置ずらしで重なりを軽減し、未登録IDやTheme欠落時も従来の図形MarkerへFallbackする。 +- 主要Panel、Button状態、選択/警告枠、待機Badge、工事中、経路、層境界の外装は`ui_skin_textures`から意味IDで取得する。伸縮PanelとButtonはNine-patchとして描画し、画像がない場合は既存の色・枠・図形へFallbackする。 +- UIの設計基準は1440×810とし、HiDPIと`canvas_items`拡縮で720p、1080p、1440p、4Kへ対応する。横長画面の余白は中央Mapへ配分し、固定領域の最小可読サイズを維持する。Theme TextureはMipmapを生成し、縮小時のちらつきと輪郭崩れを抑える。 +- Theme Textureが未設定または読込不能な場合は、操作不能にせず手続き的な背景色とGridへFallbackする。 + +## 9. モック基準 + +実装時の構成基準は `assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png` とする。 + +画像内の文字、数値、施設サイズはレイアウト検証用であり、正式なゲームデータは各仕様書とデータ定義を正とする。 diff --git a/docs/data/CHARACTER_CATALOG.md b/docs/data/CHARACTER_CATALOG.md new file mode 100644 index 0000000..3f440f4 --- /dev/null +++ b/docs/data/CHARACTER_CATALOG.md @@ -0,0 +1,87 @@ +# MVP人物・来客カタログ + +この文書は14日MVPで使用する静的な人物データの索引である。ゲームルールの正本は `GAME_SPECIFICATION.md`、機械処理の正本は `SYSTEM_SPECIFICATION.md` とし、実データは `data/characters/` のJSONを正本とする。 + +## データ境界 + +- 客クラスと種族は別データとし、8クラス×4種族の32組合せを生成できる。 +- 自由雇用候補20人は固定ID、成人確認済み年齢、役割、日給、契約金、4技能、許可業務を持つ。 +- MVPのユニーク人物はアンリとライの2人とし、相互の`counterpart_id`を必須にする。 +- 全IDは表示名から独立したlowercase snake_caseとする。セーブやイベント参照にはIDだけを使う。 +- JSONはSchema 1として厳密検証する。不明フィールド、不明タグ、重複ID、不足技能、18歳未満、成人未確認、孤児参照が一件でもあればカタログ全体を公開しない。 +- 14日MVPで参照されるID集合は固定する。同数の別IDへ差し替えて既存セーブ・イベント参照をすり替えることも許可しない。 +- JSONは1文書1 MiB、深さ64、10万値までとし、同一Object内の重複KeyはUnicode escape表現を含めて構文解析前に拒否する。検証エラーは最大256件で打ち切りを明示し、不正データによるメモリ増幅を防ぐ。 +- IDは64文字、表示名は80文字までとし、表示名の制御文字、経路の`..`、非有限数、JSONで正確に往復できない巨大数を受け入れない。 +- 倍率は浮動小数ではなくbasis pointで保持する。`10000`が1.00倍、`9000`が0.90倍である。 +- 客クラスの`need_rates`は`hunger`、`sleep`、`hygiene`、`medical`、`leisure`、`intimacy`の6 Keyを必須とする。種族の`need_rate_modifiers`もこの6 Key以外を許可しない。 +- Hygieneは汚れ、Medicalは負傷・病気、Intimacyは成人確認と昼夜Contextで変化させるため、MVP静的データの基礎値は0とする。安全は`risk_tolerance`と候補Risk、交流は選好・GroupFit・関係Eventへ分離する。 + +## 客クラス8種 + +| ID | 表示 | 主な需要・判断特性 | +|---|---|---| +| `warrior` | 戦士 | 忍耐と危険許容が高く、食事・酒場を優先 | +| `mage` | 魔法使い | Manaと静かな施設を好み、騒音を忌避 | +| `rogue` | 盗賊 | 夜・地下を好み、警備を避ける | +| `cleric` | 聖職者 | 信仰と清潔を重視し、違法・地下への感度が高い | +| `noble` | 貴族 | 高予算・低忍耐・高品質要求・VIP需要 | +| `merchant` | 商人 | 交易と価格差を重視 | +| `outsider` | 亜人・魔族客 | 夜行性傾向と地下嗜好を持つ来客枠 | +| `diplomat` | 外交官 | 高品質・安全・VIP対応を要求 | + +`outsider`は種族そのものではない。たとえば`outsider_human`も生成可能だが、通常の需要構成では獣人・魔族の出現重みを高くする。種族と職業を同一軸に戻さないための互換クラス名である。 + +## 種族4種 + +| ID | 表示 | 成人基準 | 主な補正 | +|---|---|---:|---| +| `human` | 人間 | 18 | 基準値 | +| `elf` | エルフ | 20 | Mana・静穏嗜好、忍耐増、危険許容減 | +| `beastfolk` | 獣人 | 18 | 食事需要、忍耐、危険許容がやや高い | +| `demonfolk` | 魔族 | 20 | 夜・地下嗜好、危険許容が高い | + +種族は所持金、忍耐、危険許容、嗜好、欲求増加率だけを補正する。性別や種族を破産結果、基本能力、成人契約の可否へ直結させない。 + +## 自由雇用候補20人 + +| ID | 表示 | 年齢 | 役割 | 希望番 | 日給G | +|---|---|---:|---|---|---:| +| `staff_akane` | アカネ | 24 | 受付 | 昼 | 120 | +| `staff_bardo` | バルド | 38 | 受付 | 夜 | 135 | +| `staff_chika` | チカ | 27 | 調理 | 昼 | 145 | +| `staff_doran` | ドラン | 44 | 調理 | 夜 | 160 | +| `staff_eri` | エリ | 22 | 清掃 | 昼 | 90 | +| `staff_fugo` | フゴ | 31 | 清掃 | 夜 | 100 | +| `staff_gina` | ジーナ | 29 | 運搬 | 昼 | 110 | +| `staff_haru` | ハル | 21 | 運搬 | 夜 | 95 | +| `staff_iris` | イリス | 26 | 接客 | 昼 | 130 | +| `staff_juno` | ユノ | 33 | 接客 | 夜 | 140 | +| `staff_klaus` | クラウス | 36 | 警備 | 昼 | 155 | +| `staff_lina` | リナ | 30 | 警備 | 夜 | 150 | +| `staff_marc` | マルク | 41 | 警備 | 任意 | 170 | +| `staff_nadia` | ナディア | 28 | 酒場 | 夜 | 145 | +| `staff_otto` | オットー | 35 | 酒場 | 夜 | 150 | +| `staff_petra` | ペトラ | 25 | 施術 | 昼 | 145 | +| `staff_quinn` | クイン | 32 | 施術 | 夜 | 160 | +| `staff_rika` | リカ | 23 | 事務 | 昼 | 105 | +| `staff_sven` | スヴェン | 39 | 保守 | 任意 | 125 | +| `staff_tessa` | テッサ | 34 | 医療 | 任意 | 165 | + +雇用時には静的データから`role_id`、4技能、許可業務、日給、通常雇用種別を実行時従業員へ投影する。希望番はSchedule作成側へ、年齢・成人確認・契約金は雇用Commandの検証境界へ渡し、コンテンツ条件を実行時の暗黙知にしない。これにより、カタログから生成した従業員の日給が実行時に0へ脱落することを防ぐ。 + +## ユニーク人物 + +| ID | 表示 | 種別 | 年齢 | 役割 | 対応人物 | 初期機械効果 | +|---|---|---|---:|---|---|---| +| `heroine_anri` | アンリ | ヒロイン | 25 | 女将 | `rival_rai` | 配置階の忍耐減少を0.90倍 | +| `rival_rai` | ライ | ライバル | 29 | 影響者 | `heroine_anri` | Shield攻撃8、夜間+4 | + +両者は`unique`かつ`unsellable`であり、通常の売却・自由雇用解雇へ流さない。関係値50・80の境界は静的効果だけでは越えず、予告付きの明示イベントを必要とする。 + +ユニーク人物の機械効果Keyは既知の効果だけを許可し、ヒロイン用とライバル用を相互に流用しない。倍率は0.25~3.00、MVPのShield攻撃値は0~100へ制限する。自由雇用候補へ`unique`または`unsellable`を付与することも、ユニーク人物から両タグを外すことも拒否する。 + +## 実装上の次境界 + +静的カタログは、内容の欠落や危険な人物データを起動時に検出するところまでを担当する。自動来客でのクラス・種族出現重み、雇用画面、アンリとライの同一施設・同一シフト接触判定は、運営Coordinatorと実行環境を接続した後に行う。 + +客個体の年齢、性別、社会ランク、危険度はアーキタイプではなく生成個体の責務である。現在の`GuestDefinition`は成人確認済みフラグまでを受け取るため、個体生成を接続する際は、選択種族の`adult_age`以上の実年齢を生成・保存し、年齢不詳の個体を成人向け判定へ流さない検証を追加する。 diff --git a/docs/data/FACILITY_CATALOG.md b/docs/data/FACILITY_CATALOG.md new file mode 100644 index 0000000..6ded145 --- /dev/null +++ b/docs/data/FACILITY_CATALOG.md @@ -0,0 +1,20 @@ +# MVP施設カタログ実装メモ + +`GAME_SPECIFICATION.md` 7.6の16施設を、`FacilityDefinition`の現在の項目へ対応付けている。 + +施設の地上一般/地下FRONT/BACK分類と、MVP互換性を保った追加16施設は`docs/FACILITY_LIST.md`を正本とする。追加施設は`data/facilities/expansion/`へ分離し、通常のMVP Registry scanと建築パレットにはまだ含めない。 + +詳細Schema version 2のフィールド、Definition/Instance/日次履歴の責務分離、移行規則は`docs/FACILITY_DATA_DESIGN.md`を正本とする。 + +## 現スキーマと実行時投影 + +- 客用昇降機・業務用昇降機は `vertical_connector`、従業員ドアは `cross_layer_connector` を持つ。従業員ドアはBACK側を配置基準とし、完成時に同座標FRONTとの職員専用Connectorへ投影する。 +- 客用通路・裏通路は `route_subslot` と `schema_facility_proxy` を持つ1セル建設定義として受け付ける。完成後は通常の稼働施設へ登録せず、主要占有と共存するROUTE subslotと実Pathへ投影する。 +- 客用/業務用昇降機は、安全な垂直Topologyが未実装のため、Runtimeの建設確定前に資源・状態を変えず明示拒否する。 +- 「常時」「出動依存」「移動」のように固定サービス時間を持たない施設は、正の時間を必須とする現スキーマに合わせ、1分を状態更新・通過の最小単位としている。 +- 厨房の「15分で4食」は `base_service_minutes = 15` と `inventory_outputs` の4食で表現する。 +- 受付、厨房、成人向け個室は詳細Schema version 2へ移行済み。複数サービスは`service_profiles`で個別の時間、品質、価格、処理単位、欲求効果を持つ。 + +## 既存試作値 + +既存の受付、大衆食堂、一般客室には、統合済みの経済・来客回帰試験が依存する試作調整値がある。今回は互換性を維持し、仕様表との差分(受付の処理時間と直接料金、大衆食堂の処理時間と料金、一般客室の料金)は後続のバランス移行で扱う。 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..6daa5cb --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,37 @@ +[preset.0] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="tests/*, docs/*, .vscode/*" +export_path="build/windows/TheTree.exe" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +codesign/enable=false +application/modify_resources=true +application/icon="res://assets/icons/project_icon.svg" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="0.1.0" +application/product_version="0.1.0" +application/company_name="" +application/product_name="The Tree" +application/file_description="The Tree prototype" +application/copyright="" +application/trademarks="" diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..f4b5a83 --- /dev/null +++ b/project.godot @@ -0,0 +1,45 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="The Tree" +config/description="世界樹を舞台にした2D経営シミュレーション" +config/version="0.1.0" +run/main_scene="res://scenes/main/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://assets/icons/project_icon.svg" + +[autoload] + +EventBus="*res://autoload/event_bus.gd" +GameClock="*res://autoload/game_clock.gd" +GameState="*res://autoload/game_state.gd" +SimulationRuntime="*res://autoload/simulation_runtime.gd" + +[debug] + +gdscript/warnings/untyped_declaration=1 + +[display] + +window/size/viewport_width=1440 +window/size/viewport_height=810 +window/size/window_width_override=1280 +window/size/window_height_override=720 +window/dpi/allow_hidpi=true +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" +window/stretch/scale_mode="fractional" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/main/main.gd b/scenes/main/main.gd new file mode 100644 index 0000000..5f8386f --- /dev/null +++ b/scenes/main/main.gd @@ -0,0 +1,1983 @@ +extends Control + +const WorldPreviewScript := preload("res://scenes/main/world_preview.gd") +const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres") +const MANUAL_SAVE_PATH := "user://saves/manual_ui" +const MVP_FACILITY_IDS: Array[StringName] = [ + &"reception", + &"standard_room", + &"cafeteria", + &"warehouse", + &"kitchen", + &"public_bath", + &"staff_waiting_room", + &"security_station", + &"tavern", + &"massage_room", + &"guest_elevator", + &"service_elevator", + &"staff_door", + &"guest_corridor", + &"service_corridor", + &"adult_private_room", +] +const FACILITY_STATE_NAMES := [ + "故障", + "到達不能", + "魔力不足", + "人員不足", + "閉鎖中", + "低下運転", + "営業中", +] +const FACILITY_REASON_NAMES := { + &"BROKEN": "故障", + &"NO_ACCESS": "経路なし", + &"NO_POWER": "魔力供給なし", + &"NO_STAFF": "必要人員不足", + &"CLOSED_BY_OPERATOR": "手動閉鎖", + &"OUTSIDE_OPENING_HOURS": "営業時間外", + &"LOW_DURABILITY": "耐久度低下", +} +const PREVIEW_REASON_NAMES := { + "INVALID_DEFINITION": "施設データが不正です", + "MISSING_DEPENDENCY": "建設システムを利用できません", + "OUT_OF_BOUNDS": "世界の範囲外です", + "ZONE_MISMATCH": "地上・境界・地下の条件が合いません", + "LAYER_MISMATCH": "表層/裏層が合いません", + "OVERLAP": "ほかの施設または工事と重なります", + "UNSUPPORTED": "構造を支えられません", + "INSUFFICIENT_RESOURCES": "建設資源が不足しています", +} +const EXCAVATION_REASON_NAMES := { + &"OUT_OF_BOUNDS": "世界の範囲外です", + &"NOT_UNDERGROUND": "地下のマスだけ掘削できます", + &"ALREADY_EXCAVATED": "すでに掘削済みです", + &"ALREADY_RESERVED": "このマスは掘削中です", + &"NO_EXCAVATED_NEIGHBOR": "掘削済みの隣接マスから延ばしてください", + &"NO_ENTRANCE_CONNECTION": "地上入口までつながる経路がありません", + &"SUPPORT_REQUIRED": "硬い地盤を支える補強施設が必要です", + &"INSUFFICIENT_RESOURCES": "掘削資源が不足しています", + &"MISSING_DEPENDENCY": "掘削システムを利用できません", +} +const EVENT_CHOICE_NAMES := { + &"event.relationship.threshold.acknowledge": "向き合う", + &"event.relationship.threshold.defer": "今は保留する", +} +const STAFF_ROLE_NAMES := { + &"attendant": "接客", + &"bartender": "酒場", + &"clerk": "会計", + &"cleaner": "清掃", + &"cook": "調理", + &"guard": "警備", + &"maintenance": "保守", + &"masseur": "施術", + &"medic": "医療", + &"porter": "物流", + &"receptionist": "受付", +} +const STAFF_SHIFT_NAMES := { + &"day": "日勤", + &"night": "夜勤", + &"any": "柔軟勤務", +} +const STAFF_SKILL_NAMES := { + &"service": "接客", + &"operation": "運営", + &"combat": "戦闘", + &"underworld": "裏社会", +} + +@export var visual_theme: VisualThemeProfile = DefaultVisualTheme + +var time_label: Label +var phase_label: Label +var guest_count_label: Label +var lodging_count_label: Label +var alert_count_label: Label +var alert_icon_node: TextureRect +var resource_labels: Dictionary = {} +var resource_icon_nodes: Dictionary = {} +var world_preview: WorldPreview +var front_button: Button +var back_button: Button +var excavation_button: Button +var pause_button: Button +var speed_buttons: Dictionary = {} +var dashboard_summary_label: Label +var foreground_event_panel: VBoxContainer +var foreground_event_label: Label +var foreground_event_choices: VBoxContainer +var staff_candidate_panel: VBoxContainer +var staff_candidate_summary_label: Label +var staff_candidate_toggle_button: Button +var staff_candidate_buttons: Dictionary = {} +var selected_heading_label: Label +var selected_detail_label: Label +var selected_entity_icon: TextureRect +var build_status_label: Label +var build_buttons: Dictionary = {} +var navigation_buttons: Dictionary = {} +var management_panel: Control +var management_toggle_button: Button +var palette_scroll: ScrollContainer +var palette_toggle_button: Button +var _management_panel_open := false +var _build_palette_open := false + +var _runtime: Variant = null +var _selected_entity_id: StringName = &"" +var _selected_entity_kind: StringName = &"" +var _selected_definition_id: StringName = &"" +var _selected_definition: Variant = null +var _build_rotation := 0 +var _build_sequence := 1 +var _excavation_sequence := 1 +var _hire_sequence := 1 +var _pending_build_commands: Dictionary = {} +var _excavation_mode := false +var _pending_excavation_commands: Dictionary = {} +var _pending_hire_commands: Dictionary = {} +var _staff_candidate_panel_open := false +var _operations_start_report: Dictionary = {} + + +func _ready() -> void: + _runtime = get_node_or_null("/root/SimulationRuntime") + if _runtime != null and _runtime.has_method("enable_production_save_policy"): + _runtime.enable_production_save_policy() + if _runtime != null \ + and _runtime.has_method("get_operations_report") \ + and _runtime.has_method("enable_production_operations"): + var current_operations: Dictionary = _runtime.call(&"get_operations_report") + if not bool(current_operations.get(&"enabled", false)): + _operations_start_report = _runtime.call(&"enable_production_operations") + _synchronize_ui_command_state() + _build_interface() + _connect_signals() + _populate_build_palette() + _populate_staff_candidates() + _on_time_changed(GameClock.day, GameClock.get_hour(), GameClock.get_minute()) + _on_phase_changed(GameClock.get_phase()) + for resource_name: StringName in GameState.resources: + _on_resource_changed(resource_name, GameState.get_resource(resource_name)) + _set_layer(GameState.active_layer) + _update_pause_and_speed() + _refresh_dashboard() + if not _operations_start_report.is_empty() \ + and not bool(_operations_start_report.get(&"ok", false)): + _set_status( + "本番運営を開始できませんでした: %s" % _result_message(_operations_start_report), + false + ) + + +func _unhandled_key_input(event: InputEvent) -> void: + if not event.pressed or event.echo: + return + var handled := true + match event.keycode: + KEY_ESCAPE: + _clear_active_tool() + KEY_E: + _toggle_excavation_mode() + KEY_H: + _toggle_staff_candidate_panel() + KEY_B: + _toggle_build_palette() + KEY_I: + _set_management_panel_open(not _management_panel_open) + KEY_SPACE: + _toggle_pause() + KEY_TAB: + _set_layer(1 - world_preview.layer_mode) + KEY_1: + _set_speed(1) + KEY_2: + _set_speed(2) + KEY_4: + _set_speed(4) + KEY_R: + if _selected_definition == null: + handled = false + else: + _rotate_build_preview() + _: + handled = false + if handled: + get_viewport().set_input_as_handled() + + +func _build_interface() -> void: + var background := ColorRect.new() + background.color = _visual_color(&"window_background", Color("24170f")) + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + background.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(background) + + var root_margin := MarginContainer.new() + root_margin.add_theme_constant_override("margin_left", 6) + root_margin.add_theme_constant_override("margin_top", 6) + root_margin.add_theme_constant_override("margin_right", 6) + root_margin.add_theme_constant_override("margin_bottom", 6) + root_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(root_margin) + + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 4) + root_margin.add_child(layout) + layout.add_child(_create_top_bar()) + + var content := HBoxContainer.new() + content.name = "MainContent" + content.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 4) + layout.add_child(content) + content.add_child(_create_navigation_rail()) + + var map_panel := PanelContainer.new() + map_panel.name = "MapPanel" + map_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + map_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + map_panel.add_theme_stylebox_override( + "panel", + _themed_panel_style(&"panel_outer", _visual_color(&"window_background", Color("1e1712")), _visual_color(&"border_gold", Color("9b6a32")), 8, 5) + ) + content.add_child(map_panel) + world_preview = WorldPreviewScript.new() + world_preview.name = "WorldPreview" + world_preview.custom_minimum_size = Vector2(600, 380) + world_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL + world_preview.size_flags_vertical = Control.SIZE_EXPAND_FILL + world_preview.set_visual_theme(visual_theme) + map_panel.add_child(world_preview) + # The inspector overlays the map only when requested, leaving the full + # cross-section available while watching the simulation. + management_panel = _create_side_panel() + world_preview.add_child(management_panel) + management_panel.set_anchors_and_offsets_preset(Control.PRESET_RIGHT_WIDE) + management_panel.offset_left = -320 + management_panel.offset_right = -8 + management_panel.offset_top = 8 + management_panel.offset_bottom = -8 + management_panel.visible = false + + layout.add_child(_create_bottom_bar()) + + +func _create_top_bar() -> Control: + var panel := PanelContainer.new() + panel.name = "TopBar" + panel.custom_minimum_size.y = 46 + panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_wood", _visual_color(&"dark_panel", Color("6b3e1d")), _visual_color(&"border_gold", Color("d5a552")), 8, 8)) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 7) + panel.add_child(row) + + var title_box := VBoxContainer.new() + title_box.add_theme_constant_override("separation", 0) + row.add_child(title_box) + var title := Label.new() + title.text = "THE TREE" + title.custom_minimum_size.x = 98 + title.add_theme_font_size_override("font_size", 17) + title.add_theme_color_override("font_color", Color("fff0c8")) + title_box.add_child(title) + var subtitle := Label.new() + subtitle.text = "世界樹の宿" + subtitle.add_theme_font_size_override("font_size", 10) + subtitle.add_theme_color_override("font_color", Color("e5c992")) + title_box.add_child(subtitle) + + row.add_child(VSeparator.new()) + time_label = Label.new() + time_label.custom_minimum_size.x = 104 + time_label.add_theme_font_size_override("font_size", 12) + time_label.add_theme_color_override("font_color", Color("fff5d9")) + row.add_child(time_label) + phase_label = Label.new() + phase_label.custom_minimum_size.x = 48 + phase_label.add_theme_font_size_override("font_size", 12) + phase_label.add_theme_color_override("font_color", Color("ffd37a")) + row.add_child(phase_label) + + guest_count_label = _hud_value_label("来客 0人", 72) + lodging_count_label = _hud_value_label("宿泊 0人", 72) + row.add_child(guest_count_label) + row.add_child(lodging_count_label) + for resource_name: StringName in [&"gold", &"mana", &"materials", &"faith"]: + var resource_block := HBoxContainer.new() + resource_block.add_theme_constant_override("separation", 2) + var resource_icon := TextureRect.new() + resource_icon.name = "%sIcon" % String(resource_name).capitalize() + resource_icon.texture = visual_theme.resource_icon(resource_name) if visual_theme != null else null + resource_icon.custom_minimum_size = Vector2(16, 16) + resource_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + resource_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + resource_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + resource_icon_nodes[resource_name] = resource_icon + resource_block.add_child(resource_icon) + var label := _hud_value_label("", 54) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + resource_labels[resource_name] = label + resource_block.add_child(label) + row.add_child(resource_block) + alert_count_label = _hud_value_label("警戒 0", 64) + alert_count_label.add_theme_color_override("font_color", Color("ffb095")) + alert_icon_node = _icon_rect(visual_theme.control_icon(&"alert") if visual_theme != null else null, 20) + alert_icon_node.name = "AlertIcon" + row.add_child(alert_icon_node) + row.add_child(alert_count_label) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + pause_button = _button("停止", _toggle_pause, 52) + row.add_child(pause_button) + for value: int in [1, 2, 4]: + var speed_button := _button("x%d" % value, _set_speed.bind(value), 42) + speed_button.toggle_mode = true + speed_buttons[value] = speed_button + row.add_child(speed_button) + return panel + + +func _create_navigation_rail() -> Control: + var panel := PanelContainer.new() + panel.name = "NavigationRail" + panel.custom_minimum_size.x = 64 + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_leather", _visual_color(&"dark_panel_alt", Color("513018")), _visual_color(&"border_gold", Color("bd8540")), 8, 7)) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 4) + panel.add_child(column) + var entries := [ + [&"staff", "従業員"], + [&"facility", "施設"], + [&"product", "商品"], + [&"explore", "探索"], + [&"investment", "投資"], + [&"ledger", "帳簿"], + ] + for entry: Array in entries: + var mode := StringName(entry[0]) + var button := _button(String(entry[1]), _activate_management_mode.bind(mode), 50) + button.custom_minimum_size.y = 46 + button.toggle_mode = true + button.add_theme_font_size_override("font_size", 12) + _apply_button_icon(button, visual_theme.navigation_icon(mode) if visual_theme != null else null, 16) + navigation_buttons[mode] = button + column.add_child(button) + var nav_spacer := Control.new() + nav_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + column.add_child(nav_spacer) + var layer_hint := Label.new() + layer_hint.text = "TAB\n表 / 裏" + layer_hint.tooltip_text = "ホイール: 拡大縮小\n中ボタンドラッグ: 移動\nHome: 全体表示" + layer_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + layer_hint.add_theme_font_size_override("font_size", 10) + layer_hint.add_theme_color_override("font_color", Color("e2c38c")) + column.add_child(layer_hint) + return panel + + +func _create_side_panel() -> Control: + var panel := PanelContainer.new() + panel.name = "SidePanel" + panel.custom_minimum_size.x = 312 + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_parchment", _visual_color(&"light_panel", Color("f4e3bd")), _visual_color(&"border_gold", Color("a96f2f")), 8, 10)) + var scroll := ScrollContainer.new() + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_child(scroll) + var column := VBoxContainer.new() + column.custom_minimum_size.x = 278 + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.add_theme_constant_override("separation", 8) + scroll.add_child(column) + + var inspector_heading := HBoxContainer.new() + var inspector_title := _section_heading("運営状況") + inspector_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + inspector_heading.add_child(inspector_title) + inspector_heading.add_child(_button("閉じる I", _set_management_panel_open.bind(false), 64)) + column.add_child(inspector_heading) + dashboard_summary_label = Label.new() + dashboard_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + dashboard_summary_label.add_theme_color_override("font_color", Color("4d341f")) + dashboard_summary_label.add_theme_font_size_override("font_size", 13) + column.add_child(dashboard_summary_label) + foreground_event_panel = VBoxContainer.new() + foreground_event_panel.name = "ForegroundEventPanel" + foreground_event_panel.add_theme_constant_override("separation", 6) + foreground_event_panel.visible = false + column.add_child(foreground_event_panel) + foreground_event_panel.add_child(_section_heading("要対応イベント")) + foreground_event_label = Label.new() + foreground_event_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + foreground_event_label.add_theme_color_override("font_color", Color("8f3f29")) + foreground_event_label.add_theme_font_size_override("font_size", 13) + foreground_event_panel.add_child(foreground_event_label) + foreground_event_choices = VBoxContainer.new() + foreground_event_choices.name = "ForegroundEventChoices" + foreground_event_choices.add_theme_constant_override("separation", 5) + foreground_event_panel.add_child(foreground_event_choices) + column.add_child(HSeparator.new()) + + var candidate_heading_row := HBoxContainer.new() + candidate_heading_row.add_child(_section_heading("雇用候補")) + var candidate_heading_spacer := Control.new() + candidate_heading_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + candidate_heading_row.add_child(candidate_heading_spacer) + staff_candidate_toggle_button = _button( + "候補を見る H", + _toggle_staff_candidate_panel, + 108 + ) + staff_candidate_toggle_button.name = "StaffCandidateToggleButton" + candidate_heading_row.add_child(staff_candidate_toggle_button) + column.add_child(candidate_heading_row) + staff_candidate_panel = VBoxContainer.new() + staff_candidate_panel.name = "StaffCandidatePanel" + staff_candidate_panel.visible = false + staff_candidate_panel.add_theme_constant_override("separation", 7) + column.add_child(staff_candidate_panel) + staff_candidate_summary_label = Label.new() + staff_candidate_summary_label.name = "StaffCandidateSummary" + staff_candidate_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + staff_candidate_summary_label.add_theme_color_override("font_color", Color("765438")) + staff_candidate_summary_label.add_theme_font_size_override("font_size", 12) + staff_candidate_panel.add_child(staff_candidate_summary_label) + var candidate_list := VBoxContainer.new() + candidate_list.name = "StaffCandidateList" + candidate_list.add_theme_constant_override("separation", 7) + staff_candidate_panel.add_child(candidate_list) + column.add_child(HSeparator.new()) + + selected_heading_label = _section_heading("選択中の施設") + column.add_child(selected_heading_label) + var selected_row := HBoxContainer.new() + selected_row.add_theme_constant_override("separation", 8) + column.add_child(selected_row) + selected_entity_icon = TextureRect.new() + selected_entity_icon.name = "SelectedEntityIcon" + selected_entity_icon.custom_minimum_size = Vector2(24, 24) + selected_entity_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + selected_entity_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + selected_entity_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + selected_row.add_child(selected_entity_icon) + selected_detail_label = Label.new() + selected_detail_label.text = "地図上の施設をクリックすると、営業状態を確認できます。" + selected_detail_label.custom_minimum_size.y = 112 + selected_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + selected_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + selected_detail_label.add_theme_color_override("font_color", Color("4d341f")) + selected_detail_label.add_theme_font_size_override("font_size", 13) + selected_row.add_child(selected_detail_label) + + return panel + + +func _create_bottom_bar() -> Control: + var panel := PanelContainer.new() + panel.name = "BottomBar" + panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_wood", _visual_color(&"dark_panel", Color("24382e")), _visual_color(&"border_gold", Color("7d8862")), 0, 5)) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 4) + panel.add_child(column) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 5) + column.add_child(row) + + palette_toggle_button = _button("建築 B", _toggle_build_palette, 76) + palette_toggle_button.name = "BuildPaletteToggleButton" + palette_toggle_button.toggle_mode = true + row.add_child(palette_toggle_button) + front_button = _button("客用・表層", _set_layer.bind(0), 96) + back_button = _button("裏方・裏層", _set_layer.bind(1), 96) + _apply_button_icon(front_button, visual_theme.control_icon(&"front_layer") if visual_theme != null else null, 16) + _apply_button_icon(back_button, visual_theme.control_icon(&"back_layer") if visual_theme != null else null, 16) + front_button.toggle_mode = true + back_button.toggle_mode = true + row.add_child(front_button) + row.add_child(back_button) + excavation_button = _button("掘削 E", _toggle_excavation_mode, 72) + excavation_button.name = "ExcavationButton" + excavation_button.toggle_mode = true + _apply_button_icon(excavation_button, visual_theme.control_icon(&"excavate") if visual_theme != null else null, 16) + row.add_child(excavation_button) + row.add_child(VSeparator.new()) + var rotate_button := _button("回転 R", _rotate_build_preview, 72) + _apply_button_icon(rotate_button, visual_theme.control_icon(&"rotate") if visual_theme != null else null, 16) + row.add_child(rotate_button) + row.add_child(_button("解除 Esc", _clear_active_tool, 80)) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + management_toggle_button = _button("運営 I", _toggle_management_panel, 72) + management_toggle_button.name = "ManagementToggleButton" + management_toggle_button.toggle_mode = true + row.add_child(management_toggle_button) + var save_button := _button("保存", _save_manual, 62) + _apply_button_icon(save_button, visual_theme.control_icon(&"save") if visual_theme != null else null, 16) + row.add_child(save_button) + row.add_child(_button("読込", _load_manual, 56)) + + palette_scroll = ScrollContainer.new() + palette_scroll.name = "BuildPaletteScroll" + palette_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + palette_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + palette_scroll.custom_minimum_size.y = 62 + palette_scroll.visible = false + column.add_child(palette_scroll) + var grid := GridContainer.new() + grid.name = "BuildPalette" + grid.columns = MVP_FACILITY_IDS.size() + grid.add_theme_constant_override("h_separation", 4) + grid.add_theme_constant_override("v_separation", 0) + palette_scroll.add_child(grid) + + build_status_label = Label.new() + build_status_label.name = "BuildStatusLabel" + build_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + build_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + build_status_label.add_theme_font_size_override("font_size", 11) + build_status_label.add_theme_color_override("font_color", Color("c7d3a8")) + column.add_child(build_status_label) + return panel + + +func _toggle_build_palette() -> void: + _set_build_palette_open(not _build_palette_open) + + +func _set_build_palette_open(open: bool) -> void: + _build_palette_open = open + if palette_scroll != null: + palette_scroll.visible = open + if palette_toggle_button != null: + palette_toggle_button.set_pressed_no_signal(open) + palette_toggle_button.text = "建築を閉じる B" if open else "建築 B" + + +func _toggle_management_panel() -> void: + _set_management_panel_open(not _management_panel_open) + + +func _set_management_panel_open(open: bool) -> void: + _management_panel_open = open + if management_panel != null: + management_panel.visible = open + if management_toggle_button != null: + management_toggle_button.set_pressed_no_signal(open) + if not open: + _set_staff_candidate_panel_open(false) + + +func _activate_management_mode(mode: StringName) -> void: + _set_management_panel_open(mode != &"facility") + for raw_mode: Variant in navigation_buttons: + (navigation_buttons[raw_mode] as Button).set_pressed_no_signal(StringName(raw_mode) == mode) + match mode: + &"staff": + _set_staff_candidate_panel_open(true) + &"facility": + _set_build_palette_open(true) + _set_staff_candidate_panel_open(false) + _set_status("建築する施設を下のパレットから選んでください。", true, true) + &"product": + _set_status("商品管理は一覧画面の実装準備中です。", true, true) + &"explore": + _set_status("探索管理は一覧画面の実装準備中です。", true, true) + &"investment": + _set_status("投資管理は一覧画面の実装準備中です。", true, true) + &"ledger": + _set_status("帳簿は06:00決算と接続する予定です。", true, true) + + +func _connect_signals() -> void: + GameClock.time_changed.connect(_on_time_changed) + GameClock.phase_changed.connect(_on_phase_changed) + GameState.resource_changed.connect(_on_resource_changed) + world_preview.facility_selected.connect(_on_facility_selected) + world_preview.construction_selected.connect(_on_construction_selected) + world_preview.cell_clicked.connect(_on_map_cell_clicked) + world_preview.build_preview_changed.connect(_on_build_preview_changed) + world_preview.excavation_preview_changed.connect(_on_excavation_preview_changed) + if _runtime != null and _runtime is Object: + if _runtime.has_signal(&"snapshot_published"): + _runtime.connect(&"snapshot_published", Callable(self, "_on_snapshot_published")) + if _runtime.has_signal(&"command_completed"): + _runtime.connect(&"command_completed", Callable(self, "_on_command_completed")) + + +func _populate_build_palette() -> void: + var palette := find_child("BuildPalette", true, false) as GridContainer + if palette == null: + return + var registry: Variant = _service(&"definition_registry") + for definition_id: StringName in MVP_FACILITY_IDS: + var definition: Variant = null + if registry != null and registry.has_method(&"get_facility"): + definition = registry.call(&"get_facility", definition_id) + if definition == null: + continue + var button := Button.new() + button.toggle_mode = true + button.custom_minimum_size = Vector2(116, 50) + button.text = _build_button_text(definition) + button.tooltip_text = String(_definition_value(definition, &"description", "")) + button.add_theme_font_size_override("font_size", 12) + _apply_button_icon(button, visual_theme.facility_icon(definition_id) if visual_theme != null else null, 22) + _apply_button_style(button) + button.toggled.connect(_on_build_button_toggled.bind(definition_id)) + build_buttons[definition_id] = button + palette.add_child(button) + if build_buttons.is_empty(): + _set_status("施設定義を読み込めませんでした。", false) + else: + _set_status("B 建築 / H 雇用 / I 運営 ホイール 拡大縮小・中ボタン 移動・Home 全体表示", true, true) + + +func _populate_staff_candidates() -> void: + var list := find_child("StaffCandidateList", true, false) as VBoxContainer + if list == null: + return + for child: Node in list.get_children(): + list.remove_child(child) + child.queue_free() + staff_candidate_buttons.clear() + var summaries := _staff_candidate_summaries() + for summary: Dictionary in summaries: + var candidate_id := StringName(summary.get(&"candidate_id", &"")) + if candidate_id.is_empty(): + continue + var card := PanelContainer.new() + card.name = "Candidate_%s" % String(candidate_id) + card.add_theme_stylebox_override( + "panel", + _themed_panel_style(&"panel_leather", Color("132630"), Color("31505d"), 7, 8) + ) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 4) + card.add_child(content) + var detail := Label.new() + detail.name = "CandidateDetail_%s" % String(candidate_id) + detail.text = _staff_candidate_text(summary) + detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + detail.add_theme_color_override("font_color", Color("c8d7dc")) + detail.add_theme_font_size_override("font_size", 12) + content.add_child(detail) + var hire_button := Button.new() + hire_button.name = "Hire_%s" % String(candidate_id) + hire_button.text = "雇用する" + hire_button.custom_minimum_size.y = 30 + _apply_button_style(hire_button) + hire_button.pressed.connect(_hire_staff_candidate.bind(candidate_id)) + content.add_child(hire_button) + staff_candidate_buttons[candidate_id] = hire_button + list.add_child(card) + _refresh_staff_candidates() + + +func _staff_candidate_summaries() -> Array[Dictionary]: + if _runtime == null or not _runtime.has_method(&"get_mvp_staff_candidate_summaries"): + return [] + var value: Variant = _runtime.call(&"get_mvp_staff_candidate_summaries") + var result: Array[Dictionary] = [] + if value is Array: + for item: Variant in value: + if item is Dictionary: + result.append((item as Dictionary).duplicate(true)) + return result + + +func _staff_candidate_text(summary: Dictionary) -> String: + var candidate_id := StringName(summary.get(&"candidate_id", &"")) + var display_name := String(summary.get(&"display_name", candidate_id)) + var role_id := StringName(summary.get(&"role_id", &"")) + var shift_id := StringName(summary.get(&"preferred_shift", &"any")) + var skills_value: Variant = summary.get(&"skills", {}) + var skills: Dictionary = skills_value if skills_value is Dictionary else {} + var skill_parts: Array[String] = [] + for skill_id: StringName in [&"service", &"operation", &"combat", &"underworld"]: + skill_parts.append("%s%d" % [ + String(STAFF_SKILL_NAMES.get(skill_id, String(skill_id))), + int(skills.get(skill_id, 0)), + ]) + return "%s(%d歳) %s / %s\n契約 G%d 日給 G%d\n技能 %s" % [ + display_name, + int(summary.get(&"age", 0)), + String(STAFF_ROLE_NAMES.get(role_id, String(role_id))), + String(STAFF_SHIFT_NAMES.get(shift_id, String(shift_id))), + int(summary.get(&"signing_cost", 0)), + int(summary.get(&"daily_wage", 0)), + "・".join(skill_parts), + ] + + +func _toggle_staff_candidate_panel() -> void: + _set_staff_candidate_panel_open(not _staff_candidate_panel_open) + + +func _set_staff_candidate_panel_open(open: bool) -> void: + _staff_candidate_panel_open = open + if staff_candidate_panel != null: + staff_candidate_panel.visible = open + _refresh_staff_candidates() + if open: + _set_management_panel_open(true) + _set_status("雇用候補を選んでください。契約金と日給は確定前に表示されます。", true, true) + + +func _refresh_staff_candidates() -> void: + if staff_candidate_panel == null or staff_candidate_summary_label == null: + return + var summaries := _staff_candidate_summaries() + var hired_count := 0 + for summary: Dictionary in summaries: + var candidate_id := StringName(summary.get(&"candidate_id", &"")) + var hired := bool(summary.get(&"hired", false)) + if hired: + hired_count += 1 + var button := staff_candidate_buttons.get(candidate_id) as Button + if button == null: + continue + var pending := _pending_hire_commands.values().has(candidate_id) + button.disabled = hired or pending + button.text = "雇用済み" if hired else "手続き中…" if pending else "雇用する" + staff_candidate_summary_label.text = "未雇用 %d人 / 雇用済み %d人" % [ + maxi(summaries.size() - hired_count, 0), + hired_count, + ] + if staff_candidate_toggle_button != null: + staff_candidate_toggle_button.text = ( + "候補を閉じる H" if _staff_candidate_panel_open + else "候補を見る H(%d)" % maxi(summaries.size() - hired_count, 0) + ) + + +func _hire_staff_candidate(candidate_id: StringName) -> void: + if _runtime == null or not _runtime.has_method(&"submit_command"): + _set_status("雇用手続きを送信できません。", false) + return + if _pending_hire_commands.values().has(candidate_id): + _set_status("この候補の雇用手続きはすでに受理されています。", false) + return + var summaries := _staff_candidate_summaries() + var selected: Dictionary = {} + for summary: Dictionary in summaries: + if StringName(summary.get(&"candidate_id", &"")) == candidate_id: + selected = summary + break + if selected.is_empty(): + _set_status("雇用候補を読み込めませんでした。", false) + return + if bool(selected.get(&"hired", false)): + _set_status("%sはすでに雇用済みです。" % String(selected.get(&"display_name", candidate_id)), false) + _refresh_staff_candidates() + return + var sequence := _hire_sequence + _hire_sequence += 1 + var idempotency_key := StringName( + "ui.hire.%s.%08d" % [String(candidate_id), sequence] + ) + var result: Variant = _runtime.call( + &"submit_command", + &"hire_staff_candidate", + {&"candidate_id": candidate_id}, + &"player", + candidate_id, + 75, + -1, + idempotency_key + ) + if result == null or not result.has_method(&"is_success"): + _set_status("雇用手続きへ応答がありません。", false) + return + if not bool(result.call(&"is_success")): + _set_status(_hire_failure_message(result), false) + _refresh_staff_candidates() + return + var display_name := String(selected.get(&"display_name", candidate_id)) + if result.details is Dictionary and result.details.has(&"staff_id"): + _set_status("%sを雇用しました(契約 G%d / 日給 G%d)。" % [ + display_name, + int(selected.get(&"signing_cost", 0)), + int(selected.get(&"daily_wage", 0)), + ], true) + else: + _pending_hire_commands[int(result.command_id)] = candidate_id + _set_status("%sの雇用手続きを受理しました。" % display_name, true) + _refresh_dashboard() + + +func _hire_failure_message(result: Variant) -> String: + var details: Variant = result.details + if details is Dictionary: + var reason := StringName(details.get(&"reason", &"")) + match reason: + &"ARREARS_REMAIN": + return "雇用不可: 未払給与 G%dを清算してください。" % int(details.get(&"arrears_total", 0)) + &"BANKRUPT": + return "雇用不可: 資金破綻後は新しい契約を結べません。" + &"EXTERNAL_ROLLBACK_FAILED": + return "雇用不可: 運営データの復旧が必要です。" + var message := String(result.message).to_lower() + if int(result.code) == int(CommandResult.Code.DUPLICATE_COMMAND_ID): + return "この候補はすでに雇用済みです。" + if "insufficient" in message or "balance" in message: + return "雇用不可: 契約金が不足しています。" + return _command_failure_message(result) + + +func _build_button_text(definition: Variant) -> String: + var name := String(_definition_value(definition, &"display_name", _definition_value(definition, &"id", "施設"))) + var cost_value: Variant = _definition_value(definition, &"construction_cost", {}) + var gold := 0 + var materials := 0 + if cost_value is Dictionary: + gold = int(cost_value.get(&"gold", cost_value.get("gold", 0))) + materials = int(cost_value.get(&"materials", cost_value.get("materials", 0))) + return "%s\nG %d / 資材 %d" % [name, gold, materials] + + +func _on_build_button_toggled(pressed: bool, definition_id: StringName) -> void: + if not pressed: + if _selected_definition_id == definition_id: + _clear_build_selection() + return + _select_build_definition(definition_id) + + +func _select_build_definition(definition_id: StringName) -> void: + var registry: Variant = _service(&"definition_registry") + if registry == null or not registry.has_method(&"get_facility"): + _set_status("建設パレットを利用できません。", false) + return + var definition: Variant = registry.call(&"get_facility", definition_id) + if definition == null: + _set_status("選択した施設定義が見つかりません。", false) + return + _set_excavation_mode(false, false) + _selected_definition_id = definition_id + _selected_definition = definition + _build_rotation = 0 + for raw_id: Variant in build_buttons: + (build_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == definition_id) + var target_layer := clampi(int(_definition_value(definition, &"layer", 0)), 0, 1) + _set_layer(target_layer) + world_preview.set_build_definition(definition, _build_rotation) + _set_status("%sを選択中。配置するマスをクリックしてください。" % String(_definition_value(definition, &"display_name", definition_id)), true, true) + + +func _clear_build_selection(announce := true) -> void: + _selected_definition_id = &"" + _selected_definition = null + _build_rotation = 0 + for button_value: Variant in build_buttons.values(): + (button_value as Button).set_pressed_no_signal(false) + if world_preview != null: + world_preview.clear_build_definition() + if announce: + _set_status("建設選択を解除しました。", true, true) + + +func _clear_active_tool() -> void: + if _staff_candidate_panel_open: + _set_staff_candidate_panel_open(false) + return + if _excavation_mode: + _set_excavation_mode(false) + return + _clear_build_selection() + _set_build_palette_open(false) + _set_management_panel_open(false) + + +func _toggle_excavation_mode() -> void: + _set_excavation_mode(not _excavation_mode) + + +func _set_excavation_mode(enabled: bool, announce := true) -> void: + _excavation_mode = enabled + if excavation_button != null: + excavation_button.set_pressed_no_signal(_excavation_mode) + if world_preview != null: + world_preview.set_excavation_mode(_excavation_mode) + if not _excavation_mode: + if announce: + _set_status("掘削モードを解除しました。", true, true) + return + _clear_build_selection(false) + _selected_entity_id = &"" + _selected_entity_kind = &"" + world_preview.selected_entity_id = &"" + selected_heading_label.text = "掘削モード" + selected_detail_label.text = "地下のマスにカーソルを合わせると、費用と所要時間を確認できます。" + var gate_message := _excavation_gate_message() + _set_status( + gate_message if not gate_message.is_empty() else "地下の掘削候補を選んでください。FRONT / BACKは同時に開通します。", + gate_message.is_empty(), + true + ) + + +func _rotate_build_preview() -> void: + if _selected_definition == null: + _set_status("先に建設する施設を選択してください。", false, true) + return + var allowed_value: Variant = _definition_value(_selected_definition, &"allowed_rotations", [0]) + var allowed: Array[int] = [] + if allowed_value is Array: + for rotation_value: Variant in allowed_value: + allowed.append(int(rotation_value)) + allowed.sort() + if allowed.is_empty(): + allowed = [0] + var index := allowed.find(_build_rotation) + _build_rotation = allowed[(index + 1) % allowed.size()] + world_preview.set_build_rotation(_build_rotation) + _set_status("回転 %d°。地図のマスをクリックして建設します。" % _build_rotation, true, true) + + +func _on_map_cell_clicked(cell: Vector3i) -> void: + if _excavation_mode: + _start_excavation(cell) + return + if _selected_definition == null: + _selected_entity_id = &"" + _selected_entity_kind = &"" + world_preview.selected_entity_id = &"" + selected_heading_label.text = "選択中のマス" + selected_detail_label.text = "座標 (%d, %d)\nレイヤー %s\n\n施設のないマスです。" % [cell.x, cell.y, "FRONT / 表層" if cell.z == 0 else "BACK / 裏層"] + return + var preview := world_preview.get_build_preview_report(cell) + if not bool(preview.get(&"can_build", false)): + _set_status(_preview_failure_message(preview), false) + return + if _runtime == null or not _runtime.has_method(&"submit_command"): + _set_status("Simulation Runtimeへ接続できません。", false) + return + var idempotency_key := StringName("ui.build.%08d" % _build_sequence) + _build_sequence += 1 + var result: Variant = _runtime.call( + &"submit_command", + &"build_facility", + {&"definition_id": _selected_definition_id, &"origin": cell, &"rotation": _build_rotation}, + &"player", + &"", + 50, + -1, + idempotency_key + ) + if result == null or not result.has_method(&"is_success"): + _set_status("建設指示を送信できませんでした。", false) + return + if not bool(result.call(&"is_success")): + _set_status(_command_failure_message(result), false) + return + var name := String(_definition_value(_selected_definition, &"display_name", _selected_definition_id)) + if result.details is Dictionary and result.details.has(&"job_id"): + _set_status("%sの建設を開始しました(%d, %d / %d°)。" % [name, cell.x, cell.y, _build_rotation], true) + else: + _pending_build_commands[int(result.command_id)] = _selected_definition_id + _set_status("%sの建設指示を受理しました(%d, %d / %d°)。" % [name, cell.x, cell.y, _build_rotation], true) + _refresh_dashboard() + + +func _on_build_preview_changed(report: Dictionary) -> void: + if _selected_definition == null or world_preview.get_hovered_cell() == null: + return + if bool(report.get(&"can_build", false)): + var cell: Vector3i = world_preview.get_hovered_cell() + _set_status("配置可能 (%d, %d)・%d° — クリックで建設" % [cell.x, cell.y, _build_rotation], true, true) + else: + _set_status(_preview_failure_message(report), false, true) + + +func _on_excavation_preview_changed(report: Dictionary) -> void: + if not _excavation_mode or world_preview.get_hovered_cell() == null: + return + var gate_message := _excavation_gate_message() + if not gate_message.is_empty(): + _set_status("掘削不可: %s%s" % [gate_message, _excavation_cost_and_time(report)], false, true) + return + var cell: Vector3i = world_preview.get_hovered_cell() + if bool(report.get(&"ok", false)): + _set_status( + "掘削可能 (%d, %d)・%s%s — クリックでFRONT / BACKを開通" % [ + cell.x, + cell.y, + "FRONT / 表層" if cell.z == 0 else "BACK / 裏層", + _excavation_cost_and_time(report), + ], + true, + true + ) + else: + _set_status(_excavation_failure_message(report), false, true) + + +func _start_excavation(cell: Vector3i) -> void: + var report := world_preview.get_excavation_preview_report(cell) + var gate_message := _excavation_gate_message() + if not gate_message.is_empty(): + _set_status("掘削不可: %s%s" % [gate_message, _excavation_cost_and_time(report)], false) + return + if not bool(report.get(&"ok", false)): + _set_status(_excavation_failure_message(report), false) + return + if _runtime == null or not _runtime.has_method(&"submit_command"): + _set_status("Simulation Runtimeへ接続できません。", false) + return + var spatial_position := Vector2i(cell.x, cell.y) + if _pending_excavation_commands.values().has(spatial_position): + _set_status("このマスの掘削指示はすでに受理されています。", false) + return + var idempotency_key := _excavation_idempotency_key(cell) + var result: Variant = _runtime.call( + &"submit_command", + &"start_excavation", + {&"cell": Vector3i(cell.x, cell.y, world_preview.layer_mode)}, + &"player", + &"", + 50, + -1, + idempotency_key + ) + if result == null or not result.has_method(&"is_success"): + _set_status("掘削指示を送信できませんでした。", false) + return + if not bool(result.call(&"is_success")): + _set_status(_excavation_command_failure_message(result), false) + return + if result.details is Dictionary and result.details.has(&"job_id"): + _select_excavation_job(StringName(result.details.get(&"job_id", &""))) + _set_status("掘削を開始しました(%d, %d / FRONT・BACK)。" % [cell.x, cell.y], true) + else: + _pending_excavation_commands[int(result.command_id)] = spatial_position + _set_status("掘削指示を受理しました(%d, %d / FRONT・BACK)。" % [cell.x, cell.y], true) + _refresh_dashboard() + + +func _excavation_idempotency_key(cell: Vector3i) -> StringName: + # One click gets one stable retry identity. A later player retry receives a + # new sequence so a previously cached validation failure cannot block it. + var sequence := _excavation_sequence + _excavation_sequence += 1 + return StringName( + "ui.excavation.%d.%d.%08d" % [cell.x, cell.y, sequence] + ) + + +func _on_facility_selected(instance_id: StringName) -> void: + _set_management_panel_open(true) + _set_excavation_mode(false, false) + _selected_entity_id = instance_id + _selected_entity_kind = &"facility" + _refresh_selected_entity() + + +func _on_construction_selected(job_id: StringName) -> void: + _set_management_panel_open(true) + _set_excavation_mode(false, false) + _selected_entity_id = job_id + _selected_entity_kind = &"construction" + _refresh_selected_entity() + + +func _refresh_dashboard() -> void: + var guests := _call_array(_service(&"guest_service"), &"get_guests") + var queued_guests := 0 + var served_guests := 0 + for guest: Variant in guests: + if guest == null: + continue + if int(guest.state) == 4: + queued_guests += 1 + elif int(guest.state) == 5: + served_guests += 1 + var staff_members := _call_array(_service(&"staff_service"), &"get_staff_members") + var available_staff := 0 + var working_staff := 0 + for staff: Variant in staff_members: + if staff == null: + continue + if int(staff.state) == 2: + available_staff += 1 + elif int(staff.state) in [3, 4]: + working_staff += 1 + var event_service: Variant = _service(&"event_service") + var events := _call_array(event_service, &"get_instances") + var active_events := 0 + for event_instance: Variant in events: + if event_instance != null and int(event_instance.state) in [0, 1, 2, 7]: + active_events += 1 + var active_jobs := 0 + for job: Variant in _call_array(_service(&"construction_service"), &"get_jobs"): + if job != null and int(job.state) not in [2, 3]: + active_jobs += 1 + var heat_text := "—" + var heat_value := 0 + var authority: Variant = _service(&"authority_service") + if authority != null and authority.has_method(&"get_authority_state"): + var heat_state: Variant = authority.call(&"get_authority_state") + if heat_state != null: + heat_value = int(heat_state.heat) + heat_text = "%d / 100" % heat_value + var foreground_text := "なし" + if event_service != null and event_service.has_method(&"get_foreground_instance"): + var foreground: Variant = event_service.call(&"get_foreground_instance") + if foreground != null: + foreground_text = String(foreground.resolved_event_id) + _refresh_foreground_event(event_service) + var operations_text := _operations_dashboard_text() + dashboard_summary_label.text = ( + "客 %d人(待機 %d / 利用 %d)\n" % [guests.size(), queued_guests, served_guests] + + "従業員 %d人(待機 %d / 作業 %d)\n" % [staff_members.size(), available_staff, working_staff] + + "イベント %d件 前景: %s\n" % [active_events, foreground_text] + + "警戒度 %s  建設中 %d件" % [heat_text, active_jobs] + + operations_text + ) + if guest_count_label != null: + guest_count_label.text = "来客 %d人" % guests.size() + if lodging_count_label != null: + var lodging_guests := 0 + for guest: Variant in guests: + if guest != null and int(guest.planned_exit_at) >= GameClock.absolute_minute + 360: + lodging_guests += 1 + lodging_count_label.text = "宿泊 %d人" % lodging_guests + if alert_count_label != null: + alert_count_label.text = "警戒 %d" % heat_value + alert_count_label.add_theme_color_override( + "font_color", + Color("ffb498") if heat_value >= 60 else Color("ebca84") if heat_value >= 30 else Color("c7d3a8") + ) + _refresh_staff_candidates() + _refresh_selected_entity() + _update_pause_and_speed() + + +func _refresh_foreground_event(event_service: Variant) -> void: + if foreground_event_panel == null \ + or foreground_event_label == null \ + or foreground_event_choices == null: + return + for child: Node in foreground_event_choices.get_children(): + foreground_event_choices.remove_child(child) + child.queue_free() + var foreground: Variant = null + if event_service != null and event_service.has_method(&"get_foreground_instance"): + foreground = event_service.call(&"get_foreground_instance") + if foreground == null or int(foreground.state) != 2: + foreground_event_panel.visible = false + return + foreground_event_panel.visible = true + _set_management_panel_open(true) + var actors: Array[String] = [] + for actor_value: Variant in foreground.actor_ids: + actors.append(String(actor_value)) + foreground_event_label.text = ( + "%s\n関係者: %s" % [ + String(foreground.resolved_event_id), + "・".join(actors) if not actors.is_empty() else "なし", + ] + ) + var definition: Variant = null + if event_service.has_method(&"get_definition"): + definition = event_service.call(&"get_definition", foreground.resolved_event_id) + var choices_value: Variant = _definition_value(definition, &"choices", []) + if not choices_value is Array: + return + for choice: Variant in choices_value: + if choice == null: + continue + var choice_id := StringName(_definition_value(choice, &"choice_id", &"")) + if choice_id.is_empty(): + continue + var choice_button := Button.new() + choice_button.text = _event_choice_button_text(choice) + choice_button.custom_minimum_size.y = 34 + _apply_button_style(choice_button) + choice_button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + choice_button.pressed.connect( + _submit_foreground_event_choice.bind( + foreground.event_instance_id, + foreground.choice_instance_id, + choice_id + ) + ) + foreground_event_choices.add_child(choice_button) + + +func _event_choice_button_text(choice: Variant) -> String: + var choice_id := StringName(_definition_value(choice, &"choice_id", &"")) + var text_key := StringName(_definition_value(choice, &"text_key", choice_id)) + var title := String(EVENT_CHOICE_NAMES.get(text_key, String(choice_id))) + var cost_value: Variant = _definition_value(choice, &"cost", {}) + if not cost_value is Dictionary or cost_value.is_empty(): + return title + var cost_parts: Array[String] = [] + var cost_keys: Array = cost_value.keys() + cost_keys.sort_custom(func(left: Variant, right: Variant) -> bool: + return String(left) < String(right) + ) + for raw_key: Variant in cost_keys: + cost_parts.append("%s %d" % [String(raw_key), int(cost_value[raw_key])]) + return "%s(%s)" % [title, " / ".join(cost_parts)] + + +func _submit_foreground_event_choice( + event_instance_id: StringName, + choice_instance_id: StringName, + choice_id: StringName +) -> void: + if _runtime == null or not _runtime.has_method(&"submit_command"): + _set_status("イベント選択を送信できません。", false) + return + var idempotency_key := StringName( + "ui.event.choice.%s.%s" % [String(choice_instance_id), String(choice_id)] + ) + var result: Variant = _runtime.call( + &"submit_command", + &"submit_event_choice", + { + &"event_instance_id": event_instance_id, + &"choice_instance_id": choice_instance_id, + &"choice_id": choice_id, + }, + &"player", + event_instance_id, + 100, + -1, + idempotency_key + ) + var succeeded: bool = result != null \ + and result.has_method(&"is_success") \ + and bool(result.call(&"is_success")) + _set_status( + "イベントの選択を確定しました。" if succeeded \ + else _command_failure_message(result) if result != null else "イベント選択に失敗しました。", + succeeded + ) + _refresh_dashboard() + _update_pause_and_speed() + + +func _operations_dashboard_text() -> String: + if _runtime == null or not _runtime.has_method(&"get_operations_report"): + return "" + var report_value: Variant = _runtime.call(&"get_operations_report") + if not report_value is Dictionary: + return "" + var report: Dictionary = report_value + if not bool(report.get(&"enabled", false)): + return "\n運営日次処理 未開始" + var coordinator_value: Variant = report.get(&"coordinator", {}) + var coordinator: Dictionary = coordinator_value if coordinator_value is Dictionary else {} + var terminal := StringName(coordinator.get(&"terminal_state", &"RUNNING")) + var state_text := "営業中" + if terminal == &"BANKRUPT": + state_text = "破綻(終了)" + elif bool(report.get(&"runtime_failure_latched", false)): + state_text = "処理失敗・再試行待ち" + elif bool(report.get(&"tick_blocked", false)): + state_text = "確認待ち" + var day_profit_text := "未確定" + var summaries_value: Variant = coordinator.get(&"day_summaries", []) + if summaries_value is Array and not summaries_value.is_empty(): + var latest_value: Variant = summaries_value.back() + if latest_value is Dictionary: + day_profit_text = "G %+d" % int(latest_value.get(&"net_profit", 0)) + return ( + "\n行動点 %d / 6  未払給与 G %d\n" % [ + int(report.get(&"personal_action_points", 0)), + int(coordinator.get(&"arrears_total", 0)), + ] + + "運営状態 %s  前日損益 %s" % [state_text, day_profit_text] + ) + + +func _refresh_selected_entity() -> void: + if _selected_entity_id.is_empty(): + selected_entity_icon.texture = null + selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設" + selected_detail_label.text = ( + "地下のマスにカーソルを合わせると、費用と所要時間を確認できます。" + if _excavation_mode else "地図上の施設をクリックすると、営業状態を確認できます。" + ) + return + if _selected_entity_kind == &"construction": + _refresh_selected_construction() + return + if _selected_entity_kind == &"excavation": + _refresh_selected_excavation() + return + var facility_service: Variant = _service(&"facility_service") + if facility_service == null or not facility_service.has_method(&"get_instance"): + selected_detail_label.text = "施設サービスを利用できません。" + return + var facility: Variant = facility_service.call(&"get_instance", _selected_entity_id) + if facility == null: + var construction_facility := _construction_facility(_selected_entity_id) + if construction_facility.is_empty(): + _selected_entity_id = &"" + _selected_entity_kind = &"" + world_preview.selected_entity_id = &"" + selected_heading_label.text = "選択中の施設" + selected_detail_label.text = "選択していた施設は現在の世界に存在しません。" + selected_entity_icon.texture = null + return + var construction_definition_id := StringName(construction_facility.get(&"definition_id", &"")) + selected_heading_label.text = String(construction_definition_id) + selected_entity_icon.texture = visual_theme.facility_icon(construction_definition_id) if visual_theme != null else null + selected_detail_label.text = "施設データを運営サービスへ同期しています。" + return + var definition: Variant = _facility_definition(facility.definition_id) + selected_entity_icon.texture = visual_theme.facility_icon(facility.definition_id) if visual_theme != null else null + var display_name := String(_definition_value(definition, &"display_name", facility.definition_id)) + var state_index := clampi(int(facility.state), 0, FACILITY_STATE_NAMES.size() - 1) + var reasons := _facility_reasons(facility.state_reasons) + var queue_count: int = facility.queue_entries.size() + var service_count: int = facility.active_services.size() + var staff_count: int = facility.active_staff_ids.size() + var occupied_slots: int = service_count + selected_heading_label.text = display_name + selected_detail_label.text = ( + "状態 %s%s\n" % [FACILITY_STATE_NAMES[state_index], "(%s)" % reasons if not reasons.is_empty() else ""] + + "待機列 %d / %d  サービス %d / %d\n" % [queue_count, int(facility.queue_capacity), occupied_slots, int(facility.capacity)] + + "配置人員 %d / 必要 %d  耐久 %d%%\n" % [staff_count, int(facility.staff_min), int(facility.durability) / 100] + + "基本料金 G %d  所要 %d分\n" % [int(facility.base_price), int(facility.base_service_minutes)] + + "座標 (%d, %d) %s" % [facility.origin.x, facility.origin.y, "FRONT" if facility.origin.z == 0 else "BACK"] + ) + + +func _refresh_selected_construction() -> void: + var construction: Variant = _service(&"construction_service") + if construction == null or not construction.has_method(&"get_job"): + selected_detail_label.text = "建設サービスを利用できません。" + return + var job: Variant = construction.call(&"get_job", _selected_entity_id) + if job == null: + _selected_entity_id = &"" + _selected_entity_kind = &"" + world_preview.selected_entity_id = &"" + selected_heading_label.text = "選択中の施設" + selected_detail_label.text = "選択していた工事は現在の世界に存在しません。" + selected_entity_icon.texture = null + return + var definition: Variant = _facility_definition(job.definition_id) + selected_entity_icon.texture = visual_theme.facility_icon(job.definition_id) if visual_theme != null else null + var display_name := String(_definition_value(definition, &"display_name", job.definition_id)) + var state_names := ["計画済み", "建設中", "完成", "中止", "停止中"] + var job_state := clampi(int(job.state), 0, state_names.size() - 1) + selected_heading_label.text = "%s(工事)" % display_name + selected_detail_label.text = ( + "状態 %s\n" % state_names[job_state] + + "進捗 %d%%  予定 %d分\n" % [int(job.progress) / 100, int(job.build_minutes)] + + "座標 (%d, %d) %s\n" % [job.origin.x, job.origin.y, "FRONT" if int(job.layer) == 0 else "BACK"] + + ("停止理由 %s" % String(job.state_reason) if not String(job.state_reason).is_empty() else "建設はシミュレーション時間とともに進みます。") + ) + + +func _select_excavation_job(job_id: StringName) -> void: + if job_id.is_empty(): + return + _selected_entity_id = job_id + _selected_entity_kind = &"excavation" + world_preview.selected_entity_id = &"" + world_preview.clear_hovered_cell() + _refresh_selected_excavation() + + +func _refresh_selected_excavation() -> void: + selected_entity_icon.texture = visual_theme.control_icon(&"excavate") if visual_theme != null else null + var excavation: Variant = _service(&"excavation_service") + if excavation == null or not excavation.has_method(&"get_job"): + selected_detail_label.text = "掘削サービスを利用できません。" + return + var job_value: Variant = excavation.call(&"get_job", _selected_entity_id) + if not job_value is Dictionary or job_value.is_empty(): + _selected_entity_id = &"" + _selected_entity_kind = &"" + selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設" + selected_detail_label.text = "選択していた掘削作業は現在の世界に存在しません。" + return + var job: Dictionary = job_value + var state := StringName(job.get(&"state", &"")) + var state_text := "掘削中" + if state == &"PAUSED": + state_text = "一時停止" + elif state == &"COMPLETED": + state_text = "完了" + var position: Vector2i = job.get(&"position", Vector2i.ZERO) + var cost_value: Variant = job.get(&"cost", {}) + var cost: Dictionary = cost_value if cost_value is Dictionary else {} + var guidance := "両レイヤーが開通しました。" + if state == &"ACTIVE": + guidance = "掘削はシミュレーション時間とともに進みます。" + elif state == &"PAUSED": + guidance = "停止理由 %s" % String(job.get(&"state_reason", "")) + selected_heading_label.text = "地下掘削(%d, %d)" % [position.x, position.y] + selected_detail_label.text = ( + "状態 %s  進捗 %d%%\n" % [state_text, clampi(int(job.get(&"progress", 0)) / 100, 0, 100)] + + "所要 %d分  費用 G %d / 資材 %d\n" % [ + int(job.get(&"build_minutes", 0)), + int(cost.get(&"gold", cost.get("gold", 0))), + int(cost.get(&"materials", cost.get("materials", 0))), + ] + + "対象 FRONT / 表層・BACK / 裏層\n" + + guidance + ) + + +func _preview_failure_message(report: Dictionary) -> String: + var codes_value: Variant = report.get(&"error_codes", []) + if codes_value is Array and not codes_value.is_empty(): + var code := String(codes_value[0]) + return "配置不可: %s" % PREVIEW_REASON_NAMES.get(code, code) + var reasons_value: Variant = report.get(&"reasons", []) + if reasons_value is Array and not reasons_value.is_empty() and reasons_value[0] is Dictionary: + return "配置不可: %s" % String(reasons_value[0].get(&"message", "条件を満たしていません")) + return "このマスには配置できません。" + + +func _excavation_failure_message(report: Dictionary) -> String: + var errors_value: Variant = report.get(&"errors", []) + var reason := "条件を満たしていません" + if errors_value is Array and not errors_value.is_empty(): + var code := StringName(errors_value[0]) + reason = String(EXCAVATION_REASON_NAMES.get(code, String(code))) + if code == &"INSUFFICIENT_RESOURCES": + var missing_value: Variant = report.get(&"missing_resources", {}) + if missing_value is Dictionary and not missing_value.is_empty(): + var missing: Dictionary = missing_value + reason += "(不足: G %d / 資材 %d)" % [ + int(missing.get(&"gold", missing.get("gold", 0))), + int(missing.get(&"materials", missing.get("materials", 0))), + ] + return "掘削不可: %s%s" % [reason, _excavation_cost_and_time(report)] + + +func _excavation_cost_and_time(report: Dictionary) -> String: + var cost_value: Variant = report.get(&"cost", {}) + var cost: Dictionary = cost_value if cost_value is Dictionary else {} + var minutes := int(report.get(&"build_minutes", 0)) + if cost.is_empty() and minutes <= 0: + return "" + return "(費用 G %d / 資材 %d・所要 %d分)" % [ + int(cost.get(&"gold", cost.get("gold", 0))), + int(cost.get(&"materials", cost.get("materials", 0))), + minutes, + ] + + +func _excavation_gate_message() -> String: + var progression: Variant = _service(&"progression_service") + if progression == null: + return "世界樹ランクを確認できません" + var current_rank := int(_definition_value(progression, &"current_rank", 1)) + if current_rank < 2: + return "世界樹ランク2で地下掘削が解禁されます(現在ランク%d)" % current_rank + if _runtime == null or not _runtime.has_method(&"get_operations_report"): + return "運営状態を確認できません" + var report_value: Variant = _runtime.call(&"get_operations_report") + if not report_value is Dictionary: + return "運営状態を確認できません" + var report: Dictionary = report_value + if not bool(report.get(&"enabled", false)): + return "本番運営が始まるまで掘削できません" + var coordinator_value: Variant = report.get(&"coordinator", {}) + var coordinator: Dictionary = coordinator_value if coordinator_value is Dictionary else {} + if StringName(coordinator.get(&"terminal_state", &"RUNNING")) == &"BANKRUPT": + return "資金破綻により新しい掘削を開始できません" + if bool(coordinator.get(&"external_quarantined", false)): + return "運営データの復旧が必要なため掘削を開始できません" + var arrears := int(coordinator.get(&"arrears_total", 0)) + if arrears > 0: + return "未払給与 G %dを清算するまで掘削できません" % arrears + return "" + + +func _excavation_command_failure_message(result: Variant) -> String: + var details: Variant = result.details + if details is Dictionary: + var report_value: Variant = details.get(&"report", null) + if report_value is Dictionary: + return _excavation_failure_message(report_value) + var reason := StringName(details.get(&"reason", &"")) + match reason: + &"ARREARS_REMAIN": + return "掘削不可: 未払給与 G %dを清算してください" % int(details.get(&"arrears_total", 0)) + &"BANKRUPT": + return "掘削不可: 資金破綻により新しい工事は開始できません" + &"EXTERNAL_ROLLBACK_FAILED": + return "掘削不可: 運営データの復旧が必要です" + if details.has(&"required_rank"): + return "掘削不可: 世界樹ランク%dが必要です(現在ランク%d)" % [ + int(details.get(&"required_rank", 2)), + int(details.get(&"current_rank", 1)), + ] + if int(result.code) == int(CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY): + return "この掘削指示はすでに受理されています。" + return _command_failure_message(result) + + +func _command_failure_message(result: Variant) -> String: + var message := String(result.message) + var details: Variant = result.details + if details is Dictionary: + var report_value: Variant = details.get(&"placement_report", null) + if report_value is Dictionary: + return _preview_failure_message(report_value) + return "操作に失敗しました: %s" % (message if not message.is_empty() else "不明なエラー") + + +func _save_manual() -> void: + if _runtime == null or not _runtime.has_method(&"save_to_path"): + _set_status("保存サービスへ接続できません。", false) + return + var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://saves")) + if directory_error != OK and directory_error != ERR_ALREADY_EXISTS: + _set_status("保存フォルダを作成できませんでした。", false) + return + var result: Variant = _runtime.call(&"save_to_path", MANUAL_SAVE_PATH, {&"display_name": "手動保存"}) + if result is Dictionary and bool(result.get(&"ok", false)): + _set_status("手動セーブを保存しました。", true) + else: + _set_status("保存に失敗しました: %s" % _result_message(result), false) + + +func _load_manual() -> void: + if _runtime == null or ( + not _runtime.has_method(&"load_with_recovery") + and not _runtime.has_method(&"load_from_path") + ): + _set_status("読込サービスへ接続できません。", false) + return + var result: Variant = ( + _runtime.call(&"load_with_recovery", MANUAL_SAVE_PATH) + if _runtime.has_method(&"load_with_recovery") + else _runtime.call(&"load_from_path", MANUAL_SAVE_PATH) + ) + if result is Dictionary and bool(result.get(&"ok", false)): + _pending_build_commands.clear() + _pending_excavation_commands.clear() + _pending_hire_commands.clear() + _synchronize_ui_command_state() + _selected_entity_id = &"" + _selected_entity_kind = &"" + world_preview.selected_entity_id = &"" + world_preview.queue_redraw() + _refresh_dashboard() + _update_pause_and_speed() + _set_status("手動セーブを読み込みました。", true) + else: + _set_status("読み込みに失敗しました: %s" % _result_message(result), false) + + +func _result_message(result: Variant) -> String: + if result is Dictionary: + var message := String(result.get(&"message", result.get(&"code", "保存データがありません"))) + return message if not message.is_empty() else "保存データがありません" + return "サービスから応答がありません" + + +func _synchronize_ui_command_state() -> void: + if _runtime == null: + return + var queue: Variant = _runtime.get(&"command_queue") + if queue == null: + return + if queue.has_method(&"get_accepted_command_metadata"): + var metadata_value: Variant = queue.call(&"get_accepted_command_metadata") + if metadata_value is Array: + for raw_metadata: Variant in metadata_value: + if not raw_metadata is Dictionary: + continue + _advance_ui_sequence_from_key(String(raw_metadata.get("idempotency_key", ""))) + if not queue.has_method(&"create_snapshot"): + return + var snapshot_value: Variant = queue.call(&"create_snapshot") + if not snapshot_value is Dictionary: + return + var pending_value: Variant = snapshot_value.get("pending", []) + if not pending_value is Array: + return + for raw_pending: Variant in pending_value: + if not raw_pending is Dictionary: + continue + var command_id := int(raw_pending.get("command_id", 0)) + var command_type := StringName(raw_pending.get("command_type", &"")) + var payload_value: Variant = raw_pending.get("payload", {}) + var payload: Dictionary = payload_value if payload_value is Dictionary else {} + if command_id <= 0: + continue + match command_type: + &"build_facility": + var definition_id := StringName(payload.get(&"definition_id", payload.get("definition_id", &""))) + if not definition_id.is_empty(): + _pending_build_commands[command_id] = definition_id + &"start_excavation": + var cell_value: Variant = payload.get(&"cell", payload.get("cell", null)) + if cell_value is Vector3i: + _pending_excavation_commands[command_id] = Vector2i(cell_value.x, cell_value.y) + &"hire_staff_candidate": + var candidate_id := StringName(payload.get(&"candidate_id", payload.get("candidate_id", &""))) + if not candidate_id.is_empty(): + _pending_hire_commands[command_id] = candidate_id + + +func _advance_ui_sequence_from_key(key: String) -> void: + if key.is_empty(): + return + var parts := key.split(".") + if parts.is_empty(): + return + var suffix := String(parts[parts.size() - 1]) + if not suffix.is_valid_int(): + return + var next_sequence := int(suffix) + 1 + if key.begins_with("ui.build."): + _build_sequence = maxi(_build_sequence, next_sequence) + elif key.begins_with("ui.excavation."): + _excavation_sequence = maxi(_excavation_sequence, next_sequence) + elif key.begins_with("ui.hire."): + _hire_sequence = maxi(_hire_sequence, next_sequence) + + +func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: + _refresh_dashboard() + _update_pause_and_speed() + + +func _on_command_completed(result: Variant) -> void: + if result == null: + return + if _pending_hire_commands.has(int(result.command_id)): + var candidate_id := StringName(_pending_hire_commands[int(result.command_id)]) + _pending_hire_commands.erase(int(result.command_id)) + var display_name := String(candidate_id) + for summary: Dictionary in _staff_candidate_summaries(): + if StringName(summary.get(&"candidate_id", &"")) == candidate_id: + display_name = String(summary.get(&"display_name", candidate_id)) + break + if bool(result.call(&"is_success")): + _set_status("%sを雇用しました。" % display_name, true) + else: + _set_status(_hire_failure_message(result), false) + _refresh_dashboard() + return + if _pending_excavation_commands.has(int(result.command_id)): + var position: Vector2i = _pending_excavation_commands[int(result.command_id)] + _pending_excavation_commands.erase(int(result.command_id)) + if bool(result.call(&"is_success")): + _select_excavation_job(StringName(result.details.get(&"job_id", &""))) + _set_status("掘削を開始しました(%d, %d / FRONT・BACK)。" % [position.x, position.y], true) + else: + _set_status(_excavation_command_failure_message(result), false) + return + if not _pending_build_commands.has(int(result.command_id)): + return + var definition_id := StringName(_pending_build_commands[int(result.command_id)]) + _pending_build_commands.erase(int(result.command_id)) + if bool(result.call(&"is_success")): + var definition: Variant = _facility_definition(definition_id) + _set_status("%sの建設を開始しました。" % String(_definition_value(definition, &"display_name", definition_id)), true) + else: + _set_status(_command_failure_message(result), false) + + +func _set_layer(value: int) -> void: + var normalized := clampi(value, 0, 1) + if world_preview != null: + world_preview.layer_mode = normalized + GameState.active_layer = normalized + if front_button != null: + front_button.set_pressed_no_signal(normalized == 0) + if back_button != null: + back_button.set_pressed_no_signal(normalized == 1) + EventBus.layer_changed.emit(normalized) + + +func _toggle_pause() -> void: + var event_service: Variant = _service(&"event_service") + if event_service != null and event_service.has_method(&"get_foreground_instance"): + var foreground: Variant = event_service.call(&"get_foreground_instance") + if foreground != null and int(foreground.state) == 2: + GameClock.set_paused(true) + _set_status("先に要対応イベントの選択肢を決めてください。", false) + _update_pause_and_speed() + return + if _runtime != null \ + and _runtime.has_method(&"get_operations_report") \ + and _runtime.has_method(&"acknowledge_operations_pause"): + var report_value: Variant = _runtime.call(&"get_operations_report") + if report_value is Dictionary: + var report: Dictionary = report_value + var coordinator_value: Variant = report.get(&"coordinator", {}) + var coordinator: Dictionary = ( + coordinator_value if coordinator_value is Dictionary else {} + ) + if StringName(coordinator.get(&"terminal_state", &"RUNNING")) == &"BANKRUPT": + _set_status("資金破綻により運営は終了しています。", false) + return + if bool(report.get(&"enabled", false)) \ + and int(report.get(&"deferred_runtime_minute", -1)) >= 0: + var retry_value: Variant = _runtime.call(&"acknowledge_operations_pause") + var retry_ok := retry_value is Dictionary and bool(retry_value.get(&"ok", false)) + _set_status( + "運営処理を再開しました。" if retry_ok \ + else "運営処理を再開できません: %s" % _result_message(retry_value), + retry_ok + ) + _refresh_dashboard() + _update_pause_and_speed() + return + GameClock.toggle_pause() + _update_pause_and_speed() + + +func _set_speed(value: int) -> void: + GameClock.set_speed(value) + _update_pause_and_speed() + + +func _update_pause_and_speed() -> void: + var operations_deferred := false + var operations_terminal := false + var event_waiting := false + if _runtime != null and _runtime.has_method(&"get_operations_report"): + var report_value: Variant = _runtime.call(&"get_operations_report") + if report_value is Dictionary: + operations_deferred = bool(report_value.get(&"enabled", false)) \ + and int(report_value.get(&"deferred_runtime_minute", -1)) >= 0 + var coordinator_value: Variant = report_value.get(&"coordinator", {}) + if coordinator_value is Dictionary: + operations_terminal = StringName( + coordinator_value.get(&"terminal_state", &"RUNNING") + ) == &"BANKRUPT" + var event_service: Variant = _service(&"event_service") + if event_service != null and event_service.has_method(&"get_foreground_instance"): + var foreground: Variant = event_service.call(&"get_foreground_instance") + event_waiting = foreground != null and int(foreground.state) == 2 + if phase_label != null: + if operations_terminal: + phase_label.text = "運営終了" + elif operations_deferred: + phase_label.text = "要確認" + elif event_waiting: + phase_label.text = "選択待ち" + else: + phase_label.text = "停止中" if GameClock.paused else _phase_name(GameClock.get_phase()) + phase_label.add_theme_color_override("font_color", Color("e88972") if GameClock.paused else Color("f0c574")) + if pause_button != null: + pause_button.text = ( + "破綻" + if operations_terminal else ( + "運営再試行" + if operations_deferred else ( + "イベント待ち" + if event_waiting else ("再開" if GameClock.paused else "停止") + ) + ) + ) + pause_button.disabled = operations_terminal or event_waiting + for raw_speed: Variant in speed_buttons: + var button := speed_buttons[raw_speed] as Button + button.set_pressed_no_signal(not GameClock.paused and int(raw_speed) == GameClock.speed) + + +func _on_time_changed(current_day: int, hour: int, minute: int) -> void: + time_label.text = "%d日目 %02d:%02d" % [current_day, hour, minute] + _update_pause_and_speed() + + +func _on_phase_changed(phase: GameClock.Phase) -> void: + if not GameClock.paused: + phase_label.text = _phase_name(phase) + + +func _phase_name(phase: GameClock.Phase) -> String: + match phase: + GameClock.Phase.MORNING: + return "朝" + GameClock.Phase.DAY: + return "昼" + GameClock.Phase.EVENING: + return "夕方" + _: + return "夜" + + +func _on_resource_changed(resource_name: StringName, value: int) -> void: + if not resource_labels.has(resource_name): + return + var titles := { + &"gold": "所持金", + &"mana": "マナ", + &"materials": "資材", + &"faith": "信仰", + } + (resource_labels[resource_name] as Label).text = "%s %d" % [titles[resource_name], value] + + +func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button: + var button := Button.new() + button.text = text_value + button.custom_minimum_size = Vector2(minimum_width, 28) + button.add_theme_font_size_override("font_size", 12) + button.pressed.connect(callback) + _apply_button_style(button) + return button + + +func _hud_value_label(text_value: String, minimum_width: float) -> Label: + var label := Label.new() + label.text = text_value + label.custom_minimum_size.x = minimum_width + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 12) + label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0"))) + return label + + +func _icon_rect(icon_texture: Texture2D, side: float) -> TextureRect: + var icon := TextureRect.new() + icon.texture = icon_texture + icon.custom_minimum_size = Vector2(side, side) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + return icon + + +func _apply_button_icon(button: Button, icon_texture: Texture2D, maximum_width: int) -> void: + if icon_texture == null: + return + button.icon = icon_texture + button.add_theme_constant_override("icon_max_width", maximum_width) + button.expand_icon = true + + +func _apply_button_style(button: Button) -> void: + button.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff0c8"))) + button.add_theme_color_override("font_hover_color", Color("fff8e5")) + button.add_theme_color_override("font_pressed_color", Color("fff8e5")) + button.add_theme_color_override("font_disabled_color", Color("a98e68")) + button.add_theme_stylebox_override("normal", _themed_panel_style(&"button_normal", _visual_color(&"button_normal", Color("75451f")), _visual_color(&"border_gold", Color("b77b37")), 7, 7)) + button.add_theme_stylebox_override("hover", _themed_panel_style(&"button_hover", _visual_color(&"button_hover", Color("8d5628")), _visual_color(&"border_gold", Color("e4b45c")), 7, 7)) + button.add_theme_stylebox_override("pressed", _themed_panel_style(&"button_pressed", _visual_color(&"button_pressed", Color("496528")), _visual_color(&"border_gold", Color("d8bd61")), 7, 7)) + button.add_theme_stylebox_override("focus", _themed_panel_style(&"button_pressed", _visual_color(&"button_pressed", Color("496528")), _visual_color(&"border_gold", Color("f2d97d")), 7, 7)) + button.add_theme_stylebox_override("disabled", _themed_panel_style(&"button_disabled", _visual_color(&"button_disabled", Color("574334")), Color("7b6955"), 7, 7)) + + +func _section_heading(text_value: String) -> Label: + var heading := Label.new() + heading.text = text_value + heading.add_theme_font_size_override("font_size", 14) + heading.add_theme_color_override("font_color", _visual_color(&"text_dark", Color("55371e"))) + return heading + + +func _panel_style(color: Color, border_color: Color, radius := 8, margin := 12) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.border_color = border_color + style.set_border_width_all(1) + style.set_corner_radius_all(mini(radius, 1)) + style.set_content_margin_all(margin) + style.shadow_size = 0 + return style + + +func _themed_panel_style(_skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox: + # Small flat frames keep attention on the animated cross-section. + return _panel_style(color, border_color, mini(radius, 1), mini(margin, 6)) + + +func _set_status(message: String, success: bool, quiet := false) -> void: + if build_status_label == null: + return + build_status_label.text = message + build_status_label.tooltip_text = message + if quiet: + build_status_label.add_theme_color_override("font_color", Color("c7d3a8") if success else Color("ffb498")) + else: + build_status_label.add_theme_color_override("font_color", Color("d8e8ad") if success else Color("ffb498")) + + +func _visual_color(property_name: StringName, fallback: Color) -> Color: + var interface_colors := { + &"window_background": Color("17261f"), + &"dark_panel": Color("24392e"), + &"dark_panel_alt": Color("20342a"), + &"light_panel": Color("e9e6cd"), + &"border_gold": Color("778267"), + &"text_light": Color("eae7cc"), + &"text_dark": Color("344838"), + &"button_normal": Color("354b39"), + &"button_hover": Color("4c6246"), + &"button_pressed": Color("79623c"), + &"button_disabled": Color("354135"), + } + if interface_colors.has(property_name): + return interface_colors[property_name] + if visual_theme == null: + return fallback + var value: Variant = visual_theme.get(property_name) + return value if value is Color else fallback + + +func _service(property_name: StringName) -> Variant: + if _runtime == null or not _runtime is Object: + return null + for property: Dictionary in _runtime.get_property_list(): + if StringName(property.get(&"name", &"")) == property_name: + return _runtime.get(property_name) + return null + + +func _call_array(service: Variant, method_name: StringName) -> Array: + if service == null or not service.has_method(method_name): + return [] + var result: Variant = service.call(method_name) + return result if result is Array else [] + + +func _facility_definition(definition_id: StringName) -> Variant: + var registry: Variant = _service(&"definition_registry") + if registry == null or not registry.has_method(&"get_facility"): + return null + return registry.call(&"get_facility", definition_id) + + +func _construction_facility(instance_id: StringName) -> Dictionary: + var construction: Variant = _service(&"construction_service") + if construction == null or not construction.has_method(&"get_facility"): + return {} + var result: Variant = construction.call(&"get_facility", instance_id) + return result if result is Dictionary else {} + + +func _facility_reasons(reasons_value: Variant) -> String: + if not reasons_value is Array: + return "" + var names: Array[String] = [] + for reason_value: Variant in reasons_value: + var reason := StringName(reason_value) + names.append(String(FACILITY_REASON_NAMES.get(reason, reason))) + return "・".join(names) + + +func _definition_value(source: Variant, property_name: StringName, fallback: Variant) -> Variant: + if source is Dictionary: + return source.get(property_name, source.get(String(property_name), fallback)) + if source == null or not source is Object: + return fallback + for property: Dictionary in source.get_property_list(): + if StringName(property.get(&"name", &"")) == property_name: + return source.get(property_name) + return fallback diff --git a/scenes/main/main.gd.uid b/scenes/main/main.gd.uid new file mode 100644 index 0000000..2f6badf --- /dev/null +++ b/scenes/main/main.gd.uid @@ -0,0 +1 @@ +uid://ytnpx6usfhfy diff --git a/scenes/main/main.tscn b/scenes/main/main.tscn new file mode 100644 index 0000000..595367d --- /dev/null +++ b/scenes/main/main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/main/main.gd" id="1_main"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_main") diff --git a/scenes/main/world_preview.gd b/scenes/main/world_preview.gd new file mode 100644 index 0000000..4cddb2f --- /dev/null +++ b/scenes/main/world_preview.gd @@ -0,0 +1,1004 @@ +class_name WorldPreview +extends Control + +signal facility_selected(instance_id: StringName) +signal construction_selected(job_id: StringName) +signal cell_clicked(cell: Vector3i) +signal hovered_cell_changed(cell: Variant) +signal build_preview_changed(report: Dictionary) +signal excavation_preview_changed(report: Dictionary) + +enum LayerMode { + FRONT, + BACK, +} + +const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd") +const MAP_PADDING := Vector2(76.0, 70.0) +const INVALID_PREVIEW := {&"can_build": false, &"error_codes": [], &"reasons": [], &"cells": []} +const INVALID_EXCAVATION_PREVIEW := { + &"ok": false, + &"cells": [], + &"cost": {}, + &"build_minutes": 0, + &"errors": [&"MISSING_DEPENDENCY"], +} + +var layer_mode := LayerMode.FRONT: + set(value): + layer_mode = clampi(value, LayerMode.FRONT, LayerMode.BACK) as LayerMode + if _hovered_cell != null: + _hovered_cell = Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode) + queue_redraw() + _update_build_preview() + _update_excavation_preview() + +var selected_entity_id: StringName = &"": + set(value): + selected_entity_id = value + queue_redraw() + +var build_definition: Variant = null +var build_rotation := 0 +var excavation_mode := false +var visual_theme: VisualThemeProfile = null + +var zoom_level := 1.0 +var pan_offset := Vector2.ZERO +var show_grid := false +var _animation_time := 0.0 +var _animation_frame := -1 +var _actor_tracks: Dictionary = {} +var _dragging := false + +var _runtime: Variant = null +var _hovered_cell: Variant = null +var _last_preview_report: Dictionary = INVALID_PREVIEW.duplicate(true) +var _last_excavation_preview_report: Dictionary = INVALID_EXCAVATION_PREVIEW.duplicate(true) + + +func _ready() -> void: + clip_contents = true + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + focus_mode = Control.FOCUS_CLICK + mouse_filter = Control.MOUSE_FILTER_STOP + resized.connect(queue_redraw) + mouse_exited.connect(_on_mouse_exited) + if _runtime == null: + _runtime = get_node_or_null("/root/SimulationRuntime") + _connect_runtime() + queue_redraw() + + +func set_runtime(value: Variant) -> void: + _disconnect_runtime() + _runtime = value + _actor_tracks.clear() + _connect_runtime() + queue_redraw() + _update_build_preview() + _update_excavation_preview() + + +func set_visual_theme(profile: VisualThemeProfile) -> void: + visual_theme = profile + queue_redraw() + + +func set_build_definition(definition: Variant, rotation := 0) -> void: + if definition != null and excavation_mode: + set_excavation_mode(false) + build_definition = definition + build_rotation = _normalized_rotation(rotation) + _update_build_preview() + queue_redraw() + + +func clear_build_definition() -> void: + build_definition = null + build_rotation = 0 + _last_preview_report = INVALID_PREVIEW.duplicate(true) + build_preview_changed.emit(_last_preview_report.duplicate(true)) + queue_redraw() + + +func set_build_rotation(value: int) -> void: + build_rotation = _normalized_rotation(value) + _update_build_preview() + queue_redraw() + + +func set_excavation_mode(enabled: bool) -> void: + if excavation_mode == enabled: + _update_excavation_preview() + return + excavation_mode = enabled + if excavation_mode and build_definition != null: + build_definition = null + build_rotation = 0 + _last_preview_report = INVALID_PREVIEW.duplicate(true) + build_preview_changed.emit(_last_preview_report.duplicate(true)) + _update_excavation_preview() + queue_redraw() + + +func get_excavation_preview_report(cell: Variant = null) -> Dictionary: + var target: Variant = _hovered_cell if cell == null else cell + if not target is Vector3i: + return INVALID_EXCAVATION_PREVIEW.duplicate(true) + var excavation: Variant = _service(&"excavation_service") + if excavation != null and excavation.has_method(&"validate_excavation"): + var report: Variant = excavation.call( + &"validate_excavation", + Vector3i(target.x, target.y, layer_mode) + ) + if report is Dictionary: + return report.duplicate(true) + return INVALID_EXCAVATION_PREVIEW.duplicate(true) + + +func get_hovered_cell() -> Variant: + return _hovered_cell + + +func clear_hovered_cell() -> void: + _set_hovered_cell(null) + + +func get_build_preview_report(cell: Variant = null) -> Dictionary: + var target: Variant = _hovered_cell if cell == null else cell + if build_definition == null or not target is Vector3i: + return INVALID_PREVIEW.duplicate(true) + var construction: Variant = _service(&"construction_service") + if construction != null and construction.has_method(&"validate_placement"): + var report: Variant = construction.call( + &"validate_placement", + build_definition, + Vector3i(target.x, target.y, layer_mode), + build_rotation + ) + if report != null and report.has_method(&"to_dictionary"): + return report.call(&"to_dictionary") as Dictionary + return { + &"can_build": _fallback_cells_are_available(Vector3i(target.x, target.y, layer_mode)), + &"error_codes": [], + &"reasons": [], + &"cells": _serialize_cells(_fallback_build_cells(Vector3i(target.x, target.y, layer_mode))), + } + + +func get_map_rect() -> Rect2: + var bounds := _world_bounds() + if bounds.is_empty(): + return Rect2() + var dimensions := _cell_dimensions() + var map_size := Vector2( + int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, + int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1 + ) * dimensions + return Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size) + + +func _cell_dimensions() -> Vector2: + var bounds := _world_bounds() + if bounds.is_empty(): + return Vector2(32, 48) + var columns := maxi(int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, 1) + var rows := maxi(int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1, 1) + var available := (size - MAP_PADDING * 2.0).max(Vector2.ONE) + var fit := minf(available.x / (columns * 16.0), available.y / (rows * 24.0)) + # Native 16px units use integer magnification. Large worlds can be viewed + # at overview scale, then enlarged with the same invertible camera mapping. + var pixel := maxf(floorf(fit), 1.0) if fit >= 1.0 else maxf(fit, 0.25) + return (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6)) + + +func set_zoom(value: float, anchor := Vector2(-1, -1)) -> void: + var before := get_map_rect() + var at := size * 0.5 if anchor.x < 0 else anchor + var relative := (at - before.position) / before.size if before.has_area() else Vector2(0.5, 0.5) + zoom_level = clampf(value, 0.5, 4.0) + var after := get_map_rect() + pan_offset += at - (after.position + relative * after.size) + _clamp_pan() + _set_hovered_cell(canvas_to_cell(at)) + queue_redraw() + + +func reset_view() -> void: + zoom_level = 1.0 + pan_offset = Vector2.ZERO + _actor_tracks.clear() + queue_redraw() + + +func _clamp_pan() -> void: + var extent := get_map_rect().size * 0.5 + size * 0.3 + pan_offset = pan_offset.clamp(-extent, extent) + + +func _process(delta: float) -> void: + var clock := get_node_or_null("/root/GameClock") + if clock != null and bool(clock.get("paused")): + return + var speed := float(clock.get("speed")) if clock != null else 1.0 + _animation_time += delta * maxf(speed, 0.0) + var frame := int(_animation_time * 12.0) + if frame != _animation_frame: + _animation_frame = frame + queue_redraw() + + +func _unhandled_key_input(event: InputEvent) -> void: + if not event is InputEventKey or not event.pressed or event.echo: + return + if event.keycode == KEY_HOME: + reset_view() + get_viewport().set_input_as_handled() + elif event.keycode == KEY_G: + show_grid = not show_grid + queue_redraw() + get_viewport().set_input_as_handled() + + +func cell_to_canvas(cell: Vector3i) -> Vector2: + var bounds := _world_bounds() + if bounds.is_empty(): + return Vector2.ZERO + return get_map_rect().position + Vector2( + float(cell.x - int(bounds[&"min_x"])) + 0.5, + float(int(bounds[&"max_y"]) - cell.y) + 0.5 + ) * _cell_dimensions() + + +func canvas_to_cell(canvas_position: Vector2) -> Variant: + var bounds := _world_bounds() + var map_rect := get_map_rect() + if bounds.is_empty() or not map_rect.has_area() or not map_rect.has_point(canvas_position): + return null + var local_position := (canvas_position - map_rect.position) / _cell_dimensions() + var cell := Vector3i(int(bounds[&"min_x"]) + floori(local_position.x), int(bounds[&"max_y"]) - floori(local_position.y), layer_mode) + var world: Variant = _service(&"world_service") + if world != null and world.has_method(&"is_in_bounds") and not bool(world.call(&"is_in_bounds", cell)): + return null + return cell + + +func get_cell_rect(cell: Vector3i) -> Rect2: + if _world_bounds().is_empty(): + return Rect2() + var dimensions := _cell_dimensions() + return Rect2(cell_to_canvas(cell) - dimensions * 0.5, dimensions) + + +func facility_at_canvas(canvas_position: Vector2) -> StringName: + var cell: Variant = canvas_to_cell(canvas_position) + if not cell is Vector3i: + return &"" + var world: Variant = _service(&"world_service") + if world == null or not world.has_method(&"get_occupant"): + return &"" + var occupant_id := StringName(world.call(&"get_occupant", cell)) + if occupant_id.is_empty(): + return &"" + var kind := &"" + if world.has_method(&"get_occupant_kind"): + kind = StringName(world.call(&"get_occupant_kind", cell)) + return occupant_id if kind == &"facility" else &"" + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_MIDDLE: + _dragging = event.pressed + mouse_default_cursor_shape = Control.CURSOR_DRAG if _dragging else Control.CURSOR_ARROW + accept_event() + return + if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]: + set_zoom(zoom_level + (0.5 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -0.5), event.position) + accept_event() + return + if event is InputEventMouseMotion and _dragging: + pan_offset += event.relative + _clamp_pan() + queue_redraw() + accept_event() + return + if event is InputEventMouseMotion: + _set_hovered_cell(canvas_to_cell(event.position)) + return + if not event is InputEventMouseButton or not event.pressed or event.button_index != MOUSE_BUTTON_LEFT: + return + var cell: Variant = canvas_to_cell(event.position) + if not cell is Vector3i: + return + if build_definition != null or excavation_mode: + cell_clicked.emit(cell) + accept_event() + return + var world: Variant = _service(&"world_service") + if world != null and world.has_method(&"get_occupant"): + var occupant_id := StringName(world.call(&"get_occupant", cell)) + var kind := &"" + if world.has_method(&"get_occupant_kind"): + kind = StringName(world.call(&"get_occupant_kind", cell)) + if not occupant_id.is_empty() and kind == &"facility": + selected_entity_id = occupant_id + facility_selected.emit(occupant_id) + accept_event() + return + if not occupant_id.is_empty() and kind == &"construction": + selected_entity_id = occupant_id + construction_selected.emit(occupant_id) + accept_event() + return + cell_clicked.emit(cell) + accept_event() + + +func _draw() -> void: + _draw_background() + var bounds := _world_bounds() + if bounds.is_empty(): + _draw_center_message("世界データを待っています") + return + var map_rect := get_map_rect() + if map_rect.size == Vector2.ZERO: + return + _draw_world_cells(bounds, map_rect) + if show_grid: + _draw_congestion_overlay(bounds) + _draw_world_entity_icons() + _draw_construction_labels() + _draw_facility_labels() + _draw_queue_badges() + _draw_actors() + _draw_excavation_jobs() + _draw_build_preview() + _draw_excavation_preview() + _draw_hover() + _draw_map_caption(map_rect) + + +func _draw_background() -> void: + var night := false + var clock := get_node_or_null("/root/GameClock") + if clock != null: + var hour := int(clock.call("get_hour")) + night = hour >= 18 or hour < 6 + var sky := Color("b5cbb5") if not night else Color("273c49") + if layer_mode == LayerMode.BACK: + sky = Color("526d68") if not night else Color("233d40") + draw_rect(Rect2(Vector2.ZERO, size), sky) + # Pixel silhouettes make the world frame distinct from the usable floors. + var horizon := size.y * 0.65 + for band in range(3): + var color := [Color("8cae98"), Color("749981"), Color("567e68")][band] as Color + if night: + color = color.darkened(0.48) + for x in range(-24, int(size.x)+64, 48): + var crown_y := horizon + band*46 - posmod(x*17+band*29, 83) + draw_rect(Rect2(x, crown_y, 48, size.y-crown_y), color) + draw_rect(Rect2(x+8, crown_y-12, 32, 12), color) + if night: + for i in range(28): + var at := Vector2(posmod(i*137+31, maxi(int(size.x),1)), posmod(i*47+13,maxi(int(horizon),1))) + draw_rect(Rect2(at,Vector2(2,2)),Color("c6d3b6")) + else: + for i in range(5): + var x := float(posmod(i*263+41, maxi(int(size.x),1))) + var y := 32.0+float(i%3)*27.0 + draw_rect(Rect2(x,y,86,12),Color("d5dec4")) + draw_rect(Rect2(x+17,y-7,46,7),Color("d5dec4")) + + +func _draw_world_cells(bounds: Dictionary, map_rect: Rect2) -> void: + var world: Variant = _service(&"world_service") + if world == null: + return + var p := _cell_dimensions().x / 16.0 + var ground := get_cell_rect(Vector3i(0,0,layer_mode)).end.y + var trunk := Rect2(map_rect.position-Vector2(9*p,22*p), map_rect.size+Vector2(18*p,24*p)) + draw_rect(trunk, Color("574d39")) + draw_rect(trunk.grow(-2*p), Color("776445")) + for x in range(int(trunk.position.x+4*p),int(trunk.end.x),maxi(int(7*p),1)): + var notch := float(posmod(x*11,17))*p + draw_rect(Rect2(x,trunk.position.y+notch,2*p,trunk.size.y-notch),Color("8a7350")) + # Branches, crown and roots belong to the scenery, never facility slots. + for side in [-1,1]: + var edge := trunk.position.x if side < 0 else trunk.end.x + for branch in range(3): + var y := trunk.position.y+(branch*24+22)*p + var x := edge-(18+branch*4)*p if side<0 else edge + draw_rect(Rect2(x,y,(18+branch*4)*p,5*p),Color("69583d")) + _draw_leaf_cluster(Vector2(x+(2 if side<0 else 14)*p,y-5*p),p,branch) + var root_x := edge-28*p if side<0 else edge-2*p + draw_rect(Rect2(root_x,ground-3*p,30*p,6*p),Color("65583e")) + draw_rect(Rect2(root_x+(10*p if side<0 else 0),ground-7*p,20*p,5*p),Color("776445")) + draw_rect(Rect2(trunk.position.x-12*p,trunk.position.y-14*p,trunk.size.x+24*p,17*p),Color("385d45")) + for i in range(17): + var arch := sin(float(i)/16.0*PI)*17.0 + _draw_leaf_cluster(Vector2(trunk.position.x+float(i)*trunk.size.x/16.0,trunk.position.y-4*p-arch*p-float(i%3)*3*p),p,i) + # Unbuilt space stays dark wood; completed rooms cut into it below. + for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"])+1): + for x in range(int(bounds[&"min_x"]),int(bounds[&"max_x"])+1): + var cell := Vector3i(x,y,layer_mode) + var rect := get_cell_rect(cell) + if not rect.intersects(Rect2(Vector2.ZERO,size)): + continue + var excavated := bool(world.call(&"is_excavated",cell)) if world.has_method(&"is_excavated") else true + if not excavated: + draw_rect(rect,Color("47463e")) + if posmod(x+y,3)==0: + draw_rect(Rect2(rect.position+Vector2(5,11)*p,Vector2(5,2)*p),Color("5a594b")) + elif bool(world.call(&"is_walkable",cell)): + draw_rect(rect.grow(-p), Color("aaa083") if layer_mode==LayerMode.FRONT else Color("829a8d")) + draw_rect(Rect2(rect.position.x,rect.end.y-4*p,rect.size.x,3*p),Color("b69465")) + draw_rect(Rect2(rect.position.x,rect.end.y-p,rect.size.x,p),Color("514839")) + if posmod(x,4)==0: + PixelArt.window(self,rect.position+Vector2(5,4)*p,p) + var kind := StringName(world.call(&"get_occupant_kind",cell)) + if kind==&"construction": + draw_rect(rect.grow(-p),Color("8d7951")) + _draw_hatch(rect.grow(-p),Color("e1bd6c"),rect.size.x) + if show_grid or build_definition != null or excavation_mode: + draw_rect(rect,Color("dfd4ad",0.25),false,1.0) + var label_at := Vector2(map_rect.position.x-32*p, get_cell_rect(Vector3i(0,y,layer_mode)).get_center().y) + var floor_name := "%dF" % (y+1) if y>=0 else "B%d" % -y + draw_rect(Rect2(label_at-Vector2(2,8)*p,Vector2(22,12)*p),Color("2f493f")) + draw_string(ThemeDB.fallback_font,label_at+Vector2(0,1)*p,floor_name,HORIZONTAL_ALIGNMENT_CENTER,18*p,clampi(int(7*p),10,18),Color("e4d9b9")) + # A narrow garden frames the entrance without hiding underground floors. + draw_rect(Rect2(map_rect.position.x-35*p,ground,map_rect.size.x+70*p,3*p),Color("58784c")) + var town := visual_theme.pixel_town_tiles if visual_theme != null else null + for side in [-1,1]: + var gx := map_rect.position.x-32*p if side<0 else map_rect.end.x+15*p + PixelArt.tile(self,town,4,Rect2(gx,ground-30*p,16*p,16*p)) + PixelArt.tile(self,town,16,Rect2(gx,ground-14*p,16*p,16*p)) + PixelArt.tile(self,town,2,Rect2(gx-12*p,ground-8*p,12*p,12*p)) + + +func _draw_leaf_cluster(center: Vector2, p: float, variation: int) -> void: + var c := center.round() + var shift := float(variation%3)*2.0 + draw_rect(Rect2(c-Vector2(24,5)*p,Vector2(48,12)*p),Color("385d45")) + draw_rect(Rect2(c-Vector2(20,10)*p,Vector2(40,20)*p),Color("4c7950")) + draw_rect(Rect2(c-Vector2(13+shift,15)*p,Vector2(29,25)*p),Color("4c7950")) + draw_rect(Rect2(c-Vector2(22,5)*p,Vector2(37,10)*p),Color("5e8b57")) + draw_rect(Rect2(c-Vector2(11+shift,13)*p,Vector2(23,7)*p),Color("739957")) + for i in range(9): + var offset := Vector2(posmod(i*11+variation*3,38)-19,posmod(i*7+variation,21)-11)*p + draw_rect(Rect2(c+offset,Vector2(3+i%2,2)*p),Color("95ad69") if i%3==0 else Color("648957")) + + +func _draw_congestion_overlay(bounds: Dictionary) -> void: + var path_service: Variant = _service(&"path_service") + if path_service == null or not path_service.has_method(&"get_cell_traffic"): + return + for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"]) + 1): + for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1): + var cell := Vector3i(x, y, layer_mode) + var traffic_value: Variant = path_service.call(&"get_cell_traffic", cell) + if not traffic_value is Dictionary or traffic_value.is_empty(): + continue + var traffic: Dictionary = traffic_value + var density := int(traffic.get(&"current_density", 0)) + var capacity := maxi(int(traffic.get(&"base_capacity", 1)), 1) + var blocked := bool(traffic.get(&"temporary_block", false)) + if density <= 0 and not blocked: + continue + var ratio := float(density) / float(capacity) + var color := Color("55c96b") + if blocked or ratio > 1.0: + color = Color("e34332") + elif ratio >= 0.65: + color = Color("e3a62f") + var rect := get_cell_rect(cell).grow(-2.0) + var center := rect.get_center() + var route_width := clampf(rect.size.y * 0.16, 2.0, 5.0) + draw_line( + Vector2(rect.position.x + rect.size.x * 0.12, center.y), + Vector2(rect.end.x - rect.size.x * 0.12, center.y), + Color(color, 0.90), + route_width + ) + if blocked: + draw_line(rect.position, rect.end, color, 2.0) + draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), color, 2.0) + + +func _draw_world_entity_icons() -> void: + var construction: Variant = _service(&"construction_service") + if construction == null or not construction.has_method(&"get_facilities"): + return + var p := _cell_dimensions().x / 16.0 + var dungeon := visual_theme.pixel_dungeon_tiles if visual_theme != null else null + var town := visual_theme.pixel_town_tiles if visual_theme != null else null + for value: Variant in construction.call(&"get_facilities"): + if not value is Dictionary or int(value.get(&"layer",0)) != layer_mode: + continue + var facility: Dictionary = value + var cells := _cells_from_serialized(facility.get(&"occupied_cells",[])) + if cells.is_empty(): + continue + var rect := _cell_bounds_rect(cells) + if not rect.intersects(Rect2(Vector2.ZERO,size)): + continue + var id := StringName(facility.get(&"definition_id",&"")) + PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x) + var instance_id := StringName(facility.get(&"instance_id",&"")) + # Thin status lamp conveys operational state without repainting a room. + draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id)) + if instance_id==selected_entity_id: + draw_rect(rect.grow(-p),Color("f5d582"),false,2*p) + + +func _cell_bounds_rect(cells: Array[Vector3i]) -> Rect2: + if cells.is_empty(): + return Rect2() + var result := get_cell_rect(cells[0]) + for index: int in range(1, cells.size()): + result = result.merge(get_cell_rect(cells[index])) + return result + + +func _draw_queue_badges() -> void: + var service: Variant = _service(&"facility_service") + if service==null or not service.has_method(&"get_instances"): + return + for instance: Variant in service.call(&"get_instances"): + if instance==null or instance.origin.z!=layer_mode or instance.queue_entries.is_empty(): + continue + var count: int = instance.queue_entries.size() + var rect := get_cell_rect(instance.origin) + var at := Vector2(rect.position.x+3,rect.position.y+3) + draw_rect(Rect2(at,Vector2(30,15)),Color("76593f")) + draw_rect(Rect2(at+Vector2.ONE,Vector2(28,13)),Color("bb974e")) + draw_string(ThemeDB.fallback_font,at+Vector2(3,11),"待%d" % count,HORIZONTAL_ALIGNMENT_LEFT,-1,10,Color("fff2ce")) + + +func _draw_hatch(rect: Rect2, color: Color, cell_size: float) -> void: + if cell_size < 8.0: + return + var offset := -rect.size.y + while offset < rect.size.x: + var start := Vector2(rect.position.x + maxf(offset, 0.0), rect.end.y - maxf(-offset, 0.0)) + var finish := Vector2(rect.position.x + minf(offset + rect.size.y, rect.size.x), rect.end.y - minf(rect.size.y, rect.size.x - offset)) + draw_line(start, finish, color, 1.0) + offset += maxf(6.0, cell_size * 0.45) + + +func _draw_construction_labels() -> void: + var construction: Variant = _service(&"construction_service") + if construction == null or not construction.has_method(&"get_jobs"): + return + var font := ThemeDB.fallback_font + for job: Variant in construction.call(&"get_jobs"): + if job == null or int(job.layer) != layer_mode or int(job.state) in [2, 3]: + continue + var rect := get_cell_rect(job.origin) + if rect.size.x < 12.0: + continue + var percent := clampi(int(job.progress) / 100, 0, 100) + draw_string(font, rect.position + Vector2(3, -3), "%d%%" % percent, HORIZONTAL_ALIGNMENT_LEFT, -1, clampi(int(rect.size.x * 0.45), 9, 13), Color("f5d58a")) + + +func _draw_facility_labels() -> void: + var construction: Variant = _service(&"construction_service") + var registry: Variant = _service(&"definition_registry") + if construction == null or not construction.has_method(&"get_facilities"): + return + for value: Variant in construction.call(&"get_facilities"): + if not value is Dictionary or int(value.get(&"layer",0))!=layer_mode: + continue + var facility: Dictionary = value + var cells := _cells_from_serialized(facility.get(&"occupied_cells",[])) + if cells.is_empty(): + continue + var rect := _cell_bounds_rect(cells) + var id := StringName(facility.get(&"definition_id",&"")) + var hovered := _hovered_cell is Vector3i and cells.has(_hovered_cell) + var selected := StringName(facility.get(&"instance_id",&""))==selected_entity_id + if not hovered and not selected and rect.size.x<100: + continue + if id in [&"guest_corridor",&"service_corridor",&"staff_door"] and not hovered and not selected: + continue + var definition: Variant = registry.call(&"get_facility",id) if registry != null else null + var label := String(definition.display_name) if definition != null else String(id) + var font_size := 12 + var label_width := minf(ThemeDB.fallback_font.get_string_size(label,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size).x+12,rect.size.x) + var at := Vector2(rect.get_center().x-label_width*0.5,rect.position.y-17) + draw_rect(Rect2(at,Vector2(label_width,16)),Color("304b41",0.95)) + draw_string(ThemeDB.fallback_font,at+Vector2(4,12),label,HORIZONTAL_ALIGNMENT_CENTER,label_width-8,font_size,Color("f0e2b9")) + + +func _draw_actors() -> void: + var counts: Dictionary = {} + var alive: Dictionary = {} + var service_slots: Dictionary = {} + var guest_service: Variant = _service(&"guest_service") + if guest_service != null and guest_service.has_method(&"get_guests"): + for guest: Variant in guest_service.call(&"get_guests"): + if guest == null: + continue + var key := "guest/"+String(guest.guest_id) + alive[key] = true + var position := _tracked_actor_position(key,guest.current_cell) + if guest.current_cell.z==layer_mode: + var receiving := not String(guest.current_service_id).is_empty() + if receiving: + position = _service_use_position(guest,position,service_slots) + else: + position += _actor_offset(guest.current_cell,counts) + _draw_pixel_actor(position,key,false,int(guest.state),receiving,false,_guest_activity(guest)) + var staff_service: Variant = _service(&"staff_service") + if staff_service != null and staff_service.has_method(&"get_staff_members"): + for staff: Variant in staff_service.call(&"get_staff_members"): + if staff == null: + continue + var key := "staff/"+String(staff.staff_id) + alive[key] = true + var position := _tracked_actor_position(key,staff.current_cell) + if staff.current_cell.z==layer_mode: + _draw_pixel_actor(position+_actor_offset(staff.current_cell,counts),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),staff.role_id) + for key: Variant in _actor_tracks.keys(): + if not alive.has(key): + _actor_tracks.erase(key) + + +func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2: + if not _actor_tracks.has(key): + _actor_tracks[key] = {&"cell":cell,&"from":cell,&"at":_animation_time,&"direction":1.0} + var track: Dictionary = _actor_tracks[key] + var previous: Vector3i = track[&"cell"] + if previous != cell: + track[&"from"] = previous if previous.z==cell.z and absi(previous.x-cell.x)+absi(previous.y-cell.y)==1 else cell + track[&"cell"] = cell + track[&"at"] = _animation_time + if previous.x!=cell.x: + track[&"direction"] = 1.0 if cell.x>previous.x else -1.0 + var clock := get_node_or_null("/root/GameClock") + var t := clampf((_animation_time-float(track[&"at"]))/0.42,0.0,1.0) + # Pausing displays the authoritative cell, never an ambiguous half-step. + if clock != null and bool(clock.get("paused")): + t = 1.0 + return cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(cell),t) + + +func _service_use_position(guest: Variant, fallback: Vector2, slots: Dictionary) -> Vector2: + # Guest paths stop at an access cell. While a service is active, project the + # occupant into that facility's use position; authoritative paths stay intact. + var id := StringName(guest.target_facility_id) + var world: Variant = _service(&"world_service") + if world==null or id.is_empty(): + return fallback + var cells := _cells_from_serialized(world.call(&"get_entity_cells",id)) + if cells.is_empty() or cells[0].z!=layer_mode: + return fallback + var rect := _cell_bounds_rect(cells) + var slot := int(slots.get(id,0)) + slots[id] = slot+1 + var p := _cell_dimensions().x/16.0 + var x := rect.position.x+minf(9.0+float(slot%4)*14.0,rect.size.x/p-5.0)*p + return Vector2(x,rect.end.y-_cell_dimensions().y*0.5) + + +func _guest_activity(guest: Variant) -> StringName: + if String(guest.target_facility_id).is_empty(): + return &"" + var service: Variant = _service(&"facility_service") + var facility: Variant = service.call(&"get_instance",guest.target_facility_id) if service != null else null + return StringName(facility.definition_id) if facility != null else &"" + + +func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"") -> void: + var texture := visual_theme.pixel_dungeon_tiles if visual_theme != null else null + if texture==null: + return + var world_pixel := _cell_dimensions().x/16.0 + var p := maxf(floorf(world_pixel*0.5),1.0) if world_pixel>=1.0 else world_pixel + var track: Dictionary = _actor_tracks.get(key,{}) + var moving: bool = track.get(&"cell",Vector3i.ZERO)!=track.get(&"from",Vector3i.ZERO) and _animation_time-float(track.get(&"at",0.0))<0.42 + var clock := get_node_or_null("/root/GameClock") + var paused := clock != null and bool(clock.get("paused")) + moving = moving and not paused + var variant := int(absi(key.hash()))%5 + var tile_id := (96 if staff else 84)+variant + var frame := int(_animation_time*9.0+variant)%4 + var step := (1.0 if frame==0 else (-1.0 if frame==2 else 0.0)) if moving else 0.0 + var at := (center+Vector2(-8*p, _cell_dimensions().y*0.5-3*world_pixel-16*p)).round() + var source := Vector2((tile_id%12)*16,(tile_id/12)*16) + # Retain the same character; split only the feet into a four-phase walk. + # Neighbouring atlas tiles are other people, never animation frames. + var bob := -p if moving and frame%2==0 else 0.0 + draw_rect(Rect2(at+Vector2(4,14)*p,Vector2(8,1)*p),Color("3a453c",0.35)) + var flip := float(track.get(&"direction",1.0))<0.0 + var torso := Rect2(at+Vector2(0,bob),Vector2(16,15)*p) + if flip: + torso.position.x += torso.size.x + torso.size.x = -torso.size.x + draw_texture_rect_region(texture,torso,Rect2(source,Vector2(16,15))) + for leg in range(2): + var leg_x := float(leg)*8*p + var stride := step*p*(1.0 if leg==0 else -1.0) + var foot := Rect2(at+Vector2(leg_x,14*p+stride+bob),Vector2(8,2)*p) + if flip: + foot.position.x = at.x+float(2-leg)*8*p + foot.size.x = -foot.size.x + draw_texture_rect_region(texture,foot,Rect2(source+Vector2(leg*8,14),Vector2(8,2))) + if staff: + draw_rect(Rect2(at+Vector2(7,8)*p,Vector2(2,3)*p),Color("81c6ba")) + if carrying: + PixelArt.tile(self,texture,89,Rect2(at+Vector2(9,6)*p,Vector2(9,9)*p)) + var working := staff and state==StaffRuntime.State.WORKING + var resting := receiving and activity==&"standard_room" + if resting: + var lift := float(int(_animation_time*2)%3) if not paused else 0.0 + draw_string(ThemeDB.fallback_font,at+Vector2(11,-1-lift)*p,"z",HORIZONTAL_ALIGNMENT_LEFT,-1,maxi(int(6*p),7),Color("f7efd6")) + if staff and activity==&"cleaner" and (moving or working): + var sweep := step*p if working or moving else 0.0 + draw_rect(Rect2(at+Vector2(12*p+sweep,7*p),Vector2(p,8*p)),Color("b7975c")) + draw_rect(Rect2(at+Vector2(10*p+sweep,14*p),Vector2(5*p,2*p)),Color("cdb375")) + if receiving and activity in [&"cafeteria",&"tavern"]: + var lift := p if not paused and int(_animation_time*3)%2==0 else 0.0 + draw_rect(Rect2(at+Vector2(11*p,8*p-lift),Vector2(3*p,2*p)),Color("e6d7b5")) + var waiting := not staff and state==GuestRuntime.State.QUEUING + if (receiving and not resting) or working or waiting: + var badge := Rect2(at+Vector2(10,-4)*p,Vector2(6,5)*p) + draw_rect(badge,Color("f2e9cb")) + var mark := Color("c39150") if waiting else Color("61958a") + if waiting: + draw_rect(Rect2(badge.position+Vector2(2,1)*p,Vector2(1,2)*p),mark) + draw_rect(Rect2(badge.position+Vector2(2,3)*p,Vector2(2,1)*p),mark) + else: + var offset := 1.0 if not paused and int(_animation_time*3)%2==0 else 0.0 + draw_rect(Rect2(badge.position+Vector2(1+offset,2)*p,Vector2(3,1)*p),mark) + + +func _draw_excavation_jobs() -> void: + var excavation: Variant = _service(&"excavation_service") + if excavation == null or not excavation.has_method(&"get_jobs"): + return + var font := ThemeDB.fallback_font + for job_value: Variant in excavation.call(&"get_jobs"): + if not job_value is Dictionary: + continue + var job: Dictionary = job_value + var state := StringName(job.get(&"state", &"")) + if state == &"COMPLETED": + continue + var position: Variant = job.get(&"position", null) + if not position is Vector2i: + continue + var cell := Vector3i(position.x, position.y, layer_mode) + var rect := get_cell_rect(cell).grow(-1.0) + if rect.size == Vector2.ZERO: + continue + var paused := state == &"PAUSED" + draw_rect(rect, Color("d69a56", 0.26) if paused else Color("4ca5c7", 0.28)) + draw_rect(rect, Color("efbd72") if paused else Color("79d3e7"), false, 2.0) + if rect.size.x >= 12.0: + var percent := clampi(int(job.get(&"progress", 0)) / 100, 0, 100) + draw_string( + font, + rect.position + Vector2(3.0, rect.size.y - 3.0), + "掘 %d%%" % percent, + HORIZONTAL_ALIGNMENT_LEFT, + -1, + clampi(int(rect.size.x * 0.38), 8, 12), + Color("e5f8fa") + ) + + +func _draw_build_preview() -> void: + if build_definition == null or not _hovered_cell is Vector3i: + return + var preview_cells := _cells_from_serialized(_last_preview_report.get(&"cells", [])) + if preview_cells.is_empty(): + preview_cells = _fallback_build_cells(_hovered_cell) + var valid := bool(_last_preview_report.get(&"can_build", false)) + var fill := Color("64d48c", 0.34) if valid else Color("e16262", 0.38) + var edge := Color("8ff0ab") if valid else Color("ff8a80") + for cell: Vector3i in preview_cells: + if cell.z != layer_mode: + continue + var rect := get_cell_rect(cell).grow(-1.0) + draw_rect(rect, fill) + draw_rect(rect, edge, false, 2.0) + + +func _draw_excavation_preview() -> void: + if not excavation_mode or not _hovered_cell is Vector3i: + return + var preview_cells := _cells_from_serialized( + _last_excavation_preview_report.get(&"cells", []) + ) + if preview_cells.is_empty(): + preview_cells = [Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode)] + var valid := bool(_last_excavation_preview_report.get(&"ok", false)) + var fill := Color("52bdd0", 0.35) if valid else Color("e16262", 0.38) + var edge := Color("8ce6ee") if valid else Color("ff8a80") + for cell: Vector3i in preview_cells: + if cell.z != layer_mode: + continue + var rect := get_cell_rect(cell).grow(-1.0) + draw_rect(rect, fill) + draw_rect(rect, edge, false, 2.0) + + +func _draw_hover() -> void: + if not _hovered_cell is Vector3i or build_definition != null or excavation_mode: + return + var rect := get_cell_rect(_hovered_cell).grow(-1.0) + draw_rect(rect, Color("d7ebef", 0.75), false, 1.5) + + +func _draw_map_caption(_map_rect: Rect2) -> void: + var font := ThemeDB.fallback_font + var caption := "世界樹の宿 / お客様のフロア" if layer_mode==LayerMode.FRONT else "世界樹の宿 / 従業員と物流" + draw_rect(Rect2(14,12,244,26),Color("29483d",0.93)) + draw_string(font,Vector2(24,30),caption,HORIZONTAL_ALIGNMENT_LEFT,-1,13,Color("f0e2bd")) + var status := "表示 %d%% · G マス目%s" % [int(zoom_level*100), " ON" if show_grid else ""] + draw_rect(Rect2(size.x-214,12,200,26),Color("29483d",0.93)) + draw_string(font,Vector2(size.x-204,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd")) + + +func _draw_center_message(message: String) -> void: + var font := ThemeDB.fallback_font + draw_string(font, Vector2(0, size.y * 0.5), message, HORIZONTAL_ALIGNMENT_CENTER, size.x, 16, Color("8ca4af")) + + +func _actor_offset(cell: Vector3i, counts: Dictionary) -> Vector2: + var count := int(counts.get(cell,0)) + counts[cell] = count+1 + var p := _cell_dimensions().x/16.0 + var slots := [0.0,4.0,-4.0,2.0,-2.0,6.0,-6.0] + return Vector2(float(slots[count%slots.size()])*p,-float(count%2)*p) + + +func _facility_color(instance_id: StringName) -> Color: + var facility_service: Variant = _service(&"facility_service") + if facility_service == null or not facility_service.has_method(&"get_instance"): + return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582") + var instance: Variant = facility_service.call(&"get_instance", instance_id) + if instance == null: + return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582") + match int(instance.state): + 0: + return Color("a24b45") + 1, 2, 3: + return Color("a77d3f") + 4: + return Color("6b6258") + 5: + return Color("89914d") + _: + return Color("6f9a55") if layer_mode == LayerMode.FRONT else Color("67518e") + + +func _fallback_build_cells(origin: Vector3i) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + if build_definition == null: + return result + var footprint: Variant = build_definition.get(&"footprint") if build_definition is Object else null + if build_definition is Dictionary: + footprint = build_definition.get(&"footprint", Vector2i.ONE) + if not footprint is Vector2i: + footprint = Vector2i.ONE + var dimensions: Vector2i = footprint + if build_rotation in [90, 270]: + dimensions = Vector2i(footprint.y, footprint.x) + for y_offset in range(dimensions.y): + for x_offset in range(dimensions.x): + result.append(origin + Vector3i(x_offset, y_offset, 0)) + return result + + +func _fallback_cells_are_available(origin: Vector3i) -> bool: + var world: Variant = _service(&"world_service") + if world == null: + return false + for cell: Vector3i in _fallback_build_cells(origin): + if world.has_method(&"is_in_bounds") and not bool(world.call(&"is_in_bounds", cell)): + return false + if world.has_method(&"get_occupant") and not StringName(world.call(&"get_occupant", cell)).is_empty(): + return false + return true + + +func _set_hovered_cell(value: Variant) -> void: + if value == _hovered_cell: + return + _hovered_cell = value + hovered_cell_changed.emit(_hovered_cell) + _update_build_preview() + _update_excavation_preview() + queue_redraw() + + +func _update_build_preview() -> void: + _last_preview_report = get_build_preview_report() + build_preview_changed.emit(_last_preview_report.duplicate(true)) + + +func _update_excavation_preview() -> void: + _last_excavation_preview_report = ( + get_excavation_preview_report() + if excavation_mode else INVALID_EXCAVATION_PREVIEW.duplicate(true) + ) + excavation_preview_changed.emit(_last_excavation_preview_report.duplicate(true)) + + +func _on_mouse_exited() -> void: + _set_hovered_cell(null) + + +func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: + queue_redraw() + _update_build_preview() + _update_excavation_preview() + + +func _connect_runtime() -> void: + if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"): + return + var callback := Callable(self, "_on_snapshot_published") + if not _runtime.is_connected(&"snapshot_published", callback): + _runtime.connect(&"snapshot_published", callback) + + +func _disconnect_runtime() -> void: + if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"): + return + var callback := Callable(self, "_on_snapshot_published") + if _runtime.is_connected(&"snapshot_published", callback): + _runtime.disconnect(&"snapshot_published", callback) + + +func _service(property_name: StringName) -> Variant: + if _runtime == null or not _runtime is Object: + return null + for property: Dictionary in _runtime.get_property_list(): + if StringName(property.get(&"name", &"")) == property_name: + return _runtime.get(property_name) + return null + + +func _world_bounds() -> Dictionary: + var world: Variant = _service(&"world_service") + if world == null or not world.has_method(&"get_bounds"): + return {} + var result: Variant = world.call(&"get_bounds") + if not result is Dictionary: + return {} + for key: StringName in [&"min_x", &"max_x", &"min_y", &"max_y"]: + if not result.has(key) and not result.has(String(key)): + return {} + return { + &"min_x": int(result.get(&"min_x", result.get("min_x", 0))), + &"max_x": int(result.get(&"max_x", result.get("max_x", -1))), + &"min_y": int(result.get(&"min_y", result.get("min_y", 0))), + &"max_y": int(result.get(&"max_y", result.get("max_y", -1))), + } + + +func _cells_from_serialized(value: Variant) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + if not value is Array: + return result + for raw_cell: Variant in value: + if raw_cell is Vector3i: + result.append(raw_cell) + elif raw_cell is Array and raw_cell.size() == 3: + result.append(Vector3i(int(raw_cell[0]), int(raw_cell[1]), int(raw_cell[2]))) + return result + + +func _serialize_cells(cells: Array[Vector3i]) -> Array[Array]: + var result: Array[Array] = [] + for cell: Vector3i in cells: + result.append([cell.x, cell.y, cell.z]) + return result + + +func _normalized_rotation(value: int) -> int: + return posmod(value, 360) / 90 * 90 diff --git a/scenes/main/world_preview.gd.uid b/scenes/main/world_preview.gd.uid new file mode 100644 index 0000000..95f2247 --- /dev/null +++ b/scenes/main/world_preview.gd.uid @@ -0,0 +1 @@ +uid://dfakfnjdqcx6m diff --git a/scripts/authority/authority_service.gd b/scripts/authority/authority_service.gd new file mode 100644 index 0000000..0143ef1 --- /dev/null +++ b/scripts/authority/authority_service.gd @@ -0,0 +1,1375 @@ +class_name AuthorityService +extends RefCounted + +## Deterministic Authority aggregate. World visibility, path finding and +## financial/sanction application are explicit callback boundaries so this +## service never scans the map or mutates another domain directly. + +signal heat_changed(old_value: int, new_value: int, source_id: StringName) +signal incident_created(incident: AuthorityIncident) +signal incident_detector_added(incident_id: StringName, detector_id: StringName) +signal evidence_registered(evidence: AuthorityEvidence) +signal evidence_discovered(evidence: AuthorityEvidence, method: StringName) +signal inspection_scheduled(inspection: AuthorityInspection) +signal inspection_state_changed(inspection_id: StringName, old_state: int, new_state: int, reason: StringName) +signal resolution_applied(inspection_id: StringName, resolution: Dictionary) + +const HeatStateType = preload("res://scripts/authority/heat_state.gd") +const IncidentType = preload("res://scripts/authority/incident.gd") +const EvidenceType = preload("res://scripts/authority/evidence.gd") +const InspectionType = preload("res://scripts/authority/inspection.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 1 +const INSPECTION_EVALUATION_MINUTE := 360 +const DEFAULT_HEAT_DECAY := 5 +const DEFAULT_ACTIVE_CASE_FLOOR := 20 + +var current_minute := 0 +var last_error := "" + +var _state: HeatState = HeatStateType.new() +var _incidents: Dictionary = {} +var _evidence: Dictionary = {} +var _inspections: Dictionary = {} +var _scored_incident_points: Dictionary = {} +var _applied_resolutions: Dictionary = {} +var _evaluated_inspection_days: Dictionary = {} +var _next_inspection_sequence := 1 +var _next_inspector_sequence := 1 + +var _line_of_sight := Callable() +var _path_query := Callable() +var _resolution_applier := Callable() +var _inspection_roll := Callable() + + +func configure_callbacks( + line_of_sight: Callable = Callable(), + path_query: Callable = Callable(), + resolution_applier: Callable = Callable(), + inspection_roll: Callable = Callable() +) -> AuthorityService: + _line_of_sight = line_of_sight + _path_query = path_query + _resolution_applier = resolution_applier + _inspection_roll = inspection_roll + return self + + +func get_authority_state() -> HeatState: + return _state + + +func get_incident(incident_id: StringName) -> AuthorityIncident: + return _incidents.get(incident_id) as AuthorityIncident + + +func get_incidents() -> Array[AuthorityIncident]: + var result: Array[AuthorityIncident] = [] + for incident_id: StringName in _sorted_keys(_incidents): + result.append(_incidents[incident_id]) + return result + + +func get_evidence(evidence_id: StringName) -> AuthorityEvidence: + return _evidence.get(evidence_id) as AuthorityEvidence + + +func get_all_evidence() -> Array[AuthorityEvidence]: + var result: Array[AuthorityEvidence] = [] + for evidence_id: StringName in _sorted_keys(_evidence): + result.append(_evidence[evidence_id]) + return result + + +func get_inspection(inspection_id: StringName) -> AuthorityInspection: + return _inspections.get(inspection_id) as AuthorityInspection + + +func get_inspections() -> Array[AuthorityInspection]: + var result: Array[AuthorityInspection] = [] + for inspection_id: StringName in _sorted_keys(_inspections): + result.append(_inspections[inspection_id]) + return result + + +func get_resolution(resolution_id: StringName) -> Dictionary: + if not _applied_resolutions.has(resolution_id): + return {} + return (_applied_resolutions[resolution_id] as Dictionary).duplicate(true) + + +func apply_heat_delta( + delta: int, + source_id: StringName, + absolute_minute: int, + reason: StringName = &"" +) -> CommandResult: + var details := _state.apply_heat_delta(delta, source_id, absolute_minute, reason) + if not bool(details.get(&"ok", false)): + return _invalid("Heat delta requires a non-negative minute.", details) + if int(details[&"old_value"]) != int(details[&"new_value"]): + heat_changed.emit(int(details[&"old_value"]), int(details[&"new_value"]), source_id) + last_error = "" + return _success(details) + + +func set_suspicion(value: int) -> CommandResult: + var old_value := _state.suspicion + _state.set_suspicion(value) + last_error = "" + return _success({&"old_value": old_value, &"new_value": _state.suspicion}) + + +func create_incident( + incident_id: StringName, + incident_type: StringName, + absolute_minute: int, + detector_id: StringName, + options: Dictionary = {} +) -> CommandResult: + var incident := IncidentType.new().configure( + incident_id, + incident_type, + absolute_minute, + options + ) + return report_incident(incident, detector_id) + + +## incident_id is the authoritative deduplication key. A second detector is +## retained as audit metadata, but Heat and evidence are never added again. +func report_incident(value: Variant, detector_id: StringName) -> CommandResult: + if value == null or not value is AuthorityIncident: + return _invalid("AuthorityIncident is missing or invalid.") + var incoming: AuthorityIncident = value + if not incoming.is_valid() or detector_id.is_empty(): + return _invalid("AuthorityIncident fails schema validation or has no detector.") + if incoming.state != IncidentType.State.OPEN: + return _invalid("New Incident reports must begin in OPEN state.") + if not incoming.evidence_ids.is_empty(): + return _invalid("New Incident reports cannot arrive with pre-attached Evidence.") + if _incidents.has(incoming.incident_id): + var existing := get_incident(incoming.incident_id) + var detector_added := existing.add_detector(detector_id) + if detector_added: + incident_detector_added.emit(existing.incident_id, detector_id) + var replay := _success({ + &"incident_id": existing.incident_id, + &"detector_added": detector_added, + &"heat_applied": 0, + &"canonical_severity": existing.severity, + }) + replay.is_replay = true + last_error = "" + return replay + incoming.add_detector(detector_id) + _incidents[incoming.incident_id] = incoming + if incoming.state == IncidentType.State.OPEN: + _state.add_active_case(incoming.incident_id) + var heat_result := _state.apply_heat_delta( + incoming.heat_delta, + incoming.incident_id, + incoming.created_at, + &"INCIDENT" + ) + if int(heat_result[&"old_value"]) != int(heat_result[&"new_value"]): + heat_changed.emit( + int(heat_result[&"old_value"]), + int(heat_result[&"new_value"]), + incoming.incident_id + ) + incident_created.emit(incoming) + last_error = "" + return _success({ + &"incident_id": incoming.incident_id, + &"detector_added": true, + &"heat_applied": int(heat_result[&"applied_delta"]), + }) + + +func dismiss_incident( + incident_id: StringName, + reason: StringName = &"DISMISSED" +) -> CommandResult: + var incident := get_incident(incident_id) + if incident == null: + return _not_found("AuthorityIncident", incident_id) + if not incident.dismiss(reason): + return _invalid("Only an open Incident can be dismissed.") + _state.remove_active_case(incident_id) + last_error = "" + return _success({&"incident_id": incident_id, &"state": &"DISMISSED"}) + + +func register_evidence(value: Variant) -> CommandResult: + if value == null or not value is AuthorityEvidence: + return _invalid("AuthorityEvidence is missing or invalid.") + var evidence: AuthorityEvidence = value + if not evidence.is_valid(): + return _invalid("AuthorityEvidence fails schema validation.") + if evidence.is_discovered(): + return _invalid("Live Evidence must enter as latent and use the discovery boundary.") + if _evidence.has(evidence.evidence_id): + return _duplicate("AuthorityEvidence", evidence.evidence_id) + if not evidence.incident_id.is_empty() and not _incidents.has(evidence.incident_id): + return _invalid("AuthorityEvidence references an unknown Incident.", { + &"incident_id": evidence.incident_id, + }) + _evidence[evidence.evidence_id] = evidence + if not evidence.incident_id.is_empty(): + get_incident(evidence.incident_id).add_evidence(evidence.evidence_id) + evidence_registered.emit(evidence) + if evidence.is_discovered(): + _recompute_authority_evidence_points(current_minute) + last_error = "" + return _success({&"evidence_id": evidence.evidence_id}) + + +func create_evidence( + evidence_id: StringName, + source: StringName, + severity: int, + options: Dictionary = {} +) -> CommandResult: + return register_evidence(EvidenceType.new().configure(evidence_id, source, severity, options)) + + +## No visibility callback means no line-of-sight proof. Reaching the cell, +## trusted records/testimony and an already-known illegal facility are the only +## callback-free discovery bases. +func attempt_discover_evidence( + evidence_id: StringName, + inspector_id: StringName, + inspector_cell: Vector3i, + absolute_minute: int, + context: Dictionary = {} +) -> CommandResult: + var evidence := get_evidence(evidence_id) + if evidence == null: + return _not_found("AuthorityEvidence", evidence_id) + if inspector_id.is_empty() or absolute_minute < 0: + return _invalid("Evidence discovery requires an inspector and non-negative minute.") + if evidence.is_expired(absolute_minute): + return _invalid("Evidence has expired.", {&"reason": &"EVIDENCE_EXPIRED"}) + if evidence.is_discovered(): + var replay := _success({ + &"evidence_id": evidence_id, + &"discovery_method": EvidenceType.DiscoveryMethod.keys()[evidence.discovery_method], + &"evidence_points": _state.evidence_points, + }) + replay.is_replay = true + last_error = "" + return replay + + var method := EvidenceType.DiscoveryMethod.LATENT + var reason: StringName = &"NO_VISIBILITY_PROOF" + if inspector_cell == evidence.location_cell: + method = EvidenceType.DiscoveryMethod.REACHED + elif bool(_value(context, &"trusted_source", false)): + method = EvidenceType.DiscoveryMethod.TRUSTED_SOURCE + elif not evidence.facility_id.is_empty() \ + and _state.known_illegal_facility_ids.has(evidence.facility_id): + method = EvidenceType.DiscoveryMethod.KNOWN_ILLEGAL + elif inspector_cell.z != evidence.location_cell.z: + reason = &"DIFFERENT_LAYER" + elif not _line_of_sight.is_valid(): + reason = &"LINE_OF_SIGHT_UNAVAILABLE" + else: + var visibility_context := context.duplicate(true) + visibility_context[&"evidence_id"] = evidence.evidence_id + visibility_context[&"facility_id"] = evidence.facility_id + var visible := bool(_line_of_sight.call( + inspector_cell, + evidence.location_cell, + visibility_context + )) + if visible: + method = EvidenceType.DiscoveryMethod.LINE_OF_SIGHT + else: + reason = &"LINE_OF_SIGHT_BLOCKED" + if method == EvidenceType.DiscoveryMethod.LATENT: + return _invalid("Evidence is not discoverable from the inspector's position.", { + &"reason": reason, + &"evidence_id": evidence_id, + }) + if not evidence.mark_discovered(inspector_id, absolute_minute, method): + return _invalid("Evidence discovery transition failed.") + if bool(_value(context, &"illegal_confirmed", false)) and not evidence.facility_id.is_empty(): + _state.add_known_illegal_facility(evidence.facility_id) + _recompute_authority_evidence_points(absolute_minute) + evidence_discovered.emit(evidence, StringName(EvidenceType.DiscoveryMethod.keys()[method])) + last_error = "" + return _success({ + &"evidence_id": evidence_id, + &"discovery_method": StringName(EvidenceType.DiscoveryMethod.keys()[method]), + &"evidence_points": _state.evidence_points, + }) + + +func advance_time( + absolute_minute: int, + daily_heat_decay: int = DEFAULT_HEAT_DECAY, + active_case_floor: int = DEFAULT_ACTIVE_CASE_FLOOR +) -> CommandResult: + if absolute_minute < current_minute or daily_heat_decay < 0: + return _invalid("Authority time cannot move backwards and decay cannot be negative.") + var old_day := current_minute / 1_440 + var new_day := absolute_minute / 1_440 + var decay_records: Array[Dictionary] = [] + for day_index: int in range(old_day + 1, new_day + 1): + var old_heat := _state.heat + var decay := _state.apply_daily_decay(day_index, daily_heat_decay, active_case_floor) + decay_records.append(decay) + if old_heat != _state.heat: + heat_changed.emit(old_heat, _state.heat, &"daily_decay") + current_minute = absolute_minute + _recompute_authority_evidence_points(absolute_minute) + last_error = "" + return _success({ + &"current_minute": current_minute, + &"decays": decay_records, + &"evidence_points": _state.evidence_points, + }) + + +func evaluate_inspection(absolute_minute: int, terms: Dictionary = {}) -> CommandResult: + if absolute_minute < 0 or absolute_minute % 1_440 != INSPECTION_EVALUATION_MINUTE: + return _invalid("Inspection selection is evaluated only at 06:00.") + var day_index := absolute_minute / 1_440 + if _evaluated_inspection_days.has(day_index): + var replay := _success((_evaluated_inspection_days[day_index] as Dictionary).duplicate(true)) + replay.is_replay = true + last_error = "" + return replay + if absolute_minute < _state.next_inspection_window: + return _invalid("Inspection evaluation is outside the next Window.", { + &"next_inspection_window": _state.next_inspection_window, + }) + var base_chance := int(_value(terms, &"base_chance", 5)) + var heat_weight := int(_value(terms, &"heat_weight", 1)) + var suspicion_weight := int(_value(terms, &"suspicion_weight", 1)) + var overdue_bonus := int(_value( + terms, + &"overdue_case_bonus", + 20 if not _state.active_case_ids.is_empty() else 0 + )) + var reputation_protection := int(_value(terms, &"reputation_protection", 0)) + var chance := clampi( + base_chance + + _state.heat * heat_weight + + _state.suspicion * suspicion_weight + + overdue_bonus + - reputation_protection, + 0, + 95 + ) + var roll := -1 + if _has_key(terms, &"roll"): + roll = clampi(int(_value(terms, &"roll", 99)), 0, 99) + elif _inspection_roll.is_valid(): + roll = clampi(int(_inspection_roll.call(day_index, chance, terms.duplicate(true))), 0, 99) + else: + roll = _stable_percent("inspection:%d:%d:%d:%d" % [ + day_index, + _state.heat, + _state.suspicion, + _state.active_case_ids.size(), + ]) + var selected := roll < chance + var details := { + &"day_index": day_index, + &"chance": chance, + &"roll": roll, + &"selected": selected, + &"inspection_id": &"", + } + if selected and not _has_open_inspection(): + var scheduled := schedule_inspection(absolute_minute, terms) + if not scheduled.is_success(): + return scheduled + details[&"inspection_id"] = scheduled.details[&"inspection_id"] + elif selected: + details[&"selected"] = false + details[&"reason"] = &"INSPECTION_ALREADY_ACTIVE" + _state.next_inspection_window = (day_index + 1) * 1_440 + INSPECTION_EVALUATION_MINUTE + _state.revision += 1 + _evaluated_inspection_days[day_index] = details.duplicate(true) + last_error = "" + return _success(details) + + +func schedule_inspection( + scheduled_at: int, + options: Dictionary = {} +) -> CommandResult: + if scheduled_at < 0: + return _invalid("Inspection schedule requires a non-negative minute.") + var inspection_id := StringName(_value(options, &"inspection_id", &"")) + var generated_inspection_id := inspection_id.is_empty() + if generated_inspection_id: + inspection_id = StringName("inspection_%08d" % _next_inspection_sequence) + if _inspections.has(inspection_id): + return _duplicate("AuthorityInspection", inspection_id) + var inspector_id := StringName(_value(options, &"inspector_id", &"")) + var generated_inspector_id := inspector_id.is_empty() + if generated_inspector_id: + inspector_id = StringName("authority_inspector_%08d" % _next_inspector_sequence) + var inspection := InspectionType.new().configure( + inspection_id, + inspector_id, + scheduled_at, + options + ) + if not inspection.is_valid(): + return _invalid("AuthorityInspection fails schema validation.") + _inspections[inspection_id] = inspection + if generated_inspection_id: + _next_inspection_sequence += 1 + if generated_inspector_id: + _next_inspector_sequence += 1 + inspection_scheduled.emit(inspection) + last_error = "" + return _success({ + &"inspection_id": inspection_id, + &"inspector_id": inspector_id, + }) + + +func start_inspection(inspection_id: StringName, absolute_minute: int) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.ARRIVING, + absolute_minute, + &"INSPECTION_STARTED" + ) + + +func mark_inspector_arrived(inspection_id: StringName, absolute_minute: int) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.CHECK_IN, + absolute_minute, + &"ARRIVED" + ) + + +func mark_inspection_blocked_entry( + inspection_id: StringName, + absolute_minute: int, + reason: StringName = &"BLOCKED_ENTRY" +) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.BLOCKED_ENTRY, + absolute_minute, + reason + ) + + +func interrupt_inspection( + inspection_id: StringName, + absolute_minute: int, + reason: StringName = &"INTERRUPTED" +) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.INTERRUPTED, + absolute_minute, + reason + ) + + +func mark_inspection_hostile( + inspection_id: StringName, + absolute_minute: int, + reason: StringName = &"HOSTILE" +) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.HOSTILE, + absolute_minute, + reason + ) + + +func cancel_inspection( + inspection_id: StringName, + absolute_minute: int, + reason: StringName = &"CANCELLED" +) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.CANCELLED, + absolute_minute, + reason + ) + + +func plan_patrol( + inspection_id: StringName, + candidates: Array, + absolute_minute: int, + time_budget_minutes: int = 120 +) -> CommandResult: + var inspection := get_inspection(inspection_id) + if inspection == null: + return _not_found("AuthorityInspection", inspection_id) + if inspection.state != InspectionType.State.CHECK_IN: + return _invalid("Patrol can only be planned after CHECK_IN.") + if time_budget_minutes <= 0: + return _invalid("Patrol time budget must be positive.") + if absolute_minute < inspection.state_changed_at: + return _invalid("Patrol plan time cannot move backwards.") + var normalized: Array[Dictionary] = [] + for raw_candidate: Variant in candidates: + if raw_candidate is Dictionary: + var waypoint := InspectionType.normalize_waypoint(raw_candidate) + if not waypoint.is_empty(): + normalized.append(waypoint) + normalized.sort_custom(_waypoint_less) + var selected: Array[Dictionary] = [] + var selected_ids: Dictionary = {} + var used_minutes := 0 + for waypoint: Dictionary in normalized: + var waypoint_id: StringName = waypoint[&"waypoint_id"] + if selected_ids.has(waypoint_id): + continue + selected_ids[waypoint_id] = true + var cost := int(waypoint[&"travel_minutes"]) + int(waypoint[&"inspection_minutes"]) + if used_minutes + cost > time_budget_minutes: + continue + selected.append(waypoint.duplicate(true)) + used_minutes += cost + inspection.time_budget_minutes = time_budget_minutes + inspection.time_spent_minutes = 0 + inspection.waypoint_route = selected + inspection.current_waypoint_index = 0 + inspection.visited_waypoint_ids.clear() + inspection.inaccessible_waypoint_ids.clear() + inspection.revision += 1 + var state_result := _change_inspection_state( + inspection_id, + InspectionType.State.PATROLLING, + absolute_minute, + &"PATROL_PLANNED" + ) + if not state_result.is_success(): + return state_result + last_error = "" + return _success({ + &"inspection_id": inspection_id, + &"waypoint_ids": _waypoint_ids(selected), + &"planned_minutes": used_minutes, + &"time_budget_minutes": time_budget_minutes, + }) + + +## Moves only to the next chosen waypoint. It never scans other cells. A +## failed path immediately records a reason and starts a safe departure. +func advance_patrol( + inspection_id: StringName, + absolute_minute: int, + context: Dictionary = {} +) -> CommandResult: + var inspection := get_inspection(inspection_id) + if inspection == null: + return _not_found("AuthorityInspection", inspection_id) + if inspection.state != InspectionType.State.PATROLLING: + return _invalid("Inspection is not PATROLLING.") + if absolute_minute < inspection.state_changed_at: + return _invalid("Patrol time cannot move backwards.") + if inspection.current_waypoint_index >= inspection.waypoint_route.size(): + return _change_inspection_state( + inspection_id, + InspectionType.State.REVIEWING, + absolute_minute, + &"PATROL_COMPLETE" + ) + var waypoint: Dictionary = inspection.waypoint_route[inspection.current_waypoint_index] + var from_cell := inspection.entrance_cell + if inspection.current_waypoint_index > 0: + from_cell = inspection.waypoint_route[inspection.current_waypoint_index - 1][&"cell"] + var to_cell: Vector3i = waypoint[&"cell"] + var path_result := { + &"reachable": false, + &"reason": &"PATH_QUERY_UNAVAILABLE", + &"travel_minutes": int(waypoint[&"travel_minutes"]), + } + if _path_query.is_valid(): + var path_context := context.duplicate(true) + path_context[&"inspection_id"] = inspection_id + path_context[&"waypoint_id"] = waypoint[&"waypoint_id"] + var raw_result: Variant = _path_query.call(from_cell, to_cell, path_context) + path_result = _normalize_path_result(raw_result, int(waypoint[&"travel_minutes"])) + if not bool(path_result[&"reachable"]): + var waypoint_id: StringName = waypoint[&"waypoint_id"] + inspection.add_inaccessible_waypoint(waypoint_id) + inspection.exit_reason = &"PATH_UNREACHABLE" + inspection.result_reason = StringName(path_result.get(&"reason", &"PATH_UNREACHABLE")) + var departure := _change_inspection_state( + inspection_id, + InspectionType.State.DEPARTING, + absolute_minute, + inspection.result_reason + ) + if not departure.is_success(): + return departure + return _success({ + &"inspection_id": inspection_id, + &"reachable": false, + &"waypoint_id": waypoint_id, + &"exit_reason": inspection.exit_reason, + &"path_reason": inspection.result_reason, + }) + var travel_minutes := maxi(int(path_result[&"travel_minutes"]), 0) + inspection.add_visited_waypoint(waypoint[&"waypoint_id"], travel_minutes) + last_error = "" + return _success({ + &"inspection_id": inspection_id, + &"reachable": true, + &"waypoint_id": waypoint[&"waypoint_id"], + &"cell": to_cell, + &"travel_minutes": travel_minutes, + }) + + +func inspect_current_waypoint( + inspection_id: StringName, + evidence_ids: Array, + absolute_minute: int, + context: Dictionary = {} +) -> CommandResult: + var inspection := get_inspection(inspection_id) + if inspection == null: + return _not_found("AuthorityInspection", inspection_id) + if inspection.state != InspectionType.State.PATROLLING or inspection.current_waypoint_index <= 0: + return _invalid("Inspector must first reach a patrol waypoint.") + if absolute_minute < inspection.state_changed_at: + return _invalid("Evidence inspection time cannot move backwards.") + var waypoint: Dictionary = inspection.waypoint_route[inspection.current_waypoint_index - 1] + var inspector_cell: Vector3i = waypoint[&"cell"] + var discovered: Array[StringName] = [] + var rejected: Array[Dictionary] = [] + for evidence_id: StringName in _sorted_unique_names(evidence_ids): + var result := attempt_discover_evidence( + evidence_id, + inspection.inspector_id, + inspector_cell, + absolute_minute, + context + ) + if result.is_success(): + inspection.add_discovered_evidence(evidence_id) + discovered.append(evidence_id) + else: + rejected.append({ + &"evidence_id": String(evidence_id), + &"message": result.message, + &"details": result.details.duplicate(true), + }) + inspection.time_spent_minutes += int(waypoint[&"inspection_minutes"]) + inspection.evidence_score = _calculate_inspection_evidence_score(inspection, absolute_minute) + inspection.revision += 1 + last_error = "" + return _success({ + &"inspection_id": inspection_id, + &"waypoint_id": waypoint[&"waypoint_id"], + &"discovered_evidence_ids": discovered, + &"rejected": rejected, + &"evidence_score": inspection.evidence_score, + }) + + +func finish_patrol(inspection_id: StringName, absolute_minute: int) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.REVIEWING, + absolute_minute, + &"PATROL_COMPLETE" + ) + + +func begin_inspection_departure( + inspection_id: StringName, + absolute_minute: int, + reason: StringName = &"DEPARTING" +) -> CommandResult: + return _change_inspection_state( + inspection_id, + InspectionType.State.DEPARTING, + absolute_minute, + reason + ) + + +func resolve_inspection( + inspection_id: StringName, + resolution_id: StringName, + absolute_minute: int, + options: Dictionary = {} +) -> CommandResult: + if resolution_id.is_empty(): + return _invalid("Inspection resolution requires a resolution_id.") + if _applied_resolutions.has(resolution_id): + var existing: Dictionary = _applied_resolutions[resolution_id] + var replay := _success(existing.duplicate(true)) + replay.is_replay = true + last_error = "" + return replay + var inspection := get_inspection(inspection_id) + if inspection == null: + return _not_found("AuthorityInspection", inspection_id) + if inspection.state != InspectionType.State.REVIEWING: + return _invalid("Inspection can only resolve from REVIEWING.") + if not inspection.resolution_id.is_empty(): + return _invalid("Inspection already has a different resolution.") + inspection.evidence_score = _calculate_inspection_evidence_score(inspection, absolute_minute) + var outcome := _determine_resolution(inspection.evidence_score, options) + var fine_per_point := maxi(int(_value(options, &"fine_per_point", 10)), 0) + var fine_amount := 0 + if outcome >= InspectionType.Result.FINE: + fine_amount = inspection.evidence_score * fine_per_point + var sanction := _sanction_for_result(outcome) + var payload := { + &"resolution_id": resolution_id, + &"inspection_id": inspection_id, + &"absolute_minute": absolute_minute, + &"result": StringName(InspectionType.Result.keys()[outcome]), + &"evidence_score": inspection.evidence_score, + &"fine_amount": fine_amount, + &"sanction": sanction, + &"evidence_ids": _names_to_strings(inspection.discovered_evidence_ids), + } + if absolute_minute < inspection.state_changed_at: + return _invalid("Inspection resolution time cannot move backwards.") + if _resolution_applier.is_valid(): + var validation := _normalize_callback_result( + _resolution_applier.call(payload.duplicate(true), &"validate", null) + ) + if not bool(validation[&"ok"]): + return _invalid("Inspection resolution validation failed.", validation) + var applied := _normalize_callback_result( + _resolution_applier.call( + payload.duplicate(true), + &"apply", + validation.get(&"token", null) + ) + ) + if not bool(applied[&"ok"]): + var rollback := _normalize_callback_result(_resolution_applier.call( + payload.duplicate(true), + &"rollback", + applied.get(&"token", validation.get(&"token", null)) + )) + var failure_details := applied.duplicate(true) + failure_details[&"rollback_ok"] = bool(rollback[&"ok"]) + return _invalid("Inspection resolution application failed.", failure_details) + var transition := _change_inspection_state( + inspection_id, + InspectionType.State.RESOLVING, + absolute_minute, + &"RESOLUTION_STARTED" + ) + if not transition.is_success(): + return transition + if fine_amount > 0: + _state.add_fine_due(fine_amount) + inspection.result = outcome + inspection.resolution_id = resolution_id + inspection.result_reason = &"RESOLUTION_APPLIED" + inspection.revision += 1 + _applied_resolutions[resolution_id] = payload.duplicate(true) + for evidence_id: StringName in inspection.discovered_evidence_ids: + var evidence := get_evidence(evidence_id) + if evidence == null or evidence.incident_id.is_empty(): + continue + var incident := get_incident(evidence.incident_id) + if incident != null and incident.state == IncidentType.State.OPEN: + incident.resolve(resolution_id, &"INSPECTION_RESOLVED") + _state.remove_active_case(incident.incident_id) + _change_inspection_state( + inspection_id, + InspectionType.State.DEPARTING, + absolute_minute, + &"RESOLUTION_APPLIED" + ) + resolution_applied.emit(inspection_id, payload.duplicate(true)) + last_error = "" + return _success(payload) + + +func depart_inspection(inspection_id: StringName, absolute_minute: int) -> CommandResult: + var inspection := get_inspection(inspection_id) + if inspection == null: + return _not_found("AuthorityInspection", inspection_id) + var departure_reason := inspection.result_reason + if departure_reason.is_empty(): + departure_reason = &"DEPARTED" + var result := _change_inspection_state( + inspection_id, + InspectionType.State.CLOSED, + absolute_minute, + departure_reason + ) + if not result.is_success(): + return result + last_error = "" + return _success({ + &"inspection_id": inspection_id, + &"state": &"CLOSED", + &"exit_reason": inspection.exit_reason, + &"result_reason": inspection.result_reason, + }) + + +func validate_invariants() -> Dictionary: + return _validate_candidate_invariants( + _state, + _incidents, + _evidence, + _inspections, + _scored_incident_points, + _applied_resolutions, + current_minute + ) + + +func create_snapshot() -> Dictionary: + var incidents: Array[Dictionary] = [] + for incident: AuthorityIncident in get_incidents(): + incidents.append(incident.create_snapshot()) + var evidence_snapshots: Array[Dictionary] = [] + for evidence: AuthorityEvidence in get_all_evidence(): + evidence_snapshots.append(evidence.create_snapshot()) + var inspections: Array[Dictionary] = [] + for inspection: AuthorityInspection in get_inspections(): + inspections.append(inspection.create_snapshot()) + var scored_points: Array[Dictionary] = [] + for score_key: StringName in _sorted_keys(_scored_incident_points): + scored_points.append({ + &"score_key": String(score_key), + &"points": int(_scored_incident_points[score_key]), + }) + var resolutions: Array[Dictionary] = [] + for resolution_id: StringName in _sorted_keys(_applied_resolutions): + resolutions.append({ + &"resolution_id": String(resolution_id), + &"payload": (_applied_resolutions[resolution_id] as Dictionary).duplicate(true), + }) + var evaluated_days: Array[int] = [] + for day_value: Variant in _evaluated_inspection_days.keys(): + evaluated_days.append(int(day_value)) + evaluated_days.sort() + var evaluations: Array[Dictionary] = [] + for day_index: int in evaluated_days: + evaluations.append({ + &"day_index": day_index, + &"result": (_evaluated_inspection_days[day_index] as Dictionary).duplicate(true), + }) + return { + &"version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"authority_state": _state.create_snapshot(), + &"incidents": incidents, + &"evidence": evidence_snapshots, + &"inspections": inspections, + &"scored_incident_points": scored_points, + &"applied_resolutions": resolutions, + &"evaluated_inspection_days": evaluations, + &"next_inspection_sequence": _next_inspection_sequence, + &"next_inspector_sequence": _next_inspector_sequence, + } + + +## Parses every object and cross-reference before replacing any live field. +func restore_snapshot(snapshot: Dictionary) -> bool: + var parsed := _parse_snapshot(snapshot) + if not bool(parsed.get(&"ok", false)): + last_error = String(parsed.get(&"error", "Invalid Authority snapshot.")) + return false + _state = parsed[&"authority_state"] + _incidents = parsed[&"incidents"] + _evidence = parsed[&"evidence"] + _inspections = parsed[&"inspections"] + _scored_incident_points = parsed[&"scored_incident_points"] + _applied_resolutions = parsed[&"applied_resolutions"] + _evaluated_inspection_days = parsed[&"evaluated_inspection_days"] + current_minute = int(parsed[&"current_minute"]) + _next_inspection_sequence = int(parsed[&"next_inspection_sequence"]) + _next_inspector_sequence = int(parsed[&"next_inspector_sequence"]) + last_error = "" + return true + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION: + return {&"ok": false, &"error": "Unsupported Authority snapshot version."} + for collection_key: StringName in [ + &"incidents", + &"evidence", + &"inspections", + &"scored_incident_points", + &"applied_resolutions", + &"evaluated_inspection_days", + ]: + if not _value(snapshot, collection_key, null) is Array: + return {&"ok": false, &"error": "Malformed Authority collection: %s" % collection_key} + var raw_state: Variant = _value(snapshot, &"authority_state", null) + if not raw_state is Dictionary: + return {&"ok": false, &"error": "Malformed AuthorityState snapshot."} + var next_state := HeatStateType.from_snapshot(raw_state) + if next_state == null: + return {&"ok": false, &"error": "Invalid AuthorityState snapshot."} + var next_incidents: Dictionary = {} + for raw_incident: Variant in _value(snapshot, &"incidents", []): + if not raw_incident is Dictionary: + return {&"ok": false, &"error": "Malformed Incident snapshot."} + var incident := IncidentType.from_snapshot(raw_incident) + if incident == null or next_incidents.has(incident.incident_id): + return {&"ok": false, &"error": "Invalid or duplicate Incident snapshot."} + next_incidents[incident.incident_id] = incident + var next_evidence: Dictionary = {} + for raw_evidence: Variant in _value(snapshot, &"evidence", []): + if not raw_evidence is Dictionary: + return {&"ok": false, &"error": "Malformed Evidence snapshot."} + var evidence := EvidenceType.from_snapshot(raw_evidence) + if evidence == null or next_evidence.has(evidence.evidence_id): + return {&"ok": false, &"error": "Invalid or duplicate Evidence snapshot."} + next_evidence[evidence.evidence_id] = evidence + var next_inspections: Dictionary = {} + for raw_inspection: Variant in _value(snapshot, &"inspections", []): + if not raw_inspection is Dictionary: + return {&"ok": false, &"error": "Malformed Inspection snapshot."} + var inspection := InspectionType.from_snapshot(raw_inspection) + if inspection == null or next_inspections.has(inspection.inspection_id): + return {&"ok": false, &"error": "Invalid or duplicate Inspection snapshot."} + next_inspections[inspection.inspection_id] = inspection + var next_scored_points: Dictionary = {} + for raw_score: Variant in _value(snapshot, &"scored_incident_points", []): + if not raw_score is Dictionary: + return {&"ok": false, &"error": "Malformed Evidence score snapshot."} + var score_key := StringName(_value(raw_score, &"score_key", &"")) + var points := int(_value(raw_score, &"points", -1)) + if score_key.is_empty() or points < 0 or next_scored_points.has(score_key): + return {&"ok": false, &"error": "Invalid or duplicate Evidence score snapshot."} + next_scored_points[score_key] = points + var next_resolutions: Dictionary = {} + for raw_resolution: Variant in _value(snapshot, &"applied_resolutions", []): + if not raw_resolution is Dictionary or not _value(raw_resolution, &"payload", null) is Dictionary: + return {&"ok": false, &"error": "Malformed resolution snapshot."} + var resolution_id := StringName(_value(raw_resolution, &"resolution_id", &"")) + if resolution_id.is_empty() or next_resolutions.has(resolution_id): + return {&"ok": false, &"error": "Invalid or duplicate resolution snapshot."} + next_resolutions[resolution_id] = (_value(raw_resolution, &"payload", {}) as Dictionary).duplicate(true) + var next_evaluations: Dictionary = {} + for raw_evaluation: Variant in _value(snapshot, &"evaluated_inspection_days", []): + if not raw_evaluation is Dictionary or not _value(raw_evaluation, &"result", null) is Dictionary: + return {&"ok": false, &"error": "Malformed inspection evaluation snapshot."} + var day_index := int(_value(raw_evaluation, &"day_index", -1)) + if day_index < 0 or next_evaluations.has(day_index): + return {&"ok": false, &"error": "Invalid or duplicate inspection evaluation day."} + next_evaluations[day_index] = (_value(raw_evaluation, &"result", {}) as Dictionary).duplicate(true) + var next_current_minute := int(_value(snapshot, &"current_minute", -1)) + var next_inspection_sequence := int(_value(snapshot, &"next_inspection_sequence", 0)) + var next_inspector_sequence := int(_value(snapshot, &"next_inspector_sequence", 0)) + if next_current_minute < 0 or next_inspection_sequence < 1 or next_inspector_sequence < 1: + return {&"ok": false, &"error": "Authority snapshot counters are invalid."} + var invariant_report := _validate_candidate_invariants( + next_state, + next_incidents, + next_evidence, + next_inspections, + next_scored_points, + next_resolutions, + next_current_minute + ) + if not bool(invariant_report[&"ok"]): + return { + &"ok": false, + &"error": "Authority snapshot violates invariants: %s" % [str(invariant_report[&"errors"])], + } + return { + &"ok": true, + &"authority_state": next_state, + &"incidents": next_incidents, + &"evidence": next_evidence, + &"inspections": next_inspections, + &"scored_incident_points": next_scored_points, + &"applied_resolutions": next_resolutions, + &"evaluated_inspection_days": next_evaluations, + &"current_minute": next_current_minute, + &"next_inspection_sequence": next_inspection_sequence, + &"next_inspector_sequence": next_inspector_sequence, + } + + +func _validate_candidate_invariants( + state: HeatState, + incidents: Dictionary, + evidence_by_id: Dictionary, + inspections: Dictionary, + scored_points: Dictionary, + resolutions: Dictionary, + reference_minute: int +) -> Dictionary: + var errors: Array[String] = [] + if state == null or not state.is_valid(): + errors.append("AuthorityState is invalid.") + return {&"ok": false, &"errors": errors} + if state.last_decay_day > reference_minute / 1_440: + errors.append("AuthorityState daily decay is ahead of current time.") + for incident_id: StringName in _sorted_keys(incidents): + var incident: AuthorityIncident = incidents[incident_id] + if incident == null or incident.incident_id != incident_id or not incident.is_valid(): + errors.append("Invalid Incident: %s" % incident_id) + continue + if incident.detector_ids.is_empty(): + errors.append("Incident has no reporting detector: %s" % incident_id) + var should_be_active := incident.state == IncidentType.State.OPEN + if state.active_case_ids.has(incident_id) != should_be_active: + errors.append("Incident active-case mismatch: %s" % incident_id) + for evidence_id: StringName in incident.evidence_ids: + if not evidence_by_id.has(evidence_id): + errors.append("Incident references missing Evidence: %s" % evidence_id) + if incident.state == IncidentType.State.RESOLVED and not resolutions.has(incident.resolution_id): + errors.append("Resolved Incident references missing resolution: %s" % incident_id) + for case_id: StringName in state.active_case_ids: + if not incidents.has(case_id): + errors.append("AuthorityState references missing active case: %s" % case_id) + for evidence_id: StringName in _sorted_keys(evidence_by_id): + var evidence: AuthorityEvidence = evidence_by_id[evidence_id] + if evidence == null or evidence.evidence_id != evidence_id or not evidence.is_valid(): + errors.append("Invalid Evidence: %s" % evidence_id) + continue + if not evidence.incident_id.is_empty(): + if not incidents.has(evidence.incident_id): + errors.append("Evidence references missing Incident: %s" % evidence_id) + elif not (incidents[evidence.incident_id] as AuthorityIncident).evidence_ids.has(evidence_id): + errors.append("Incident/Evidence reverse reference mismatch: %s" % evidence_id) + var expected_scores: Dictionary = {} + for evidence_id: StringName in _sorted_keys(evidence_by_id): + var scored_evidence: AuthorityEvidence = evidence_by_id[evidence_id] + if scored_evidence == null or not scored_evidence.is_discovered() \ + or scored_evidence.is_expired(reference_minute): + continue + var expected_key := scored_evidence.incident_id + if expected_key.is_empty(): + expected_key = StringName("evidence:%s" % evidence_id) + expected_scores[expected_key] = maxi( + int(expected_scores.get(expected_key, 0)), + scored_evidence.severity + ) + for score_key: StringName in _sorted_keys(scored_points): + if int(scored_points[score_key]) < 0 or int(scored_points[score_key]) > 100: + errors.append("Invalid Evidence score: %s" % score_key) + if scored_points != expected_scores: + errors.append("Evidence score index does not match discovered, unexpired evidence.") + var score_sum := 0 + for points: Variant in scored_points.values(): + score_sum += int(points) + if score_sum != state.evidence_points: + errors.append("Authority evidence_points cache mismatch.") + for inspection_id: StringName in _sorted_keys(inspections): + var inspection: AuthorityInspection = inspections[inspection_id] + if inspection == null or inspection.inspection_id != inspection_id or not inspection.is_valid(): + errors.append("Invalid Inspection: %s" % inspection_id) + continue + for evidence_id: StringName in inspection.discovered_evidence_ids: + if not evidence_by_id.has(evidence_id) or not (evidence_by_id[evidence_id] as AuthorityEvidence).is_discovered(): + errors.append("Inspection references unavailable Evidence: %s" % evidence_id) + if not inspection.resolution_id.is_empty() and not resolutions.has(inspection.resolution_id): + errors.append("Inspection resolution reference is missing: %s" % inspection.resolution_id) + var expected_fines_due := 0 + for resolution_id: StringName in _sorted_keys(resolutions): + var payload: Dictionary = resolutions[resolution_id] + if StringName(_value(payload, &"resolution_id", &"")) != resolution_id: + errors.append("Resolution payload ID mismatch: %s" % resolution_id) + var inspection_id := StringName(_value(payload, &"inspection_id", &"")) + var fine_amount := int(_value(payload, &"fine_amount", -1)) + if fine_amount < 0: + errors.append("Resolution has an invalid fine amount: %s" % resolution_id) + else: + expected_fines_due += fine_amount + if not inspections.has(inspection_id): + errors.append("Resolution references missing Inspection: %s" % resolution_id) + else: + var resolved_inspection: AuthorityInspection = inspections[inspection_id] + if resolved_inspection.resolution_id != resolution_id: + errors.append("Resolution/Inspection reverse reference mismatch: %s" % resolution_id) + if String(_value(payload, &"result", "")) != InspectionType.Result.keys()[resolved_inspection.result]: + errors.append("Resolution result cache mismatch: %s" % resolution_id) + if int(_value(payload, &"evidence_score", -1)) != resolved_inspection.evidence_score: + errors.append("Resolution Evidence score cache mismatch: %s" % resolution_id) + if expected_fines_due != state.fines_due: + errors.append("Authority fines_due cache mismatch.") + return {&"ok": errors.is_empty(), &"errors": errors} + + +func _recompute_authority_evidence_points(absolute_minute: int) -> void: + var next_scores: Dictionary = {} + for evidence_id: StringName in _sorted_keys(_evidence): + var evidence := get_evidence(evidence_id) + if evidence == null or not evidence.is_discovered() or evidence.is_expired(absolute_minute): + continue + var score_key := evidence.incident_id + if score_key.is_empty(): + score_key = StringName("evidence:%s" % evidence.evidence_id) + next_scores[score_key] = maxi(int(next_scores.get(score_key, 0)), evidence.severity) + _scored_incident_points = next_scores + var total := 0 + for points: Variant in _scored_incident_points.values(): + total += int(points) + _state.set_evidence_points(total) + + +func _calculate_inspection_evidence_score( + inspection: AuthorityInspection, + absolute_minute: int +) -> int: + var incident_scores: Dictionary = {} + for evidence_id: StringName in inspection.discovered_evidence_ids: + var evidence := get_evidence(evidence_id) + if evidence == null or not evidence.is_discovered() or evidence.is_expired(absolute_minute): + continue + var score_key := evidence.incident_id + if score_key.is_empty(): + score_key = StringName("evidence:%s" % evidence.evidence_id) + incident_scores[score_key] = maxi(int(incident_scores.get(score_key, 0)), evidence.severity) + var total := 0 + for points: Variant in incident_scores.values(): + total += int(points) + return total + + +func _change_inspection_state( + inspection_id: StringName, + new_state: AuthorityInspection.State, + absolute_minute: int, + reason: StringName +) -> CommandResult: + var inspection := get_inspection(inspection_id) + if inspection == null: + return _not_found("AuthorityInspection", inspection_id) + if absolute_minute < inspection.state_changed_at: + return _invalid("Inspection state time cannot move backwards.") + if not _transition_is_allowed(inspection.state, new_state): + return _invalid("Illegal Inspection state transition.", { + &"old_state": InspectionType.State.keys()[inspection.state], + &"new_state": InspectionType.State.keys()[new_state], + }) + var old_state := inspection.state + if not inspection.set_state(new_state, absolute_minute, reason): + return _invalid("Inspection state transition did not change state.") + inspection_state_changed.emit(inspection_id, old_state, new_state, reason) + last_error = "" + return _success({ + &"inspection_id": inspection_id, + &"old_state": StringName(InspectionType.State.keys()[old_state]), + &"new_state": StringName(InspectionType.State.keys()[new_state]), + &"reason": reason, + }) + + +static func _transition_is_allowed( + old_state: AuthorityInspection.State, + new_state: AuthorityInspection.State +) -> bool: + match old_state: + InspectionType.State.SCHEDULED: + return new_state in [InspectionType.State.ARRIVING, InspectionType.State.CANCELLED] + InspectionType.State.ARRIVING: + return new_state in [ + InspectionType.State.CHECK_IN, + InspectionType.State.BLOCKED_ENTRY, + InspectionType.State.INTERRUPTED, + InspectionType.State.HOSTILE, + InspectionType.State.CANCELLED, + ] + InspectionType.State.CHECK_IN: + return new_state in [ + InspectionType.State.PATROLLING, + InspectionType.State.DEPARTING, + InspectionType.State.INTERRUPTED, + InspectionType.State.HOSTILE, + InspectionType.State.CANCELLED, + ] + InspectionType.State.PATROLLING: + return new_state in [ + InspectionType.State.REVIEWING, + InspectionType.State.DEPARTING, + InspectionType.State.INTERRUPTED, + InspectionType.State.HOSTILE, + InspectionType.State.CANCELLED, + ] + InspectionType.State.REVIEWING: + return new_state in [ + InspectionType.State.RESOLVING, + InspectionType.State.DEPARTING, + InspectionType.State.CANCELLED, + ] + InspectionType.State.RESOLVING: + return new_state in [InspectionType.State.REVIEWING, InspectionType.State.DEPARTING] + InspectionType.State.BLOCKED_ENTRY, InspectionType.State.INTERRUPTED, InspectionType.State.HOSTILE: + return new_state == InspectionType.State.DEPARTING + InspectionType.State.DEPARTING: + return new_state == InspectionType.State.CLOSED + return false + + +static func _determine_resolution(score: int, options: Dictionary) -> AuthorityInspection.Result: + if _has_key(options, &"result"): + var requested := String(_value(options, &"result", "PENDING")) + var index := InspectionType.Result.keys().find(requested) + if index > InspectionType.Result.PENDING: + return index as AuthorityInspection.Result + var warning_threshold := maxi(int(_value(options, &"warning_threshold", 1)), 1) + var fine_threshold := maxi(int(_value(options, &"fine_threshold", 20)), warning_threshold) + var closure_threshold := maxi(int(_value(options, &"closure_threshold", 50)), fine_threshold) + var confiscation_threshold := maxi(int(_value(options, &"confiscation_threshold", 80)), closure_threshold) + var arrest_threshold := maxi(int(_value(options, &"arrest_threshold", 110)), confiscation_threshold) + var raid_threshold := maxi(int(_value(options, &"raid_threshold", 140)), arrest_threshold) + if score >= raid_threshold: + return InspectionType.Result.RAID_ESCALATION + if score >= arrest_threshold: + return InspectionType.Result.ARREST_ATTEMPT + if score >= confiscation_threshold: + return InspectionType.Result.CONFISCATION + if score >= closure_threshold: + return InspectionType.Result.TEMPORARY_CLOSURE + if score >= fine_threshold: + return InspectionType.Result.FINE + if score >= warning_threshold: + return InspectionType.Result.WARNING + return InspectionType.Result.NO_ACTION + + +static func _sanction_for_result(result: AuthorityInspection.Result) -> StringName: + match result: + InspectionType.Result.TEMPORARY_CLOSURE: + return &"TEMPORARY_CLOSURE" + InspectionType.Result.CONFISCATION: + return &"CONFISCATION" + InspectionType.Result.ARREST_ATTEMPT: + return &"ARREST_ATTEMPT" + InspectionType.Result.RAID_ESCALATION: + return &"RAID_ESCALATION" + return &"NONE" + + +static func _normalize_path_result(value: Variant, fallback_minutes: int) -> Dictionary: + if value is bool: + return { + &"reachable": value, + &"reason": &"OK" if value else &"PATH_UNREACHABLE", + &"travel_minutes": fallback_minutes, + } + if value is Dictionary: + return { + &"reachable": bool(_value(value, &"reachable", false)), + &"reason": StringName(_value(value, &"reason", &"OK")), + &"travel_minutes": maxi(int(_value(value, &"travel_minutes", fallback_minutes)), 0), + } + return { + &"reachable": false, + &"reason": &"PATH_QUERY_INVALID", + &"travel_minutes": fallback_minutes, + } + + +static func _normalize_callback_result(value: Variant) -> Dictionary: + if value is bool: + return {&"ok": value} + if value is Dictionary: + var result: Dictionary = value.duplicate(true) + result[&"ok"] = bool(_value(value, &"ok", false)) + return result + return {&"ok": false, &"reason": &"INVALID_CALLBACK_RESULT"} + + +func _has_open_inspection() -> bool: + for inspection: AuthorityInspection in get_inspections(): + if not inspection.is_terminal(): + return true + return false + + +static func _waypoint_less(a: Dictionary, b: Dictionary) -> bool: + var utility_a := int(a.get(&"utility", 0)) + var utility_b := int(b.get(&"utility", 0)) + if utility_a != utility_b: + return utility_a > utility_b + return String(a.get(&"waypoint_id", "")) < String(b.get(&"waypoint_id", "")) + + +static func _waypoint_ids(waypoints: Array[Dictionary]) -> Array[StringName]: + var result: Array[StringName] = [] + for waypoint: Dictionary in waypoints: + result.append(StringName(waypoint[&"waypoint_id"])) + return result + + +static func _stable_percent(text: String) -> int: + var value := 17 + for index: int in range(text.length()): + value = (value * 131 + text.unicode_at(index)) % 10_000_019 + return value % 100 + + +static func _sorted_keys(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_key: Variant in source.keys(): + result.append(StringName(raw_key)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _sorted_unique_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if values is Array: + for raw_value: Variant in values: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _has_key(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +func _success(details: Dictionary = {}) -> CommandResult: + return CommandResultType.succeeded(0, details) + + +func _invalid(message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed( + CommandResultType.Code.VALIDATION_FAILED, + 0, + message, + details + ) + + +func _not_found(kind: String, entity_id: StringName) -> CommandResult: + return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id}) + + +func _duplicate(kind: String, entity_id: StringName) -> CommandResult: + return _invalid("Duplicate %s ID: %s" % [kind, entity_id], {&"entity_id": entity_id}) diff --git a/scripts/authority/authority_service.gd.uid b/scripts/authority/authority_service.gd.uid new file mode 100644 index 0000000..824b921 --- /dev/null +++ b/scripts/authority/authority_service.gd.uid @@ -0,0 +1 @@ +uid://dg3e85o0gd2pd diff --git a/scripts/authority/evidence.gd b/scripts/authority/evidence.gd new file mode 100644 index 0000000..473d39c --- /dev/null +++ b/scripts/authority/evidence.gd @@ -0,0 +1,170 @@ +class_name AuthorityEvidence +extends RefCounted + +enum DiscoveryMethod { + LATENT, + REACHED, + LINE_OF_SIGHT, + TRUSTED_SOURCE, + KNOWN_ILLEGAL, +} + +var evidence_id: StringName = &"" +var incident_id: StringName = &"" +var source: StringName = &"" +var severity := 0 +var facility_id: StringName = &"" +var location_cell := Vector3i.ZERO +var created_at := 0 +var expires_at := -1 +var discovered_at := -1 +var discovered_by: StringName = &"" +var discovery_method: DiscoveryMethod = DiscoveryMethod.LATENT +var revision := 0 + + +func configure( + p_evidence_id: StringName, + p_source: StringName, + p_severity: int, + options: Dictionary = {} +) -> AuthorityEvidence: + evidence_id = p_evidence_id + incident_id = StringName(_value(options, &"incident_id", &"")) + source = p_source + severity = clampi(p_severity, 0, 100) + facility_id = StringName(_value(options, &"facility_id", &"")) + location_cell = _vector3i(_value(options, &"location_cell", Vector3i.ZERO)) + created_at = maxi(int(_value(options, &"created_at", 0)), 0) + expires_at = int(_value(options, &"expires_at", -1)) + discovered_at = int(_value(options, &"discovered_at", -1)) + discovered_by = StringName(_value(options, &"discovered_by", &"")) + discovery_method = _method_from_value(_value(options, &"discovery_method", DiscoveryMethod.LATENT)) + revision = maxi(int(_value(options, &"revision", 0)), 0) + return self + + +func is_discovered() -> bool: + return discovered_at >= 0 + + +func is_expired(absolute_minute: int) -> bool: + return expires_at >= 0 and absolute_minute >= expires_at + + +func mark_discovered( + inspector_id: StringName, + absolute_minute: int, + method: DiscoveryMethod +) -> bool: + if is_discovered() or inspector_id.is_empty() or absolute_minute < created_at: + return false + if method == DiscoveryMethod.LATENT: + return false + discovered_at = absolute_minute + discovered_by = inspector_id + discovery_method = method + revision += 1 + return true + + +func is_valid() -> bool: + if evidence_id.is_empty() or source.is_empty() or severity < 0 or severity > 100: + return false + if created_at < 0 or expires_at < -1 or revision < 0: + return false + if expires_at >= 0 and expires_at < created_at: + return false + if discovered_at < -1: + return false + if discovered_at >= 0: + if discovered_by.is_empty() or discovery_method == DiscoveryMethod.LATENT: + return false + if discovered_at < created_at: + return false + else: + if not discovered_by.is_empty() or discovery_method != DiscoveryMethod.LATENT: + return false + return true + + +func create_snapshot() -> Dictionary: + return { + &"evidence_id": String(evidence_id), + &"incident_id": String(incident_id), + &"source": String(source), + &"severity": severity, + &"facility_id": String(facility_id), + &"location_cell": [location_cell.x, location_cell.y, location_cell.z], + &"created_at": created_at, + &"expires_at": expires_at, + &"discovered_at": discovered_at, + &"discovered_by": String(discovered_by), + &"discovery_method": DiscoveryMethod.keys()[discovery_method], + &"revision": revision, + } + + +static func from_snapshot(snapshot: Dictionary) -> AuthorityEvidence: + if not _snapshot_is_valid(snapshot): + return null + var evidence := AuthorityEvidence.new().configure( + StringName(_value(snapshot, &"evidence_id", &"")), + StringName(_value(snapshot, &"source", &"")), + int(_value(snapshot, &"severity", -1)), + snapshot + ) + return evidence if evidence.is_valid() else null + + +static func _snapshot_is_valid(snapshot: Dictionary) -> bool: + for key: StringName in [ + &"evidence_id", &"incident_id", &"source", &"severity", &"facility_id", + &"location_cell", &"created_at", &"expires_at", &"discovered_at", + &"discovered_by", &"discovery_method", &"revision", + ]: + if not _has_key(snapshot, key): + return false + if StringName(_value(snapshot, &"evidence_id", &"")).is_empty() \ + or StringName(_value(snapshot, &"source", &"")).is_empty(): + return false + if not _value(snapshot, &"location_cell", null) is Array \ + or (_value(snapshot, &"location_cell", []) as Array).size() != 3: + return false + var severity_value := int(_value(snapshot, &"severity", -1)) + if severity_value < 0 or severity_value > 100: + return false + if int(_value(snapshot, &"created_at", -1)) < 0 \ + or int(_value(snapshot, &"expires_at", -2)) < -1 \ + or int(_value(snapshot, &"discovered_at", -2)) < -1 \ + or int(_value(snapshot, &"revision", -1)) < 0: + return false + return DiscoveryMethod.keys().has(String(_value(snapshot, &"discovery_method", ""))) + + +static func _method_from_value(value: Variant) -> DiscoveryMethod: + if value is String or value is StringName: + var index := DiscoveryMethod.keys().find(String(value)) + return (index as DiscoveryMethod) if index >= 0 else DiscoveryMethod.LATENT + var number := int(value) + return (number as DiscoveryMethod) if number >= 0 and number < DiscoveryMethod.size() else DiscoveryMethod.LATENT + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() >= 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + if value is Dictionary: + return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0))) + return Vector3i.ZERO + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _has_key(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) diff --git a/scripts/authority/evidence.gd.uid b/scripts/authority/evidence.gd.uid new file mode 100644 index 0000000..588b523 --- /dev/null +++ b/scripts/authority/evidence.gd.uid @@ -0,0 +1 @@ +uid://mnr0hrt63kc3 diff --git a/scripts/authority/heat_state.gd b/scripts/authority/heat_state.gd new file mode 100644 index 0000000..349a259 --- /dev/null +++ b/scripts/authority/heat_state.gd @@ -0,0 +1,309 @@ +class_name HeatState +extends RefCounted + +## Authoritative short-term attention state for the Authority domain. Heat is +## deliberately separate from suspicion and discovered evidence. + +const MAX_HEAT := 100 +const MAX_SUSPICION := 100 + +var heat := 0 +var suspicion := 0 +var evidence_points := 0 +var inspection_level := 0 +var next_inspection_window := 360 +var active_case_ids: Array[StringName] = [] +var fines_due := 0 +var known_illegal_facility_ids: Array[StringName] = [] +var last_decay_day := 0 +var revision := 0 +var heat_history: Array[Dictionary] = [] +var _next_history_sequence := 1 + + +func configure(options: Dictionary = {}) -> HeatState: + heat = clampi(int(_value(options, &"heat", 0)), 0, MAX_HEAT) + suspicion = clampi(int(_value(options, &"suspicion", 0)), 0, MAX_SUSPICION) + evidence_points = maxi(int(_value(options, &"evidence_points", 0)), 0) + inspection_level = clampi(int(_value(options, &"inspection_level", 0)), 0, 5) + next_inspection_window = maxi(int(_value(options, &"next_inspection_window", 360)), 0) + active_case_ids = _sorted_unique_names(_value(options, &"active_case_ids", [])) + fines_due = maxi(int(_value(options, &"fines_due", 0)), 0) + known_illegal_facility_ids = _sorted_unique_names( + _value(options, &"known_illegal_facility_ids", []) + ) + last_decay_day = maxi(int(_value(options, &"last_decay_day", 0)), 0) + revision = maxi(int(_value(options, &"revision", 0)), 0) + heat_history.clear() + var saved_history: Variant = _value(options, &"heat_history", []) + if saved_history is Array: + for raw_entry: Variant in saved_history: + if raw_entry is Dictionary: + heat_history.append((raw_entry as Dictionary).duplicate(true)) + _next_history_sequence = maxi( + int(_value(options, &"next_history_sequence", heat_history.size() + 1)), + 1 + ) + return self + + +func apply_heat_delta( + delta: int, + source_id: StringName, + absolute_minute: int, + reason: StringName = &"" +) -> Dictionary: + if absolute_minute < 0: + return {&"ok": false, &"reason": &"INVALID_MINUTE"} + var old_value := heat + heat = clampi(heat + delta, 0, MAX_HEAT) + var applied := heat - old_value + if applied != 0: + revision += 1 + heat_history.append({ + &"sequence": _next_history_sequence, + &"absolute_minute": absolute_minute, + &"source_id": String(source_id), + &"reason": String(reason), + &"requested_delta": delta, + &"applied_delta": applied, + &"old_value": old_value, + &"new_value": heat, + }) + _next_history_sequence += 1 + return { + &"ok": true, + &"old_value": old_value, + &"new_value": heat, + &"requested_delta": delta, + &"applied_delta": applied, + } + + +## Applies a given day at most once. An active-case floor can stop decay, but +## decay never raises an already-lower Heat value up to that floor. +func apply_daily_decay(day_index: int, amount: int, active_case_floor: int) -> Dictionary: + if day_index <= last_decay_day: + return {&"ok": true, &"is_replay": true, &"old_value": heat, &"new_value": heat} + if amount < 0: + return {&"ok": false, &"reason": &"INVALID_DECAY"} + var old_value := heat + var floor_value := 0 + if not active_case_ids.is_empty(): + floor_value = clampi(active_case_floor, 0, MAX_HEAT) + if floor_value > 0 and heat > floor_value: + heat = maxi(heat - amount, floor_value) + else: + heat = maxi(heat - amount, 0) if floor_value == 0 else heat + last_decay_day = day_index + revision += 1 + if heat != old_value: + heat_history.append({ + &"sequence": _next_history_sequence, + &"absolute_minute": day_index * 1_440, + &"source_id": "daily_decay", + &"reason": "DAILY_DECAY", + &"requested_delta": -amount, + &"applied_delta": heat - old_value, + &"old_value": old_value, + &"new_value": heat, + }) + _next_history_sequence += 1 + return { + &"ok": true, + &"is_replay": false, + &"old_value": old_value, + &"new_value": heat, + &"floor": floor_value, + } + + +func set_suspicion(value: int) -> void: + var normalized := clampi(value, 0, MAX_SUSPICION) + if suspicion == normalized: + return + suspicion = normalized + revision += 1 + + +func set_evidence_points(value: int) -> void: + var normalized := maxi(value, 0) + if evidence_points == normalized: + return + evidence_points = normalized + revision += 1 + + +func add_active_case(case_id: StringName) -> bool: + if case_id.is_empty() or active_case_ids.has(case_id): + return false + active_case_ids.append(case_id) + active_case_ids.sort_custom(_name_less) + revision += 1 + return true + + +func remove_active_case(case_id: StringName) -> bool: + var index := active_case_ids.find(case_id) + if index < 0: + return false + active_case_ids.remove_at(index) + revision += 1 + return true + + +func add_known_illegal_facility(facility_id: StringName) -> bool: + if facility_id.is_empty() or known_illegal_facility_ids.has(facility_id): + return false + known_illegal_facility_ids.append(facility_id) + known_illegal_facility_ids.sort_custom(_name_less) + revision += 1 + return true + + +func add_fine_due(amount: int) -> bool: + if amount <= 0: + return false + fines_due += amount + revision += 1 + return true + + +func is_valid() -> bool: + if heat < 0 or heat > MAX_HEAT or suspicion < 0 or suspicion > MAX_SUSPICION: + return false + if evidence_points < 0 or inspection_level < 0 or inspection_level > 5: + return false + if next_inspection_window < 0 or fines_due < 0 or last_decay_day < 0 or revision < 0: + return false + if active_case_ids != _sorted_unique_names(active_case_ids): + return false + if known_illegal_facility_ids != _sorted_unique_names(known_illegal_facility_ids): + return false + var last_sequence := 0 + for entry: Dictionary in heat_history: + var sequence := int(entry.get(&"sequence", 0)) + if sequence <= last_sequence: + return false + last_sequence = sequence + return _next_history_sequence > last_sequence + + +func create_snapshot() -> Dictionary: + return { + &"heat": heat, + &"suspicion": suspicion, + &"evidence_points": evidence_points, + &"inspection_level": inspection_level, + &"next_inspection_window": next_inspection_window, + &"active_case_ids": _names_to_strings(active_case_ids), + &"fines_due": fines_due, + &"known_illegal_facility_ids": _names_to_strings(known_illegal_facility_ids), + &"last_decay_day": last_decay_day, + &"revision": revision, + &"heat_history": heat_history.duplicate(true), + &"next_history_sequence": _next_history_sequence, + } + + +static func from_snapshot(snapshot: Dictionary) -> HeatState: + if not _snapshot_is_valid(snapshot): + return null + var state := HeatState.new().configure(snapshot) + return state if state.is_valid() else null + + +static func _snapshot_is_valid(snapshot: Dictionary) -> bool: + for key: StringName in [ + &"heat", &"suspicion", &"evidence_points", &"inspection_level", + &"next_inspection_window", &"active_case_ids", &"fines_due", + &"known_illegal_facility_ids", &"last_decay_day", &"revision", + &"heat_history", &"next_history_sequence", + ]: + if not _has_key(snapshot, key): + return false + if not _value(snapshot, &"active_case_ids", null) is Array \ + or not _value(snapshot, &"known_illegal_facility_ids", null) is Array \ + or not _value(snapshot, &"heat_history", null) is Array: + return false + if not _name_array_is_canonical(_value(snapshot, &"active_case_ids", [])) \ + or not _name_array_is_canonical(_value(snapshot, &"known_illegal_facility_ids", [])): + return false + var raw_heat := int(_value(snapshot, &"heat", -1)) + var raw_suspicion := int(_value(snapshot, &"suspicion", -1)) + var raw_level := int(_value(snapshot, &"inspection_level", -1)) + if raw_heat < 0 or raw_heat > MAX_HEAT or raw_suspicion < 0 or raw_suspicion > MAX_SUSPICION: + return false + if int(_value(snapshot, &"evidence_points", -1)) < 0 or raw_level < 0 or raw_level > 5: + return false + if int(_value(snapshot, &"next_inspection_window", -1)) < 0 \ + or int(_value(snapshot, &"fines_due", -1)) < 0 \ + or int(_value(snapshot, &"last_decay_day", -1)) < 0 \ + or int(_value(snapshot, &"revision", -1)) < 0: + return false + var last_sequence := 0 + for raw_entry: Variant in _value(snapshot, &"heat_history", []): + if not raw_entry is Dictionary: + return false + for entry_key: StringName in [ + &"sequence", &"absolute_minute", &"source_id", &"reason", + &"requested_delta", &"applied_delta", &"old_value", &"new_value", + ]: + if not _has_key(raw_entry, entry_key): + return false + var sequence := int(_value(raw_entry, &"sequence", 0)) + var old_value := int(_value(raw_entry, &"old_value", -1)) + var new_value := int(_value(raw_entry, &"new_value", -1)) + var applied_delta := int(_value(raw_entry, &"applied_delta", 999)) + if sequence <= last_sequence or int(_value(raw_entry, &"absolute_minute", -1)) < 0: + return false + if old_value < 0 or old_value > MAX_HEAT or new_value < 0 or new_value > MAX_HEAT: + return false + if new_value - old_value != applied_delta: + return false + last_sequence = sequence + return int(_value(snapshot, &"next_history_sequence", 0)) > last_sequence + + +static func _sorted_unique_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if values is Array: + for raw_value: Variant in values: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(_name_less) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _name_less(a: StringName, b: StringName) -> bool: + return String(a) < String(b) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _has_key(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _name_array_is_canonical(value: Variant) -> bool: + if not value is Array: + return false + var previous := "" + for index: int in range(value.size()): + var current := String(value[index]) + if current.is_empty() or (index > 0 and current <= previous): + return false + previous = current + return true diff --git a/scripts/authority/heat_state.gd.uid b/scripts/authority/heat_state.gd.uid new file mode 100644 index 0000000..ada7508 --- /dev/null +++ b/scripts/authority/heat_state.gd.uid @@ -0,0 +1 @@ +uid://cjymlwh4jkx64 diff --git a/scripts/authority/incident.gd b/scripts/authority/incident.gd new file mode 100644 index 0000000..bcf09a3 --- /dev/null +++ b/scripts/authority/incident.gd @@ -0,0 +1,226 @@ +class_name AuthorityIncident +extends RefCounted + +enum State { + OPEN, + RESOLVED, + DISMISSED, +} + +var incident_id: StringName = &"" +var incident_type: StringName = &"" +var created_at := 0 +var location_cell := Vector3i.ZERO +var facility_id: StringName = &"" +var actor_ids: Array[StringName] = [] +var severity := 0 +var heat_delta := 0 +var detector_ids: Array[StringName] = [] +var evidence_ids: Array[StringName] = [] +var state: State = State.OPEN +var resolution_id: StringName = &"" +var state_reason: StringName = &"" +var revision := 0 + + +func configure( + p_incident_id: StringName, + p_incident_type: StringName, + p_created_at: int, + options: Dictionary = {} +) -> AuthorityIncident: + incident_id = p_incident_id + incident_type = p_incident_type + created_at = maxi(p_created_at, 0) + location_cell = _vector3i(_value(options, &"location_cell", Vector3i.ZERO)) + facility_id = StringName(_value(options, &"facility_id", &"")) + actor_ids = _sorted_unique_names(_value(options, &"actor_ids", [])) + severity = clampi(int(_value(options, &"severity", 0)), 0, 100) + heat_delta = clampi(int(_value(options, &"heat_delta", severity / 5)), 0, 100) + detector_ids = _sorted_unique_names(_value(options, &"detector_ids", [])) + evidence_ids = _sorted_unique_names(_value(options, &"evidence_ids", [])) + state = _state_from_value(_value(options, &"state", State.OPEN)) + resolution_id = StringName(_value(options, &"resolution_id", &"")) + state_reason = StringName(_value(options, &"state_reason", &"")) + revision = maxi(int(_value(options, &"revision", 0)), 0) + return self + + +func add_detector(detector_id: StringName) -> bool: + if detector_id.is_empty() or detector_ids.has(detector_id): + return false + detector_ids.append(detector_id) + detector_ids.sort_custom(_name_less) + revision += 1 + return true + + +func add_evidence(evidence_id: StringName) -> bool: + if evidence_id.is_empty() or evidence_ids.has(evidence_id): + return false + evidence_ids.append(evidence_id) + evidence_ids.sort_custom(_name_less) + revision += 1 + return true + + +func resolve(p_resolution_id: StringName, reason: StringName = &"") -> bool: + if state != State.OPEN or p_resolution_id.is_empty(): + return false + state = State.RESOLVED + resolution_id = p_resolution_id + state_reason = reason + revision += 1 + return true + + +func dismiss(reason: StringName) -> bool: + if state != State.OPEN: + return false + state = State.DISMISSED + state_reason = reason + revision += 1 + return true + + +func is_valid() -> bool: + if incident_id.is_empty() or incident_type.is_empty() or created_at < 0: + return false + if severity < 0 or severity > 100 or heat_delta < 0 or heat_delta > 100 or revision < 0: + return false + if actor_ids != _sorted_unique_names(actor_ids): + return false + if detector_ids != _sorted_unique_names(detector_ids): + return false + if evidence_ids != _sorted_unique_names(evidence_ids): + return false + if state == State.RESOLVED and resolution_id.is_empty(): + return false + if state != State.RESOLVED and not resolution_id.is_empty(): + return false + return true + + +func create_snapshot() -> Dictionary: + return { + &"incident_id": String(incident_id), + &"incident_type": String(incident_type), + &"created_at": created_at, + &"location_cell": [location_cell.x, location_cell.y, location_cell.z], + &"facility_id": String(facility_id), + &"actor_ids": _names_to_strings(actor_ids), + &"severity": severity, + &"heat_delta": heat_delta, + &"detector_ids": _names_to_strings(detector_ids), + &"evidence_ids": _names_to_strings(evidence_ids), + &"state": State.keys()[state], + &"resolution_id": String(resolution_id), + &"state_reason": String(state_reason), + &"revision": revision, + } + + +static func from_snapshot(snapshot: Dictionary) -> AuthorityIncident: + if not _snapshot_is_valid(snapshot): + return null + var incident := AuthorityIncident.new().configure( + StringName(_value(snapshot, &"incident_id", &"")), + StringName(_value(snapshot, &"incident_type", &"")), + int(_value(snapshot, &"created_at", -1)), + snapshot + ) + return incident if incident.is_valid() else null + + +static func _snapshot_is_valid(snapshot: Dictionary) -> bool: + for key: StringName in [ + &"incident_id", &"incident_type", &"created_at", &"location_cell", + &"facility_id", &"actor_ids", &"severity", &"heat_delta", + &"detector_ids", &"evidence_ids", &"state", &"resolution_id", + &"state_reason", &"revision", + ]: + if not _has_key(snapshot, key): + return false + if StringName(_value(snapshot, &"incident_id", &"")).is_empty() \ + or StringName(_value(snapshot, &"incident_type", &"")).is_empty(): + return false + if not _value(snapshot, &"location_cell", null) is Array \ + or (_value(snapshot, &"location_cell", []) as Array).size() != 3: + return false + if not _value(snapshot, &"actor_ids", null) is Array \ + or not _value(snapshot, &"detector_ids", null) is Array \ + or not _value(snapshot, &"evidence_ids", null) is Array: + return false + if not _name_array_is_canonical(_value(snapshot, &"actor_ids", [])) \ + or not _name_array_is_canonical(_value(snapshot, &"detector_ids", [])) \ + or not _name_array_is_canonical(_value(snapshot, &"evidence_ids", [])): + return false + var severity_value := int(_value(snapshot, &"severity", -1)) + var heat_value := int(_value(snapshot, &"heat_delta", -1)) + if int(_value(snapshot, &"created_at", -1)) < 0 or severity_value < 0 or severity_value > 100: + return false + if heat_value < 0 or heat_value > 100 or int(_value(snapshot, &"revision", -1)) < 0: + return false + return State.keys().has(String(_value(snapshot, &"state", ""))) + + +static func _state_from_value(value: Variant) -> State: + if value is String or value is StringName: + var index := State.keys().find(String(value)) + return (index as State) if index >= 0 else State.OPEN + var number := int(value) + return (number as State) if number >= 0 and number < State.size() else State.OPEN + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() >= 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + if value is Dictionary: + return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0))) + return Vector3i.ZERO + + +static func _sorted_unique_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if values is Array: + for raw_value: Variant in values: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(_name_less) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _name_less(a: StringName, b: StringName) -> bool: + return String(a) < String(b) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _has_key(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _name_array_is_canonical(value: Variant) -> bool: + if not value is Array: + return false + var previous := "" + for index: int in range(value.size()): + var current := String(value[index]) + if current.is_empty() or (index > 0 and current <= previous): + return false + previous = current + return true diff --git a/scripts/authority/incident.gd.uid b/scripts/authority/incident.gd.uid new file mode 100644 index 0000000..dd34020 --- /dev/null +++ b/scripts/authority/incident.gd.uid @@ -0,0 +1 @@ +uid://b0ao5te7fi1i3 diff --git a/scripts/authority/inspection.gd b/scripts/authority/inspection.gd new file mode 100644 index 0000000..77be63f --- /dev/null +++ b/scripts/authority/inspection.gd @@ -0,0 +1,390 @@ +class_name AuthorityInspection +extends RefCounted + +enum State { + SCHEDULED, + ARRIVING, + CHECK_IN, + PATROLLING, + REVIEWING, + RESOLVING, + DEPARTING, + CLOSED, + BLOCKED_ENTRY, + INTERRUPTED, + HOSTILE, + CANCELLED, +} + +enum Result { + PENDING, + NO_ACTION, + WARNING, + FINE, + TEMPORARY_CLOSURE, + CONFISCATION, + ARREST_ATTEMPT, + RAID_ESCALATION, +} + +var inspection_id: StringName = &"" +var inspector_id: StringName = &"" +var scheduled_at := 0 +var entrance_cell := Vector3i.ZERO +var state: State = State.SCHEDULED +var state_changed_at := 0 +var time_budget_minutes := 120 +var time_spent_minutes := 0 +var waypoint_route: Array[Dictionary] = [] +var current_waypoint_index := 0 +var visited_waypoint_ids: Array[StringName] = [] +var inaccessible_waypoint_ids: Array[StringName] = [] +var discovered_evidence_ids: Array[StringName] = [] +var evidence_score := 0 +var result: Result = Result.PENDING +var result_reason: StringName = &"" +var exit_reason: StringName = &"" +var resolution_id: StringName = &"" +var closed_at := -1 +var revision := 0 + + +func configure( + p_inspection_id: StringName, + p_inspector_id: StringName, + p_scheduled_at: int, + options: Dictionary = {} +) -> AuthorityInspection: + inspection_id = p_inspection_id + inspector_id = p_inspector_id + scheduled_at = maxi(p_scheduled_at, 0) + entrance_cell = _vector3i(_value(options, &"entrance_cell", Vector3i.ZERO)) + state = _state_from_value(_value(options, &"state", State.SCHEDULED)) + state_changed_at = maxi(int(_value(options, &"state_changed_at", scheduled_at)), 0) + time_budget_minutes = maxi(int(_value(options, &"time_budget_minutes", 120)), 1) + time_spent_minutes = maxi(int(_value(options, &"time_spent_minutes", 0)), 0) + waypoint_route.clear() + var raw_route: Variant = _value(options, &"waypoint_route", []) + if raw_route is Array: + for raw_waypoint: Variant in raw_route: + if raw_waypoint is Dictionary: + var normalized := normalize_waypoint(raw_waypoint) + if not normalized.is_empty(): + waypoint_route.append(normalized) + current_waypoint_index = maxi(int(_value(options, &"current_waypoint_index", 0)), 0) + visited_waypoint_ids = _sorted_unique_names(_value(options, &"visited_waypoint_ids", [])) + inaccessible_waypoint_ids = _sorted_unique_names(_value(options, &"inaccessible_waypoint_ids", [])) + discovered_evidence_ids = _sorted_unique_names(_value(options, &"discovered_evidence_ids", [])) + evidence_score = maxi(int(_value(options, &"evidence_score", 0)), 0) + result = _result_from_value(_value(options, &"result", Result.PENDING)) + result_reason = StringName(_value(options, &"result_reason", &"")) + exit_reason = StringName(_value(options, &"exit_reason", &"")) + resolution_id = StringName(_value(options, &"resolution_id", &"")) + closed_at = int(_value(options, &"closed_at", -1)) + revision = maxi(int(_value(options, &"revision", 0)), 0) + return self + + +func set_state(new_state: State, absolute_minute: int, reason: StringName = &"") -> bool: + if absolute_minute < state_changed_at or state == new_state: + return false + state = new_state + state_changed_at = absolute_minute + if new_state == State.CLOSED: + closed_at = absolute_minute + if not reason.is_empty(): + result_reason = reason + revision += 1 + return true + + +func add_visited_waypoint(waypoint_id: StringName, travel_minutes: int) -> bool: + if waypoint_id.is_empty() or visited_waypoint_ids.has(waypoint_id): + return false + visited_waypoint_ids.append(waypoint_id) + visited_waypoint_ids.sort_custom(_name_less) + time_spent_minutes += maxi(travel_minutes, 0) + current_waypoint_index += 1 + revision += 1 + return true + + +func add_inaccessible_waypoint(waypoint_id: StringName) -> bool: + if waypoint_id.is_empty() or inaccessible_waypoint_ids.has(waypoint_id): + return false + inaccessible_waypoint_ids.append(waypoint_id) + inaccessible_waypoint_ids.sort_custom(_name_less) + revision += 1 + return true + + +func add_discovered_evidence(evidence_id: StringName) -> bool: + if evidence_id.is_empty() or discovered_evidence_ids.has(evidence_id): + return false + discovered_evidence_ids.append(evidence_id) + discovered_evidence_ids.sort_custom(_name_less) + revision += 1 + return true + + +func is_terminal() -> bool: + return state in [State.CLOSED, State.CANCELLED] + + +func is_valid() -> bool: + if inspection_id.is_empty() or inspector_id.is_empty() or scheduled_at < 0: + return false + if state_changed_at < scheduled_at or time_budget_minutes <= 0 or time_spent_minutes < 0: + return false + if current_waypoint_index < 0 or current_waypoint_index > waypoint_route.size(): + return false + if evidence_score < 0 or closed_at < -1 or revision < 0: + return false + if visited_waypoint_ids != _sorted_unique_names(visited_waypoint_ids): + return false + if inaccessible_waypoint_ids != _sorted_unique_names(inaccessible_waypoint_ids): + return false + if discovered_evidence_ids != _sorted_unique_names(discovered_evidence_ids): + return false + var waypoint_ids: Dictionary = {} + var previous_waypoint: Dictionary = {} + for waypoint: Dictionary in waypoint_route: + var normalized := normalize_waypoint(waypoint) + if normalized.is_empty(): + return false + var waypoint_id: StringName = normalized[&"waypoint_id"] + if waypoint_ids.has(waypoint_id): + return false + if not previous_waypoint.is_empty(): + var previous_utility := int(previous_waypoint[&"utility"]) + var current_utility := int(normalized[&"utility"]) + if current_utility > previous_utility: + return false + if current_utility == previous_utility \ + and String(normalized[&"waypoint_id"]) < String(previous_waypoint[&"waypoint_id"]): + return false + waypoint_ids[waypoint_id] = true + previous_waypoint = normalized + if current_waypoint_index != visited_waypoint_ids.size(): + return false + for route_index: int in range(current_waypoint_index): + if not visited_waypoint_ids.has(StringName(waypoint_route[route_index][&"waypoint_id"])): + return false + for waypoint_id: StringName in visited_waypoint_ids: + if not waypoint_ids.has(waypoint_id): + return false + for waypoint_id: StringName in inaccessible_waypoint_ids: + if not waypoint_ids.has(waypoint_id): + return false + if result == Result.PENDING and not resolution_id.is_empty(): + return false + if result != Result.PENDING and resolution_id.is_empty(): + return false + if state == State.CLOSED and closed_at < state_changed_at: + return false + if state != State.CLOSED and closed_at >= 0: + return false + return true + + +func create_snapshot() -> Dictionary: + var saved_route: Array[Dictionary] = [] + for waypoint: Dictionary in waypoint_route: + saved_route.append({ + &"waypoint_id": String(waypoint[&"waypoint_id"]), + &"cell": _cell_to_array(waypoint[&"cell"]), + &"suspect_score": int(waypoint[&"suspect_score"]), + &"evidence_potential": int(waypoint[&"evidence_potential"]), + &"travel_minutes": int(waypoint[&"travel_minutes"]), + &"inspection_minutes": int(waypoint[&"inspection_minutes"]), + &"time_budget_penalty": int(waypoint[&"time_budget_penalty"]), + &"utility": int(waypoint[&"utility"]), + }) + return { + &"inspection_id": String(inspection_id), + &"inspector_id": String(inspector_id), + &"scheduled_at": scheduled_at, + &"entrance_cell": [entrance_cell.x, entrance_cell.y, entrance_cell.z], + &"state": State.keys()[state], + &"state_changed_at": state_changed_at, + &"time_budget_minutes": time_budget_minutes, + &"time_spent_minutes": time_spent_minutes, + &"waypoint_route": saved_route, + &"current_waypoint_index": current_waypoint_index, + &"visited_waypoint_ids": _names_to_strings(visited_waypoint_ids), + &"inaccessible_waypoint_ids": _names_to_strings(inaccessible_waypoint_ids), + &"discovered_evidence_ids": _names_to_strings(discovered_evidence_ids), + &"evidence_score": evidence_score, + &"result": Result.keys()[result], + &"result_reason": String(result_reason), + &"exit_reason": String(exit_reason), + &"resolution_id": String(resolution_id), + &"closed_at": closed_at, + &"revision": revision, + } + + +static func from_snapshot(snapshot: Dictionary) -> AuthorityInspection: + if not _snapshot_is_valid(snapshot): + return null + var inspection := AuthorityInspection.new().configure( + StringName(_value(snapshot, &"inspection_id", &"")), + StringName(_value(snapshot, &"inspector_id", &"")), + int(_value(snapshot, &"scheduled_at", -1)), + snapshot + ) + return inspection if inspection.is_valid() else null + + +static func _snapshot_is_valid(snapshot: Dictionary) -> bool: + for key: StringName in [ + &"inspection_id", &"inspector_id", &"scheduled_at", &"entrance_cell", + &"state", &"state_changed_at", &"time_budget_minutes", &"time_spent_minutes", + &"waypoint_route", &"current_waypoint_index", &"visited_waypoint_ids", + &"inaccessible_waypoint_ids", &"discovered_evidence_ids", &"evidence_score", + &"result", &"result_reason", &"exit_reason", &"resolution_id", + &"closed_at", &"revision", + ]: + if not _has_key(snapshot, key): + return false + if StringName(_value(snapshot, &"inspection_id", &"")).is_empty() \ + or StringName(_value(snapshot, &"inspector_id", &"")).is_empty(): + return false + if not _value(snapshot, &"entrance_cell", null) is Array \ + or (_value(snapshot, &"entrance_cell", []) as Array).size() != 3: + return false + for array_key: StringName in [ + &"waypoint_route", &"visited_waypoint_ids", &"inaccessible_waypoint_ids", + &"discovered_evidence_ids", + ]: + if not _value(snapshot, array_key, null) is Array: + return false + for names_key: StringName in [ + &"visited_waypoint_ids", &"inaccessible_waypoint_ids", &"discovered_evidence_ids", + ]: + if not _name_array_is_canonical(_value(snapshot, names_key, [])): + return false + if not State.keys().has(String(_value(snapshot, &"state", ""))) \ + or not Result.keys().has(String(_value(snapshot, &"result", ""))): + return false + if int(_value(snapshot, &"scheduled_at", -1)) < 0 \ + or int(_value(snapshot, &"state_changed_at", -1)) < 0 \ + or int(_value(snapshot, &"time_budget_minutes", 0)) <= 0 \ + or int(_value(snapshot, &"time_spent_minutes", -1)) < 0 \ + or int(_value(snapshot, &"current_waypoint_index", -1)) < 0 \ + or int(_value(snapshot, &"evidence_score", -1)) < 0 \ + or int(_value(snapshot, &"closed_at", -2)) < -1 \ + or int(_value(snapshot, &"revision", -1)) < 0: + return false + for raw_waypoint: Variant in _value(snapshot, &"waypoint_route", []): + if not raw_waypoint is Dictionary: + return false + for waypoint_key: StringName in [ + &"waypoint_id", &"cell", &"suspect_score", &"evidence_potential", + &"travel_minutes", &"inspection_minutes", &"time_budget_penalty", &"utility", + ]: + if not _has_key(raw_waypoint, waypoint_key): + return false + if not _value(raw_waypoint, &"cell", null) is Array \ + or (_value(raw_waypoint, &"cell", []) as Array).size() != 3: + return false + var normalized := normalize_waypoint(raw_waypoint) + if normalized.is_empty() or int(_value(raw_waypoint, &"utility", 2_147_483_647)) != int(normalized[&"utility"]): + return false + return true + + +static func normalize_waypoint(value: Dictionary) -> Dictionary: + var waypoint_id := StringName(_value(value, &"waypoint_id", &"")) + if waypoint_id.is_empty(): + return {} + var suspect_score := clampi(int(_value(value, &"suspect_score", 0)), 0, 100) + var evidence_potential := clampi(int(_value(value, &"evidence_potential", 0)), 0, 100) + var travel_minutes := maxi(int(_value(value, &"travel_minutes", 0)), 0) + var inspection_minutes := maxi(int(_value(value, &"inspection_minutes", 5)), 1) + var time_budget_penalty := maxi(int(_value(value, &"time_budget_penalty", 0)), 0) + return { + &"waypoint_id": waypoint_id, + &"cell": _vector3i(_value(value, &"cell", Vector3i.ZERO)), + &"suspect_score": suspect_score, + &"evidence_potential": evidence_potential, + &"travel_minutes": travel_minutes, + &"inspection_minutes": inspection_minutes, + &"time_budget_penalty": time_budget_penalty, + &"utility": suspect_score * 5 + evidence_potential * 4 - travel_minutes * 2 - time_budget_penalty, + } + + +static func _state_from_value(value: Variant) -> State: + if value is String or value is StringName: + var index := State.keys().find(String(value)) + return (index as State) if index >= 0 else State.SCHEDULED + var number := int(value) + return (number as State) if number >= 0 and number < State.size() else State.SCHEDULED + + +static func _result_from_value(value: Variant) -> Result: + if value is String or value is StringName: + var index := Result.keys().find(String(value)) + return (index as Result) if index >= 0 else Result.PENDING + var number := int(value) + return (number as Result) if number >= 0 and number < Result.size() else Result.PENDING + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() >= 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + if value is Dictionary: + return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0))) + return Vector3i.ZERO + + +static func _cell_to_array(value: Variant) -> Array[int]: + var cell := _vector3i(value) + return [cell.x, cell.y, cell.z] + + +static func _sorted_unique_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if values is Array: + for raw_value: Variant in values: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(_name_less) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _name_less(a: StringName, b: StringName) -> bool: + return String(a) < String(b) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _has_key(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _name_array_is_canonical(value: Variant) -> bool: + if not value is Array: + return false + var previous := "" + for index: int in range(value.size()): + var current := String(value[index]) + if current.is_empty() or (index > 0 and current <= previous): + return false + previous = current + return true diff --git a/scripts/authority/inspection.gd.uid b/scripts/authority/inspection.gd.uid new file mode 100644 index 0000000..4a2c51f --- /dev/null +++ b/scripts/authority/inspection.gd.uid @@ -0,0 +1 @@ +uid://6n3jyvx6a6lq diff --git a/scripts/construction/construction_job.gd b/scripts/construction/construction_job.gd new file mode 100644 index 0000000..c0758b4 --- /dev/null +++ b/scripts/construction/construction_job.gd @@ -0,0 +1,220 @@ +class_name ConstructionJob +extends RefCounted + +enum State { + PLANNED, + BUILDING, + COMPLETED, + CANCELLED, + BLOCKED, +} + +const MAX_PROGRESS := 10_000 +const NORMAL_WORK_RATE := 10_000 + +var job_id: StringName = &"" +var definition_id: StringName = &"" +var origin := Vector3i.ZERO +var rotation := 0 +var layer := 0 +var reserved_cells: Array[Vector3i] = [] +var progress := 0 +var state: State = State.PLANNED +var state_reason := "" +var assigned_builder_ids: Array[StringName] = [] +var construction_cost: Dictionary = {} +var build_minutes := 60 +var work_units_done := 0 +var created_at := 0 +var started_at := -1 +var completed_at := -1 +var cancelled_at := -1 +var facility_instance_id: StringName = &"" + + +func configure( + p_job_id: StringName, + p_definition_id: StringName, + p_origin: Vector3i, + p_rotation: int, + p_layer: int, + p_reserved_cells: Array[Vector3i], + p_construction_cost: Dictionary, + p_build_minutes: int, + p_created_at: int +) -> ConstructionJob: + job_id = p_job_id + definition_id = p_definition_id + origin = p_origin + rotation = _normalize_rotation(p_rotation) + layer = p_layer + reserved_cells.assign(p_reserved_cells) + construction_cost = _normalize_cost(p_construction_cost) + build_minutes = maxi(p_build_minutes, 1) + created_at = maxi(p_created_at, 0) + return self + + +func advance_one_minute(work_rate_basis_points: int = NORMAL_WORK_RATE, absolute_minute: int = -1) -> bool: + if state == State.COMPLETED or state == State.CANCELLED: + return false + if progress >= MAX_PROGRESS: + return true + if work_rate_basis_points <= 0: + mark_blocked("No effective construction work was supplied.") + return false + if started_at < 0: + started_at = absolute_minute + state = State.BUILDING + state_reason = "" + work_units_done += work_rate_basis_points + progress = mini(MAX_PROGRESS, work_units_done / build_minutes) + return progress >= MAX_PROGRESS + + +func mark_blocked(reason: String) -> void: + if state == State.COMPLETED or state == State.CANCELLED: + return + state = State.BLOCKED + state_reason = reason + + +func mark_completed(instance_id: StringName, absolute_minute: int = -1) -> void: + progress = MAX_PROGRESS + state = State.COMPLETED + state_reason = "" + facility_instance_id = instance_id + completed_at = absolute_minute + + +func mark_cancelled(absolute_minute: int = -1) -> void: + state = State.CANCELLED + state_reason = "" + cancelled_at = absolute_minute + + +func is_terminal() -> bool: + return state == State.COMPLETED or state == State.CANCELLED + + +func refundable_cost() -> Dictionary: + var refund: Dictionary = {} + var remaining_basis_points := maxi(MAX_PROGRESS - progress, 0) + for resource_name: Variant in construction_cost: + var amount := int(construction_cost[resource_name]) + refund[StringName(resource_name)] = amount * remaining_basis_points / MAX_PROGRESS + return refund + + +func create_snapshot() -> Dictionary: + return { + &"job_id": String(job_id), + &"definition_id": String(definition_id), + &"origin": [origin.x, origin.y, origin.z], + &"rotation": rotation, + &"layer": layer, + &"reserved_cells": _serialize_cells(reserved_cells), + &"progress": progress, + &"state": State.keys()[state], + &"state_reason": state_reason, + &"assigned_builder_ids": _string_names_to_strings(assigned_builder_ids), + &"construction_cost": construction_cost.duplicate(true), + &"build_minutes": build_minutes, + &"work_units_done": work_units_done, + &"created_at": created_at, + &"started_at": started_at, + &"completed_at": completed_at, + &"cancelled_at": cancelled_at, + &"facility_instance_id": String(facility_instance_id), + } + + +static func from_snapshot(snapshot: Dictionary) -> ConstructionJob: + if not _is_valid_snapshot(snapshot): + return null + var job := ConstructionJob.new() + job.job_id = StringName(snapshot.get(&"job_id", "")) + job.definition_id = StringName(snapshot.get(&"definition_id", "")) + job.origin = _parse_cell(snapshot.get(&"origin", [])) + job.rotation = _normalize_rotation(int(snapshot.get(&"rotation", 0))) + job.layer = int(snapshot.get(&"layer", job.origin.z)) + job.reserved_cells = _parse_cells(snapshot.get(&"reserved_cells", [])) + job.progress = clampi(int(snapshot.get(&"progress", 0)), 0, MAX_PROGRESS) + var state_index := State.keys().find(String(snapshot.get(&"state", "PLANNED"))) + if state_index < 0: + return null + job.state = state_index as State + job.state_reason = String(snapshot.get(&"state_reason", "")) + var saved_builders: Variant = snapshot.get(&"assigned_builder_ids", []) + if saved_builders is Array: + for builder_id: Variant in saved_builders: + job.assigned_builder_ids.append(StringName(builder_id)) + job.construction_cost = _normalize_cost(snapshot.get(&"construction_cost", {})) + job.build_minutes = maxi(int(snapshot.get(&"build_minutes", 60)), 1) + job.work_units_done = maxi(int(snapshot.get(&"work_units_done", 0)), 0) + job.created_at = maxi(int(snapshot.get(&"created_at", 0)), 0) + job.started_at = int(snapshot.get(&"started_at", -1)) + job.completed_at = int(snapshot.get(&"completed_at", -1)) + job.cancelled_at = int(snapshot.get(&"cancelled_at", -1)) + job.facility_instance_id = StringName(snapshot.get(&"facility_instance_id", "")) + return job + + +static func _is_valid_snapshot(snapshot: Dictionary) -> bool: + if String(snapshot.get(&"job_id", "")).is_empty(): + return false + if String(snapshot.get(&"definition_id", "")).is_empty(): + return false + if not snapshot.get(&"origin", null) is Array: + return false + if not snapshot.get(&"reserved_cells", null) is Array: + return false + if not snapshot.get(&"construction_cost", null) is Dictionary: + return false + return true + + +static func _normalize_cost(source: Variant) -> Dictionary: + var normalized: Dictionary = {} + if not source is Dictionary: + return normalized + for resource_name: Variant in source: + normalized[StringName(resource_name)] = maxi(int(source[resource_name]), 0) + return normalized + + +static func _normalize_rotation(value: int) -> int: + var normalized := value % 360 + if normalized < 0: + normalized += 360 + return (normalized / 90) * 90 + + +static func _parse_cell(source: Variant) -> Vector3i: + if not source is Array or source.size() != 3: + return Vector3i.ZERO + return Vector3i(int(source[0]), int(source[1]), int(source[2])) + + +static func _parse_cells(source: Variant) -> Array[Vector3i]: + var cells: Array[Vector3i] = [] + if not source is Array: + return cells + for value: Variant in source: + if value is Array and value.size() == 3: + cells.append(_parse_cell(value)) + return cells + + +static func _serialize_cells(source: Array[Vector3i]) -> Array[Array]: + var serialized: Array[Array] = [] + for cell: Vector3i in source: + serialized.append([cell.x, cell.y, cell.z]) + return serialized + + +static func _string_names_to_strings(source: Array[StringName]) -> Array[String]: + var serialized: Array[String] = [] + for value: StringName in source: + serialized.append(String(value)) + return serialized diff --git a/scripts/construction/construction_job.gd.uid b/scripts/construction/construction_job.gd.uid new file mode 100644 index 0000000..349913c --- /dev/null +++ b/scripts/construction/construction_job.gd.uid @@ -0,0 +1 @@ +uid://csul0q7j7m1a3 diff --git a/scripts/construction/construction_service.gd b/scripts/construction/construction_service.gd new file mode 100644 index 0000000..9da9a02 --- /dev/null +++ b/scripts/construction/construction_service.gd @@ -0,0 +1,1533 @@ +class_name ConstructionService +extends RefCounted + +signal job_created(job: ConstructionJob) +signal job_changed(job: ConstructionJob) +signal facility_completed(instance: Dictionary) +signal facility_demolished(instance_id: StringName, refund: Dictionary) + +const PlacementReportType = preload("res://scripts/construction/placement_report.gd") +const ConstructionJobType = preload("res://scripts/construction/construction_job.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 2 +const LEGACY_SNAPSHOT_VERSION := 1 +const DEFAULT_BUILD_MINUTES := 60 +const DEMOLITION_MATERIAL_REFUND_BASIS_POINTS := 2_500 +const MAX_PROGRESS := 10_000 +const MAX_WORK_RATE := 1_000_000 +const MAX_WORK_UNITS := 9_000_000_000_000_000 +const MAX_BUILD_MINUTES := 10_000_000 +const MAX_JOBS := 20_000 +const MAX_FACILITIES := 20_000 +const MAX_SEQUENCE := 99_999_999 +const DEFERRED_JOB_CHANGED: StringName = &"JOB_CHANGED" +const DEFERRED_FACILITY_COMPLETED: StringName = &"FACILITY_COMPLETED" + +var default_build_minutes := DEFAULT_BUILD_MINUTES +var current_minute := 0 + +var _world: Variant +var _resource_state: Variant +var _jobs: Dictionary = {} +var _facilities: Dictionary = {} +var _next_job_sequence := 1 +var _next_facility_sequence := 1 +var _last_progress_minutes: Dictionary = {} +var _mutation_in_progress := false +var _quarantined := false +var _quarantine_reason := "" +var _deferred_notifications: Array[Dictionary] = [] +var _notification_staging_active := false +var _notification_commit_active := false + + +func _init(world: Variant = null, resource_state: Variant = null) -> void: + _world = world + _resource_state = resource_state + + +func configure(world: Variant, resource_state: Variant) -> ConstructionService: + _world = world + _resource_state = resource_state + return self + + +func validate_placement( + definition: Variant, + origin: Vector3i, + rotation: int = 0 +) -> PlacementReport: + var report := PlacementReportType.new() + if not _definition_is_valid(definition): + report.add_error( + PlacementReportType.ERROR_INVALID_DEFINITION, + "The facility definition is missing or invalid." + ) + return report.finalize() + + var footprint: Vector2i = _definition_value(definition, &"footprint", Vector2i.ZERO) + var expected_zone := int(_definition_value(definition, &"zone", 0)) + var expected_layer := int(_definition_value(definition, &"layer", origin.z)) + report.total_cost = _normalize_cost(_definition_value(definition, &"construction_cost", {})) + report.build_time = _definition_build_minutes(definition) + + var missing_world_methods := _missing_methods( + _world, + [&"cells_for_footprint", &"is_in_bounds", &"get_zone", &"get_occupant", &"is_excavated", &"validate_support"] + ) + if not missing_world_methods.is_empty(): + report.add_error( + PlacementReportType.ERROR_MISSING_DEPENDENCY, + "World placement services are unavailable.", + [], + {&"methods": missing_world_methods} + ) + if _resource_state == null: + report.add_error( + PlacementReportType.ERROR_MISSING_DEPENDENCY, + "The resource state is unavailable." + ) + + if missing_world_methods.is_empty(): + report.cells = _world_cells_for_footprint( + origin, + footprint, + _normalize_rotation(rotation), + expected_layer + ) + if report.cells.is_empty(): + report.add_error( + PlacementReportType.ERROR_INVALID_DEFINITION, + "The footprint produced no world cells." + ) + _validate_cell_constraints(report, origin, expected_zone, expected_layer) + _validate_support(report, definition) + + _validate_cost(report) + return report.finalize() + + +func start_construction( + definition: Variant, + origin: Vector3i, + rotation: int = 0, + build_minutes: int = 0, + created_at: int = -1 +) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + if _jobs.size() >= MAX_JOBS or _next_job_sequence > MAX_SEQUENCE: + return _failure("CONSTRUCTION_LIMIT", "Construction job capacity has been reached.") + if build_minutes > MAX_BUILD_MINUTES: + return _failure("INVALID_BUILD_TIME", "Construction build time exceeds the supported limit.") + _mutation_in_progress = true + var report := validate_placement(definition, origin, rotation) + if not report.can_build: + _mutation_in_progress = false + return _failure("INVALID_PLACEMENT", "Facility placement is invalid.", { + &"placement_report": report.to_dictionary(), + }) + + var job_id := StringName("construction_%08d" % _next_job_sequence) + var resource_snapshot: Variant = _capture_resource_state() + var world_snapshot := _capture_world_state(report.cells) + if not _spend_cost(report.total_cost): + var payment_rolled_back := _restore_resource_state(resource_snapshot) + _mutation_in_progress = false + if not payment_rolled_back: + _quarantine("Construction payment failed after mutating resources and could not be rolled back.") + return _failure("PAYMENT_FAILED", "Construction resources changed before the command was applied.", { + &"cost": report.total_cost.duplicate(true), + &"rolled_back": payment_rolled_back, + &"rollback_failed": not payment_rolled_back, + }) + + if not _world_occupy(report.cells, job_id, &"construction"): + var world_rolled_back := _restore_world_state(world_snapshot) + var resources_rolled_back := _restore_resource_state(resource_snapshot) + var rolled_back := world_rolled_back and resources_rolled_back + _mutation_in_progress = false + if not rolled_back: + _quarantine("Construction reservation failed and transaction rollback was incomplete.") + return _failure("RESERVATION_FAILED", "World reservation failed.", { + &"job_id": job_id, + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + &"world_rolled_back": world_rolled_back, + &"resources_rolled_back": resources_rolled_back, + }) + + var effective_minutes := build_minutes if build_minutes > 0 else report.build_time + var effective_created_at := current_minute if created_at < 0 else created_at + var definition_id := StringName(_definition_value(definition, &"id", &"")) + var expected_layer := int(_definition_value(definition, &"layer", origin.z)) + var job := ConstructionJobType.new().configure( + job_id, + definition_id, + origin, + rotation, + expected_layer, + report.cells, + report.total_cost, + effective_minutes, + effective_created_at + ) + _jobs[job_id] = job + _last_progress_minutes[job_id] = -1 + _next_job_sequence += 1 + _mutation_in_progress = false + job_created.emit(job) + return CommandResultType.succeeded(0, { + &"job_id": job_id, + &"placement_report": report.to_dictionary(), + }) + + +func get_job(job_id: StringName) -> ConstructionJob: + return _jobs.get(job_id) as ConstructionJob + + +func get_jobs() -> Array[ConstructionJob]: + var result: Array[ConstructionJob] = [] + var ids := _sorted_string_name_keys(_jobs) + for job_id: StringName in ids: + result.append(_jobs[job_id]) + return result + + +func get_facility(instance_id: StringName) -> Dictionary: + if not _facilities.has(instance_id): + return {} + return (_facilities[instance_id] as Dictionary).duplicate(true) + + +func get_facilities() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var ids := _sorted_string_name_keys(_facilities) + for instance_id: StringName in ids: + result.append((_facilities[instance_id] as Dictionary).duplicate(true)) + return result + + +func assign_builders(job_id: StringName, builder_ids: Array[StringName]) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + var job := get_job(job_id) + if job == null: + return _not_found("Construction job", job_id) + if job.is_terminal(): + return _invalid_state("A terminal construction job cannot receive builders.", job_id) + job.assigned_builder_ids.assign(builder_ids) + _emit_job_changed(job) + return CommandResultType.succeeded(0, {&"job_id": job_id}) + + +func advance_one_minute( + absolute_minute: int = -1, + work_rates: Dictionary = {}, + defer_notifications := false +) -> Array[CommandResult]: + if _notification_staging_active or _notification_commit_active: + return [_notification_unavailable_result(&"NOTIFICATION_REENTRY", "Construction notification dispatch is already active.")] + if has_deferred_notifications(): + return [_notification_unavailable_result( + &"DEFERRED_NOTIFICATIONS_PENDING", + "Deferred construction notifications must be committed or discarded before another advance." + )] + if not _mutation_allowed(): + return [_unavailable_result()] + _notification_staging_active = defer_notifications + if absolute_minute < 0: + current_minute += 1 + else: + current_minute = maxi(absolute_minute, current_minute) + var results: Array[CommandResult] = [] + for job_id: StringName in _sorted_string_name_keys(_jobs): + var job: ConstructionJob = _jobs[job_id] + if job.is_terminal(): + continue + var raw_rate: Variant = work_rates.get(job_id, ConstructionJobType.NORMAL_WORK_RATE) + if not raw_rate is int: + results.append(_failure("INVALID_WORK_RATE", "Construction work rate must be an integer.")) + continue + results.append(progress_job(job_id, int(raw_rate), current_minute)) + _notification_staging_active = false + return results + + +func has_deferred_notifications() -> bool: + return not _deferred_notifications.is_empty() + + +func commit_deferred_notifications() -> bool: + if _notification_staging_active or _notification_commit_active or _mutation_in_progress: + return false + if _deferred_notifications.is_empty(): + return true + _notification_commit_active = true + var prepared: Array[Dictionary] = [] + for notification: Dictionary in _deferred_notifications: + var kind: StringName = notification.get(&"kind", &"") + match kind: + DEFERRED_JOB_CHANGED: + var saved_job: Variant = notification.get(&"payload", null) + if not saved_job is Dictionary: + _notification_commit_active = false + return false + var job := ConstructionJobType.from_snapshot(saved_job.duplicate(true)) + if job == null: + _notification_commit_active = false + return false + prepared.append({&"kind": kind, &"payload": job}) + DEFERRED_FACILITY_COMPLETED: + var saved_instance: Variant = notification.get(&"payload", null) + if not saved_instance is Dictionary: + _notification_commit_active = false + return false + prepared.append({&"kind": kind, &"payload": saved_instance.duplicate(true)}) + _: + _notification_commit_active = false + return false + _deferred_notifications.clear() + for notification: Dictionary in prepared: + match StringName(notification[&"kind"]): + DEFERRED_JOB_CHANGED: + job_changed.emit(notification[&"payload"]) + DEFERRED_FACILITY_COMPLETED: + facility_completed.emit((notification[&"payload"] as Dictionary).duplicate(true)) + _notification_commit_active = false + return true + + +func discard_deferred_notifications() -> bool: + if _notification_staging_active or _notification_commit_active or _mutation_in_progress: + return false + _deferred_notifications.clear() + return true + + +func _emit_job_changed(job: ConstructionJob) -> void: + if _notification_staging_active: + _deferred_notifications.append({ + &"kind": DEFERRED_JOB_CHANGED, + &"payload": job.create_snapshot().duplicate(true), + }) + return + job_changed.emit(job) + + +func _emit_facility_completed(instance: Dictionary) -> void: + var payload := instance.duplicate(true) + if _notification_staging_active: + _deferred_notifications.append({ + &"kind": DEFERRED_FACILITY_COMPLETED, + &"payload": payload, + }) + return + facility_completed.emit(payload) + + +func progress_job( + job_id: StringName, + work_rate_basis_points: int = ConstructionJob.NORMAL_WORK_RATE, + absolute_minute: int = -1 +) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + var job := get_job(job_id) + if job == null: + return _not_found("Construction job", job_id) + if job.is_terminal(): + return _invalid_state("A completed or cancelled job cannot progress.", job_id) + var minute := current_minute if absolute_minute < 0 else absolute_minute + if minute < 0: + return _failure("INVALID_MINUTE", "Construction progress minute cannot be negative.") + if work_rate_basis_points <= 0 or work_rate_basis_points > MAX_WORK_RATE: + return _failure("INVALID_WORK_RATE", "Construction work rate is outside the supported range.") + if minute <= int(_last_progress_minutes.get(job_id, -1)): + var replay := CommandResultType.succeeded(0, { + &"job_id": job_id, + &"progress": job.progress, + &"state": ConstructionJobType.State.keys()[job.state], + }) + replay.is_replay = true + return replay + if job.work_units_done > MAX_WORK_UNITS - work_rate_basis_points: + return _failure("WORK_LIMIT", "Construction work accumulator limit was reached.") + _mutation_in_progress = true + var ready_to_complete := job.advance_one_minute(work_rate_basis_points, minute) + _last_progress_minutes[job_id] = minute + if ready_to_complete: + var completed := _complete_job(job, minute) + _mutation_in_progress = false + return completed + _mutation_in_progress = false + _emit_job_changed(job) + return CommandResultType.succeeded(0, { + &"job_id": job_id, + &"progress": job.progress, + &"state": ConstructionJobType.State.keys()[job.state], + }) + + +func cancel_job(job_id: StringName, absolute_minute: int = -1) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + var job := get_job(job_id) + if job == null: + return _not_found("Construction job", job_id) + if job.is_terminal(): + return _invalid_state("A completed or cancelled job cannot be cancelled.", job_id) + + var minute := current_minute if absolute_minute < 0 else absolute_minute + if minute < 0: + return _failure("INVALID_MINUTE", "Construction cancellation minute cannot be negative.") + var refund := job.refundable_cost() + var resource_snapshot: Variant = _capture_resource_state() + var world_snapshot := _capture_world_state(job.reserved_cells) + _mutation_in_progress = true + if not _world_release(job.reserved_cells, job.job_id): + var release_rolled_back := _restore_world_state(world_snapshot) + _mutation_in_progress = false + if not release_rolled_back: + _quarantine("Construction cancellation release failed and world state could not be restored.") + return _failure("RELEASE_FAILED", "The construction reservation could not be released.", { + &"job_id": job_id, + &"rolled_back": release_rolled_back, + &"rollback_failed": not release_rolled_back, + }) + if not _refund_cost(refund): + var world_rolled_back := _restore_world_state(world_snapshot) + var resources_rolled_back := _restore_resource_state(resource_snapshot) + var rolled_back := world_rolled_back and resources_rolled_back + _mutation_in_progress = false + if not rolled_back: + _quarantine("Construction cancellation refund failed and rollback was incomplete.") + return _failure("REFUND_FAILED", "The cancellation refund failed.", { + &"job_id": job_id, + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + &"world_rolled_back": world_rolled_back, + &"resources_rolled_back": resources_rolled_back, + }) + + job.mark_cancelled(minute) + _mutation_in_progress = false + _emit_job_changed(job) + return CommandResultType.succeeded(0, { + &"job_id": job_id, + &"refund": refund, + &"progress": job.progress, + }) + + +func demolish_facility( + instance_id: StringName, + material_refund_basis_points: int = DEMOLITION_MATERIAL_REFUND_BASIS_POINTS +) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + if not _facilities.has(instance_id): + return _not_found("Facility instance", instance_id) + var instance: Dictionary = _facilities[instance_id] + var cells: Array[Vector3i] = [] + cells.assign(instance.get(&"occupied_cells", [])) + var refund: Dictionary = {} + var construction_cost: Dictionary = instance.get(&"construction_cost", {}) + var materials := int(construction_cost.get(&"materials", 0)) + if materials > 0: + refund[&"materials"] = materials * clampi(material_refund_basis_points, 0, 10_000) / 10_000 + + var resource_snapshot: Variant = _capture_resource_state() + var world_snapshot := _capture_world_state(cells) + _mutation_in_progress = true + if not _world_release(cells, instance_id): + var release_rolled_back := _restore_world_state(world_snapshot) + _mutation_in_progress = false + if not release_rolled_back: + _quarantine("Facility demolition release failed and world state could not be restored.") + return _failure("RELEASE_FAILED", "The facility occupancy could not be released.", { + &"instance_id": instance_id, + &"rolled_back": release_rolled_back, + &"rollback_failed": not release_rolled_back, + }) + if not _refund_cost(refund): + var world_rolled_back := _restore_world_state(world_snapshot) + var resources_rolled_back := _restore_resource_state(resource_snapshot) + var rolled_back := world_rolled_back and resources_rolled_back + _mutation_in_progress = false + if not rolled_back: + _quarantine("Facility demolition refund failed and rollback was incomplete.") + return _failure("REFUND_FAILED", "The demolition refund failed.", { + &"instance_id": instance_id, + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + &"world_rolled_back": world_rolled_back, + &"resources_rolled_back": resources_rolled_back, + }) + + _facilities.erase(instance_id) + _mutation_in_progress = false + facility_demolished.emit(instance_id, refund) + return CommandResultType.succeeded(0, { + &"instance_id": instance_id, + &"refund": refund, + }) + + +func create_snapshot() -> Dictionary: + if _notification_staging_active or _notification_commit_active or has_deferred_notifications(): + return {} + var saved_jobs: Array[Dictionary] = [] + for job_id: StringName in _sorted_string_name_keys(_jobs): + var job: ConstructionJob = _jobs[job_id] + var saved_job := job.create_snapshot() + saved_job[&"last_progress_at"] = int(_last_progress_minutes.get(job_id, -1)) + saved_jobs.append(saved_job) + var saved_facilities: Array[Dictionary] = [] + for instance_id: StringName in _sorted_string_name_keys(_facilities): + saved_facilities.append(_serialize_facility(_facilities[instance_id])) + return { + &"schema_version": SNAPSHOT_VERSION, + &"default_build_minutes": default_build_minutes, + &"current_minute": current_minute, + &"next_job_sequence": _next_job_sequence, + &"next_facility_sequence": _next_facility_sequence, + &"jobs": saved_jobs, + &"facilities": saved_facilities, + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _mutation_in_progress or _notification_staging_active \ + or _notification_commit_active or has_deferred_notifications(): + return false + var parsed := _parse_snapshot(snapshot) + if not bool(parsed.get(&"ok", false)): + return false + if not _validate_restored_state(parsed): + return false + _jobs = parsed[&"jobs"] + _facilities = parsed[&"facilities"] + _last_progress_minutes = parsed[&"last_progress_minutes"] + default_build_minutes = parsed[&"default_build_minutes"] + current_minute = parsed[&"current_minute"] + _next_job_sequence = parsed[&"next_job_sequence"] + _next_facility_sequence = parsed[&"next_facility_sequence"] + _quarantined = parsed[&"quarantined"] + _quarantine_reason = parsed[&"quarantine_reason"] + return true + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + var raw_version: Variant = snapshot.get(&"schema_version", null) + if not raw_version is int or int(raw_version) not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + return {&"ok": false} + var version := int(raw_version) + var top_level_keys: Array[StringName] = [ + &"schema_version", &"default_build_minutes", &"current_minute", + &"next_job_sequence", &"next_facility_sequence", &"jobs", &"facilities", + ] + if version == SNAPSHOT_VERSION: + top_level_keys.append_array([&"quarantined", &"quarantine_reason"]) + if not _has_exact_keys(snapshot, top_level_keys): + return {&"ok": false} + for counter_key: StringName in [ + &"default_build_minutes", &"current_minute", &"next_job_sequence", &"next_facility_sequence", + ]: + if not snapshot[counter_key] is int: + return {&"ok": false} + var restored_default := int(snapshot[&"default_build_minutes"]) + var restored_minute := int(snapshot[&"current_minute"]) + var job_sequence := int(snapshot[&"next_job_sequence"]) + var facility_sequence := int(snapshot[&"next_facility_sequence"]) + if ( + restored_default < 1 + or restored_default > MAX_BUILD_MINUTES + or restored_minute < 0 + or job_sequence < 1 + or job_sequence > MAX_SEQUENCE + 1 + or facility_sequence < 1 + or facility_sequence > MAX_SEQUENCE + 1 + ): + return {&"ok": false} + var saved_jobs: Variant = snapshot[&"jobs"] + var saved_facilities: Variant = snapshot[&"facilities"] + if ( + not saved_jobs is Array + or not saved_facilities is Array + or saved_jobs.size() > MAX_JOBS + or saved_facilities.size() > MAX_FACILITIES + ): + return {&"ok": false} + var quarantined := false + var quarantine_reason := "" + if version == SNAPSHOT_VERSION: + if not snapshot[&"quarantined"] is bool or not snapshot[&"quarantine_reason"] is String: + return {&"ok": false} + quarantined = snapshot[&"quarantined"] + quarantine_reason = snapshot[&"quarantine_reason"] + if quarantined == quarantine_reason.is_empty(): + return {&"ok": false} + + var restored_jobs: Dictionary = {} + var progress_minutes: Dictionary = {} + for job_data: Variant in saved_jobs: + if not job_data is Dictionary or not _validate_saved_job(job_data, version): + return {&"ok": false} + var construction_data: Dictionary = job_data.duplicate(true) + construction_data.erase(&"last_progress_at") + var job := ConstructionJobType.from_snapshot(construction_data) + if job == null or restored_jobs.has(job.job_id): + return {&"ok": false} + restored_jobs[job.job_id] = job + var last_progress := -1 + if version == SNAPSHOT_VERSION: + last_progress = int(job_data[&"last_progress_at"]) + elif job.work_units_done > 0: + last_progress = maxi(job.started_at, restored_minute) + progress_minutes[job.job_id] = last_progress + + var restored_facilities: Dictionary = {} + for facility_data: Variant in saved_facilities: + if not facility_data is Dictionary or not _validate_saved_facility(facility_data): + return {&"ok": false} + var instance := _deserialize_facility(facility_data) + var instance_id: StringName = instance[&"instance_id"] + if restored_facilities.has(instance_id): + return {&"ok": false} + restored_facilities[instance_id] = instance + return { + &"ok": true, + &"jobs": restored_jobs, + &"facilities": restored_facilities, + &"last_progress_minutes": progress_minutes, + &"default_build_minutes": restored_default, + &"current_minute": restored_minute, + &"next_job_sequence": job_sequence, + &"next_facility_sequence": facility_sequence, + &"quarantined": quarantined, + &"quarantine_reason": quarantine_reason, + } + + +func _validate_saved_job(saved: Dictionary, version: int) -> bool: + var keys: Array[StringName] = [ + &"job_id", &"definition_id", &"origin", &"rotation", &"layer", &"reserved_cells", + &"progress", &"state", &"state_reason", &"assigned_builder_ids", &"construction_cost", + &"build_minutes", &"work_units_done", &"created_at", &"started_at", &"completed_at", + &"cancelled_at", &"facility_instance_id", + ] + if version == SNAPSHOT_VERSION: + keys.append(&"last_progress_at") + if not _has_exact_keys(saved, keys): + return false + for text_key: StringName in [ + &"job_id", &"definition_id", &"state", &"state_reason", &"facility_instance_id", + ]: + if not saved[text_key] is String: + return false + if String(saved[&"job_id"]).is_empty() or String(saved[&"definition_id"]).is_empty(): + return false + if _parse_saved_cell(saved[&"origin"]) == null: + return false + var cells: Variant = _parse_saved_cells(saved[&"reserved_cells"]) + if cells == null or cells.is_empty(): + return false + var origin: Vector3i = _parse_saved_cell(saved[&"origin"]) + for integer_key: StringName in [ + &"rotation", &"layer", &"progress", &"build_minutes", &"work_units_done", + &"created_at", &"started_at", &"completed_at", &"cancelled_at", + ]: + if not saved[integer_key] is int: + return false + if version == SNAPSHOT_VERSION and not saved[&"last_progress_at"] is int: + return false + if int(saved[&"rotation"]) not in [0, 90, 180, 270] or int(saved[&"layer"]) not in [0, 1]: + return false + if origin.z != int(saved[&"layer"]) or not cells.has(origin): + return false + for cell: Vector3i in cells: + if cell.z != int(saved[&"layer"]): + return false + if not _valid_cost(saved[&"construction_cost"]): + return false + if not saved[&"assigned_builder_ids"] is Array: + return false + var builder_set: Dictionary = {} + for builder_id: Variant in saved[&"assigned_builder_ids"]: + if not builder_id is String or String(builder_id).is_empty() or builder_set.has(builder_id): + return false + builder_set[builder_id] = true + var progress := int(saved[&"progress"]) + var build_minutes := int(saved[&"build_minutes"]) + var work_done := int(saved[&"work_units_done"]) + if ( + progress < 0 + or progress > MAX_PROGRESS + or build_minutes < 1 + or build_minutes > MAX_BUILD_MINUTES + or work_done < 0 + or work_done > MAX_WORK_UNITS + or progress != mini(MAX_PROGRESS, work_done / build_minutes) + or int(saved[&"created_at"]) < 0 + or int(saved[&"started_at"]) < -1 + or int(saved[&"completed_at"]) < -1 + or int(saved[&"cancelled_at"]) < -1 + ): + return false + if work_done == 0 and int(saved[&"started_at"]) != -1: + return false + if work_done > 0 and int(saved[&"started_at"]) < 0: + return false + if version == SNAPSHOT_VERSION: + var last_progress := int(saved[&"last_progress_at"]) + if last_progress < -1: + return false + if work_done == 0 and last_progress != -1: + return false + if work_done > 0 and last_progress < int(saved[&"started_at"]): + return false + var state := String(saved[&"state"]) + if state not in ConstructionJobType.State.keys(): + return false + var facility_id := String(saved[&"facility_instance_id"]) + match state: + "COMPLETED": + if progress != MAX_PROGRESS or int(saved[&"completed_at"]) < 0 or int(saved[&"cancelled_at"]) != -1 or facility_id.is_empty() or not String(saved[&"state_reason"]).is_empty(): + return false + "CANCELLED": + if int(saved[&"cancelled_at"]) < 0 or int(saved[&"completed_at"]) != -1 or not facility_id.is_empty() or not String(saved[&"state_reason"]).is_empty(): + return false + "PLANNED": + if work_done != 0 or progress != 0 or int(saved[&"started_at"]) != -1 or not String(saved[&"state_reason"]).is_empty(): + return false + "BUILDING": + if work_done <= 0 or progress >= MAX_PROGRESS or not String(saved[&"state_reason"]).is_empty(): + return false + "BLOCKED": + if String(saved[&"state_reason"]).is_empty(): + return false + _: + if int(saved[&"completed_at"]) != -1 or int(saved[&"cancelled_at"]) != -1 or not facility_id.is_empty(): + return false + if state not in ["COMPLETED", "CANCELLED"] and ( + int(saved[&"completed_at"]) != -1 + or int(saved[&"cancelled_at"]) != -1 + or not facility_id.is_empty() + ): + return false + return true + + +func _validate_saved_facility(saved: Dictionary) -> bool: + var keys: Array[StringName] = [ + &"instance_id", &"definition_id", &"origin", &"rotation", &"layer", &"occupied_cells", + &"construction_cost", &"source_job_id", &"state", &"completed_at", + ] + if not _has_exact_keys(saved, keys): + return false + for text_key: StringName in [&"instance_id", &"definition_id", &"source_job_id", &"state"]: + if not saved[text_key] is String or String(saved[text_key]).is_empty(): + return false + if String(saved[&"state"]) != "ACTIVE" or _parse_saved_cell(saved[&"origin"]) == null: + return false + var cells: Variant = _parse_saved_cells(saved[&"occupied_cells"]) + if cells == null or cells.is_empty(): + return false + var origin: Vector3i = _parse_saved_cell(saved[&"origin"]) + for integer_key: StringName in [&"rotation", &"layer", &"completed_at"]: + if not saved[integer_key] is int: + return false + if ( + int(saved[&"rotation"]) not in [0, 90, 180, 270] + or int(saved[&"layer"]) not in [0, 1] + or int(saved[&"completed_at"]) < 0 + ): + return false + if origin.z != int(saved[&"layer"]) or not cells.has(origin): + return false + for cell: Vector3i in cells: + if cell.z != int(saved[&"layer"]): + return false + return _valid_cost(saved[&"construction_cost"]) + + +func _validate_restored_state(parsed: Dictionary) -> bool: + var restored_jobs: Dictionary = parsed[&"jobs"] + var restored_facilities: Dictionary = parsed[&"facilities"] + var progress_minutes: Dictionary = parsed[&"last_progress_minutes"] + if ( + (not restored_jobs.is_empty() or not restored_facilities.is_empty()) + and _missing_methods(_world, [&"is_in_bounds", &"get_occupant"]).size() > 0 + ): + return false + var job_high_water := 0 + var facility_high_water := 0 + for job_id: Variant in restored_jobs: + if not job_id is StringName or not progress_minutes.has(job_id): + return false + var job: ConstructionJob = restored_jobs[job_id] + if not _strict_bool_call(_world, &"is_in_bounds", [job.origin]): + return false + for cell: Vector3i in job.reserved_cells: + if not _strict_bool_call(_world, &"is_in_bounds", [cell]): + return false + var sequence := _sequence_from_id(String(job_id), "construction_") + if sequence < 1: + return false + job_high_water = maxi(job_high_water, sequence) + if job.state == ConstructionJobType.State.COMPLETED: + if not restored_facilities.has(job.facility_instance_id): + return false + elif job.state != ConstructionJobType.State.CANCELLED: + for cell: Vector3i in job.reserved_cells: + if _world_occupant(cell) != job.job_id: + return false + for instance_id: Variant in restored_facilities: + if not instance_id is StringName: + return false + var sequence := _sequence_from_id(String(instance_id), "facility_") + if sequence < 1: + return false + facility_high_water = maxi(facility_high_water, sequence) + var instance: Dictionary = restored_facilities[instance_id] + if not _strict_bool_call(_world, &"is_in_bounds", [instance[&"origin"]]): + return false + var source_id: StringName = instance[&"source_job_id"] + if not restored_jobs.has(source_id): + return false + var source_job: ConstructionJob = restored_jobs[source_id] + if ( + source_job.state != ConstructionJobType.State.COMPLETED + or source_job.facility_instance_id != instance_id + or source_job.definition_id != instance[&"definition_id"] + or source_job.origin != instance[&"origin"] + or source_job.rotation != int(instance[&"rotation"]) + or source_job.layer != int(instance[&"layer"]) + or source_job.reserved_cells != instance[&"occupied_cells"] + or source_job.construction_cost != instance[&"construction_cost"] + or source_job.completed_at != int(instance[&"completed_at"]) + ): + return false + for cell: Vector3i in instance[&"occupied_cells"]: + if _world_occupant(cell) != instance_id: + return false + return ( + job_high_water < int(parsed[&"next_job_sequence"]) + and facility_high_water < int(parsed[&"next_facility_sequence"]) + ) + + +static func _parse_saved_cell(value: Variant) -> Variant: + if not value is Array or value.size() != 3: + return null + if not value[0] is int or not value[1] is int or not value[2] is int: + return null + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + + +static func _parse_saved_cells(value: Variant) -> Variant: + if not value is Array: + return null + var result: Array[Vector3i] = [] + var seen: Dictionary = {} + for raw_cell: Variant in value: + var cell: Variant = _parse_saved_cell(raw_cell) + if cell == null or seen.has(cell): + return null + seen[cell] = true + result.append(cell) + return result + + +static func _sequence_from_id(value: String, prefix: String) -> int: + if not value.begins_with(prefix) or value.length() != prefix.length() + 8: + return -1 + var suffix := value.substr(prefix.length()) + if not suffix.is_valid_int(): + return -1 + return int(suffix) + + +func _validate_cell_constraints( + report: PlacementReport, + origin: Vector3i, + expected_zone: int, + expected_layer: int +) -> void: + if origin.z != expected_layer: + report.layer_mismatch_cells.append(origin) + if expected_zone == 2 and origin.y != 0: + report.zone_mismatch_cells.append(origin) + for cell: Vector3i in report.cells: + var in_bounds := _strict_bool_call(_world, &"is_in_bounds", [cell]) + if not in_bounds: + report.out_of_bounds_cells.append(cell) + if cell.z != expected_layer and not report.layer_mismatch_cells.has(cell): + report.layer_mismatch_cells.append(cell) + if not in_bounds: + continue + var zone_mismatch := false + if expected_zone == 2: + zone_mismatch = cell.y < 0 + elif expected_zone != 0: + zone_mismatch = not _zone_matches(expected_zone, _world.call(&"get_zone", cell)) + if zone_mismatch and not report.zone_mismatch_cells.has(cell): + report.zone_mismatch_cells.append(cell) + var occupant: Variant = _world.call(&"get_occupant", cell) + if not _occupant_is_empty(occupant): + report.occupied_cells.append(cell) + if cell.y < 0 and not _strict_bool_call(_world, &"is_excavated", [cell]): + report.unexcavated_cells.append(cell) + + if not report.out_of_bounds_cells.is_empty(): + report.add_error( + PlacementReportType.ERROR_OUT_OF_BOUNDS, + "One or more footprint cells are outside the current map bounds.", + report.out_of_bounds_cells + ) + if not report.zone_mismatch_cells.is_empty(): + report.add_error( + PlacementReportType.ERROR_ZONE_MISMATCH, + "The facility is not permitted in one or more map zones.", + report.zone_mismatch_cells, + {&"expected_zone": expected_zone} + ) + if not report.layer_mismatch_cells.is_empty(): + report.add_error( + PlacementReportType.ERROR_LAYER_MISMATCH, + "The footprint layer differs from the facility definition.", + report.layer_mismatch_cells, + {&"expected_layer": expected_layer} + ) + if not report.occupied_cells.is_empty(): + report.add_error( + PlacementReportType.ERROR_OVERLAP, + "One or more footprint cells are already occupied or reserved.", + report.occupied_cells + ) + if not report.unexcavated_cells.is_empty(): + report.add_error( + PlacementReportType.ERROR_NOT_EXCAVATED, + "Underground facilities require every footprint cell to be excavated first.", + report.unexcavated_cells + ) + + +func _validate_support(report: PlacementReport, definition: Variant) -> void: + if report.cells.is_empty(): + return + var weight_per_cell := maxi(int(_definition_value(definition, &"weight_per_cell", 1)), 1) + var support_result: Variant = _call_by_supported_arity( + _world, + &"validate_support", + [report.cells, weight_per_cell] + ) + var supported := true + var reason := "The structure does not have sufficient support." + if support_result is bool: + supported = support_result + if not supported: + report.unsupported_cells.assign(report.cells) + elif support_result is Array: + for cell: Variant in support_result: + if cell is Vector3i: + report.unsupported_cells.append(cell) + supported = report.unsupported_cells.is_empty() + elif support_result is Dictionary: + var raw_ok: Variant = support_result.get(&"ok", support_result.get("ok", null)) + supported = raw_ok if raw_ok is bool else false + reason = String(support_result.get(&"reason", support_result.get("reason", reason))) + var unsupported: Variant = support_result.get( + &"unsupported_cells", + support_result.get("unsupported_cells", []) + ) + if unsupported is Array: + for cell: Variant in unsupported: + if cell is Vector3i: + report.unsupported_cells.append(cell) + if not supported and report.unsupported_cells.is_empty(): + report.unsupported_cells.assign(report.cells) + elif support_result is Object and support_result.has_method(&"is_success"): + var raw_success: Variant = support_result.call(&"is_success") + supported = raw_success if raw_success is bool else false + if not supported: + report.unsupported_cells.assign(report.cells) + + if not supported: + report.add_error( + PlacementReportType.ERROR_UNSUPPORTED, + reason, + report.unsupported_cells + ) + + +func _validate_cost(report: PlacementReport) -> void: + if _resource_state == null: + return + if _can_afford(report.total_cost): + return + for resource_name: Variant in report.total_cost: + var required := int(report.total_cost[resource_name]) + var available := _resource_amount(StringName(resource_name)) + if available < required: + report.missing_resources[StringName(resource_name)] = required - available + report.add_error( + PlacementReportType.ERROR_INSUFFICIENT_RESOURCES, + "Available resources do not cover the complete construction cost.", + [], + {&"missing": report.missing_resources.duplicate(true)} + ) + + +func _complete_job(job: ConstructionJob, absolute_minute: int) -> CommandResult: + if _facilities.size() >= MAX_FACILITIES or _next_facility_sequence > MAX_SEQUENCE: + job.mark_blocked("Facility capacity has been reached.") + _emit_job_changed(job) + return _failure("FACILITY_LIMIT", job.state_reason, {&"job_id": job.job_id}) + var instance_id := StringName("facility_%08d" % _next_facility_sequence) + var world_snapshot := _capture_world_state(job.reserved_cells) + if not _world_release(job.reserved_cells, job.job_id): + var release_rolled_back := _restore_world_state(world_snapshot) + job.mark_blocked("The construction reservation could not be released for completion.") + if not release_rolled_back: + _quarantine("Construction completion release failed and world state could not be restored.") + _emit_job_changed(job) + return _failure("RELEASE_FAILED", job.state_reason, { + &"job_id": job.job_id, + &"rolled_back": release_rolled_back, + &"rollback_failed": not release_rolled_back, + }) + if not _world_occupy(job.reserved_cells, instance_id, &"facility"): + var rolled_back := _restore_world_state(world_snapshot) + job.mark_blocked("Facility occupancy failed during construction completion.") + if not rolled_back: + _quarantine("Facility occupancy failed and the construction reservation could not be restored.") + _emit_job_changed(job) + return _failure("FACILITY_OCCUPANCY_FAILED", job.state_reason, { + &"job_id": job.job_id, + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + }) + + var instance := { + &"instance_id": instance_id, + &"definition_id": job.definition_id, + &"origin": job.origin, + &"rotation": job.rotation, + &"layer": job.layer, + &"occupied_cells": job.reserved_cells.duplicate(), + &"construction_cost": job.construction_cost.duplicate(true), + &"source_job_id": job.job_id, + &"state": &"ACTIVE", + &"completed_at": absolute_minute, + } + _facilities[instance_id] = instance + _next_facility_sequence += 1 + job.mark_completed(instance_id, absolute_minute) + _emit_job_changed(job) + _emit_facility_completed(instance) + return CommandResultType.succeeded(0, { + &"job_id": job.job_id, + &"instance_id": instance_id, + &"progress": job.progress, + }) + + +func _world_cells_for_footprint( + origin: Vector3i, + footprint: Vector2i, + rotation: int, + layer: int +) -> Array[Vector3i]: + var argument_count := _method_argument_count(_world, &"cells_for_footprint") + var returned: Variant + if argument_count <= 2: + var rotated_footprint := footprint + if rotation == 90 or rotation == 270: + rotated_footprint = Vector2i(footprint.y, footprint.x) + returned = _world.callv(&"cells_for_footprint", [origin, rotated_footprint]) + else: + returned = _call_by_supported_arity( + _world, + &"cells_for_footprint", + [origin, footprint, rotation, layer] + ) + var cells: Array[Vector3i] = [] + var seen: Dictionary = {} + if not returned is Array or returned.size() != footprint.x * footprint.y: + return cells + for cell: Variant in returned: + if not cell is Vector3i or seen.has(cell): + return [] + seen[cell] = true + cells.append(cell) + return cells + + +func _world_occupy(cells: Array[Vector3i], occupant_id: StringName, kind: StringName) -> bool: + if _world == null or not _world.has_method(&"occupy"): + return false + var argument_count := _method_argument_count(_world, &"occupy") + var arguments: Array = [occupant_id, cells, kind] + var returned: Variant = _world.callv(&"occupy", arguments.slice(0, argument_count)) + return _operation_succeeded(returned) + + +func _world_release(cells: Array[Vector3i], occupant_id: StringName) -> bool: + if _world == null or not _world.has_method(&"release"): + return false + var argument_count := _method_argument_count(_world, &"release") + var arguments: Array = [occupant_id] if argument_count == 1 else [occupant_id, cells] + var returned: Variant = _world.callv(&"release", arguments.slice(0, argument_count)) + return _operation_succeeded(returned) + + +func _definition_build_minutes(definition: Variant) -> int: + var configured := int(_definition_value(definition, &"build_minutes", 0)) + return configured if configured > 0 else maxi(default_build_minutes, 1) + + +func _definition_is_valid(definition: Variant) -> bool: + if definition == null: + return false + if definition is Object and definition.has_method(&"is_valid"): + var valid_result: Variant = definition.call(&"is_valid") + if not valid_result is bool or not valid_result: + return false + var raw_id: Variant = _definition_value(definition, &"id", null) + var footprint: Variant = _definition_value(definition, &"footprint", null) + var cost: Variant = _definition_value(definition, &"construction_cost", null) + var minutes: Variant = _definition_value(definition, &"build_minutes", default_build_minutes) + return ( + (raw_id is String or raw_id is StringName) + and not String(raw_id).is_empty() + and footprint is Vector2i + and footprint.x > 0 + and footprint.y > 0 + and _valid_cost(cost) + and minutes is int + and int(minutes) > 0 + and int(minutes) <= MAX_BUILD_MINUTES + ) + + +func _definition_value(source: Variant, property_name: StringName, fallback: Variant) -> Variant: + if source is Dictionary: + return source.get(property_name, source.get(String(property_name), fallback)) + if source == null or not source.has_method(&"get_property_list"): + return fallback + for property: Dictionary in source.get_property_list(): + if StringName(property.get(&"name", &"")) == property_name: + return source.get(property_name) + return fallback + + +func _can_afford(cost: Dictionary) -> bool: + if _resource_state == null: + return false + if _resource_state.has_method(&"can_afford"): + return _operation_succeeded(_resource_state.call(&"can_afford", cost)) + for resource_name: Variant in cost: + if _resource_amount(StringName(resource_name)) < int(cost[resource_name]): + return false + return true + + +func _spend_cost(cost: Dictionary) -> bool: + if _resource_state == null: + return false + if _resource_state.has_method(&"spend"): + return _operation_succeeded(_resource_state.call(&"spend", cost)) + if not _can_afford(cost): + return false + var deltas: Dictionary = {} + for resource_name: Variant in cost: + deltas[StringName(resource_name)] = -int(cost[resource_name]) + if _resource_state.has_method(&"apply_resource_deltas"): + return _operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas)) + return false + + +func _refund_cost(refund: Dictionary) -> bool: + if refund.is_empty(): + return true + if _resource_state == null: + return false + if _resource_state.has_method(&"apply_resource_deltas"): + return _operation_succeeded(_resource_state.call(&"apply_resource_deltas", refund)) + if _resource_state.has_method(&"add_resource"): + for resource_name: Variant in refund: + var returned: Variant = _resource_state.call( + &"add_resource", + StringName(resource_name), + int(refund[resource_name]) + ) + if not returned is int and not (returned is bool and returned): + return false + return true + return false + + +func _resource_amount(resource_name: StringName) -> int: + if _resource_state == null: + return 0 + if _resource_state.has_method(&"get_resource"): + var returned: Variant = _resource_state.call(&"get_resource", resource_name) + return returned if returned is int else 0 + var resources: Variant = _definition_value(_resource_state, &"resources", {}) + if resources is Dictionary: + var amount: Variant = resources.get(resource_name, resources.get(String(resource_name), 0)) + return amount if amount is int else 0 + return 0 + + +func _capture_resource_state() -> Variant: + var captured := { + &"amounts": { + &"gold": _resource_amount(&"gold"), + &"materials": _resource_amount(&"materials"), + }, + &"has_native": false, + &"native_snapshot": null, + } + if _resource_state != null and _resource_state.has_method(&"create_snapshot"): + captured[&"has_native"] = true + captured[&"native_snapshot"] = _resource_state.call(&"create_snapshot") + return captured + + +func _restore_resource_state(snapshot: Variant) -> bool: + if not snapshot is Dictionary or _resource_state == null: + return false + var expected: Variant = snapshot.get(&"amounts", null) + if not expected is Dictionary: + return false + if bool(snapshot.get(&"has_native", false)) and _resource_state.has_method(&"restore_snapshot"): + _operation_succeeded(_resource_state.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null))) + elif _resource_state.has_method(&"apply_resource_deltas"): + var deltas: Dictionary = {} + for resource_name: StringName in [&"gold", &"materials"]: + var expected_amount: Variant = expected.get(resource_name, null) + if not expected_amount is int: + return false + deltas[resource_name] = int(expected_amount) - _resource_amount(resource_name) + _operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas)) + else: + return false + return _resource_amounts_match(expected) + + +func _capture_world_state(cells: Array[Vector3i]) -> Dictionary: + var occupants: Array[Dictionary] = [] + for cell: Vector3i in cells: + var occupant := _world_occupant(cell) + var kind := &"" + if _world != null and _world.has_method(&"get_occupant_kind"): + var raw_kind: Variant = _world.call(&"get_occupant_kind", cell) + if raw_kind is String or raw_kind is StringName: + kind = StringName(raw_kind) + if kind.is_empty() and not occupant.is_empty(): + kind = &"construction" if String(occupant).begins_with("construction_") else &"facility" + occupants.append({&"cell": cell, &"occupant_id": occupant, &"kind": kind}) + var captured := { + &"occupants": occupants, + &"has_native": false, + &"native_snapshot": null, + } + if _world != null and _world.has_method(&"create_snapshot"): + captured[&"has_native"] = true + captured[&"native_snapshot"] = _world.call(&"create_snapshot") + return captured + + +func _restore_world_state(snapshot: Variant) -> bool: + if not snapshot is Dictionary or _world == null: + return false + var occupants: Variant = snapshot.get(&"occupants", null) + if not occupants is Array: + return false + if bool(snapshot.get(&"has_native", false)) and _world.has_method(&"restore_snapshot"): + _operation_succeeded(_world.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null))) + else: + var cells: Array[Vector3i] = [] + var ids_to_release: Dictionary = {} + for entry: Variant in occupants: + if not entry is Dictionary or not entry.get(&"cell", null) is Vector3i: + return false + var cell: Vector3i = entry[&"cell"] + cells.append(cell) + var current_id := _world_occupant(cell) + if not current_id.is_empty(): + ids_to_release[current_id] = true + for occupant_id: Variant in ids_to_release: + _world_release(cells, StringName(occupant_id)) + var groups: Dictionary = {} + for entry: Dictionary in occupants: + var expected_id := StringName(entry[&"occupant_id"]) + if expected_id.is_empty(): + continue + if not groups.has(expected_id): + groups[expected_id] = {&"cells": [], &"kind": StringName(entry[&"kind"])} + groups[expected_id][&"cells"].append(entry[&"cell"]) + for expected_id: Variant in groups: + var restored_cells: Array[Vector3i] = [] + restored_cells.assign(groups[expected_id][&"cells"]) + _world_occupy(restored_cells, StringName(expected_id), groups[expected_id][&"kind"]) + return _world_state_matches(occupants) + + +func _world_state_matches(occupants: Variant) -> bool: + if not occupants is Array: + return false + for entry: Variant in occupants: + if not entry is Dictionary or not entry.get(&"cell", null) is Vector3i: + return false + if _world_occupant(entry[&"cell"]) != StringName(entry.get(&"occupant_id", &"")): + return false + return true + + +func _world_occupant(cell: Vector3i) -> StringName: + if _world == null or not _world.has_method(&"get_occupant"): + return &"" + var returned: Variant = _world.call(&"get_occupant", cell) + if returned is String or returned is StringName: + return StringName(returned) + return &"" + + +func _resource_amounts_match(expected: Dictionary) -> bool: + for resource_name: StringName in [&"gold", &"materials"]: + var amount: Variant = expected.get(resource_name, null) + if not amount is int or _resource_amount(resource_name) != int(amount): + return false + return true + + +func _serialize_facility(instance: Dictionary) -> Dictionary: + var serialized := instance.duplicate(true) + serialized[&"instance_id"] = String(instance.get(&"instance_id", &"")) + serialized[&"definition_id"] = String(instance.get(&"definition_id", &"")) + serialized[&"source_job_id"] = String(instance.get(&"source_job_id", &"")) + serialized[&"state"] = String(instance.get(&"state", &"ACTIVE")) + var origin: Vector3i = instance.get(&"origin", Vector3i.ZERO) + serialized[&"origin"] = [origin.x, origin.y, origin.z] + var saved_cells: Array[Array] = [] + for cell: Vector3i in instance.get(&"occupied_cells", []): + saved_cells.append([cell.x, cell.y, cell.z]) + serialized[&"occupied_cells"] = saved_cells + return serialized + + +func _deserialize_facility(saved: Dictionary) -> Dictionary: + var instance := saved.duplicate(true) + instance[&"instance_id"] = StringName(saved.get(&"instance_id", "")) + instance[&"definition_id"] = StringName(saved.get(&"definition_id", "")) + instance[&"source_job_id"] = StringName(saved.get(&"source_job_id", "")) + instance[&"state"] = StringName(saved.get(&"state", "ACTIVE")) + var origin: Variant = saved.get(&"origin", []) + if origin is Array and origin.size() == 3: + instance[&"origin"] = Vector3i(int(origin[0]), int(origin[1]), int(origin[2])) + else: + instance[&"origin"] = Vector3i.ZERO + var cells: Array[Vector3i] = [] + var saved_cells: Variant = saved.get(&"occupied_cells", []) + if saved_cells is Array: + for cell: Variant in saved_cells: + if cell is Array and cell.size() == 3: + cells.append(Vector3i(int(cell[0]), int(cell[1]), int(cell[2]))) + instance[&"occupied_cells"] = cells + instance[&"construction_cost"] = _normalize_cost(saved.get(&"construction_cost", {})) + return instance + + +func _not_found(entity_name: String, entity_id: StringName) -> CommandResult: + return CommandResultType.failed( + CommandResultType.Code.NOT_FOUND, + 0, + "%s '%s' was not found." % [entity_name, entity_id], + {&"id": entity_id} + ) + + +func _invalid_state(message: String, entity_id: StringName) -> CommandResult: + return CommandResultType.failed( + CommandResultType.Code.VALIDATION_FAILED, + 0, + message, + {&"id": entity_id} + ) + + +func _failure(code: StringName, message: String, details: Dictionary = {}) -> CommandResult: + var merged := details.duplicate(true) + merged[&"code"] = code + return CommandResultType.failed( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + 0, + message, + merged + ) + + +func _notification_unavailable_result(code: StringName, message: String) -> CommandResult: + return _failure(code, message) + + +func is_quarantined() -> bool: + return _quarantined + + +func get_quarantine_reason() -> String: + return _quarantine_reason + + +func _mutation_allowed() -> bool: + return ( + not _mutation_in_progress + and not _notification_commit_active + and not _quarantined + and (_notification_staging_active or not has_deferred_notifications()) + ) + + +func _unavailable_result() -> CommandResult: + var code: StringName = &"SERVICE_QUARANTINED" if _quarantined else &"REENTRANT_MUTATION" + var message := ( + "Construction service is quarantined: %s" % _quarantine_reason + if _quarantined + else "A reentrant construction mutation was rejected." + ) + return _failure(code, message, {&"quarantine_reason": _quarantine_reason}) + + +func _quarantine(reason: String) -> void: + _quarantined = true + _quarantine_reason = reason + + +static func _normalize_cost(source: Variant) -> Dictionary: + var normalized: Dictionary = {} + if not _valid_cost(source): + return normalized + for resource_name: StringName in [&"gold", &"materials"]: + normalized[resource_name] = int(source[resource_name]) + return normalized + + +static func _valid_cost(source: Variant) -> bool: + if not source is Dictionary or source.size() != 2: + return false + for resource_name: StringName in [&"gold", &"materials"]: + if not source.has(resource_name): + return false + var amount: Variant = source[resource_name] + if not amount is int or int(amount) < 0: + return false + return true + + +static func _has_exact_keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + for key: Variant in expected: + if not source.has(key): + return false + return true + + +static func _normalize_rotation(value: int) -> int: + var normalized := value % 360 + if normalized < 0: + normalized += 360 + return (normalized / 90) * 90 + + +static func _zone_matches(expected_zone: int, actual_zone: Variant) -> bool: + if actual_zone is int: + return int(actual_zone) == expected_zone + var normalized := String(actual_zone).to_upper() + match expected_zone: + 1: + return normalized == "ABOVE_GROUND" + 2: + return normalized == "BOUNDARY" + 3: + return normalized == "UNDERGROUND" + return false + + +static func _occupant_is_empty(occupant: Variant) -> bool: + if occupant == null: + return true + match typeof(occupant): + TYPE_BOOL: + return not bool(occupant) + TYPE_INT, TYPE_FLOAT: + return float(occupant) == 0.0 + TYPE_STRING, TYPE_STRING_NAME: + return String(occupant).is_empty() + return false + + +static func _operation_succeeded(returned: Variant) -> bool: + if returned is bool: + return returned + if returned is Object and returned.has_method(&"is_success"): + var result: Variant = returned.call(&"is_success") + return result if result is bool else false + return false + + +static func _strict_bool_call(target: Variant, method_name: StringName, arguments: Array) -> bool: + if target == null or not target.has_method(method_name): + return false + var returned: Variant = target.callv(method_name, arguments) + return returned if returned is bool else false + + +static func _missing_methods(target: Variant, methods: Array[StringName]) -> Array[String]: + var missing: Array[String] = [] + if target == null: + for method_name: StringName in methods: + missing.append(String(method_name)) + return missing + for method_name: StringName in methods: + if not target.has_method(method_name): + missing.append(String(method_name)) + return missing + + +static func _call_by_supported_arity(target: Variant, method_name: StringName, arguments: Array) -> Variant: + if target == null or not target.has_method(method_name): + return null + var argument_count := mini(arguments.size(), _method_argument_count(target, method_name)) + return target.callv(method_name, arguments.slice(0, argument_count)) + + +static func _method_argument_count(target: Variant, method_name: StringName) -> int: + if target == null or not target.has_method(method_name): + return 0 + for method: Dictionary in target.get_method_list(): + if StringName(method.get(&"name", &"")) != method_name: + continue + var declared_arguments: Variant = method.get(&"args", []) + if declared_arguments is Array: + return declared_arguments.size() + return 0 + + +static func _sorted_string_name_keys(source: Dictionary) -> Array[StringName]: + var as_strings: Array[String] = [] + for key: Variant in source: + as_strings.append(String(key)) + as_strings.sort() + var result: Array[StringName] = [] + for value: String in as_strings: + result.append(StringName(value)) + return result diff --git a/scripts/construction/construction_service.gd.uid b/scripts/construction/construction_service.gd.uid new file mode 100644 index 0000000..8780830 --- /dev/null +++ b/scripts/construction/construction_service.gd.uid @@ -0,0 +1 @@ +uid://cmjm3lvgmc06d diff --git a/scripts/construction/excavation_service.gd b/scripts/construction/excavation_service.gd new file mode 100644 index 0000000..4bde61b --- /dev/null +++ b/scripts/construction/excavation_service.gd @@ -0,0 +1,1034 @@ +class_name ExcavationService +extends RefCounted + +## Deterministic underground excavation queue. +## +## Excavation is owned per spatial X/Y cell. Completing one job unlocks both +## FRONT and BACK through WorldService.set_excavated(). A job can be paused and +## resumed without losing work, and every new cell must remain connected to the +## boundary through already excavated cells. + +signal job_created(job: Dictionary) +signal job_changed(job: Dictionary) +signal excavation_completed(report: Dictionary) + +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 2 +const LEGACY_SNAPSHOT_VERSION := 1 +const MAX_PROGRESS := 10_000 +const NORMAL_WORK_RATE := 10_000 +const MAX_WORK_RATE := 1_000_000 +const MAX_WORK_UNITS := 9_000_000_000_000_000 +const MAX_HARDNESS := 3 +const MAX_JOBS := 20_000 +const MAX_SEQUENCE := 99_999_999 + +const STATE_ACTIVE: StringName = &"ACTIVE" +const STATE_PAUSED: StringName = &"PAUSED" +const STATE_COMPLETED: StringName = &"COMPLETED" + +const ERROR_OUT_OF_BOUNDS: StringName = &"OUT_OF_BOUNDS" +const ERROR_NOT_UNDERGROUND: StringName = &"NOT_UNDERGROUND" +const ERROR_ALREADY_EXCAVATED: StringName = &"ALREADY_EXCAVATED" +const ERROR_ALREADY_RESERVED: StringName = &"ALREADY_RESERVED" +const ERROR_NO_EXCAVATED_NEIGHBOR: StringName = &"NO_EXCAVATED_NEIGHBOR" +const ERROR_NO_ENTRANCE_CONNECTION: StringName = &"NO_ENTRANCE_CONNECTION" +const ERROR_SUPPORT_REQUIRED: StringName = &"SUPPORT_REQUIRED" +const ERROR_INSUFFICIENT_RESOURCES: StringName = &"INSUFFICIENT_RESOURCES" +const ERROR_MISSING_DEPENDENCY: StringName = &"MISSING_DEPENDENCY" + +const HARDNESS_RULES := { + 0: {&"gold": 20, &"materials": 1, &"minutes": 30}, + 1: {&"gold": 50, &"materials": 1, &"minutes": 60}, + 2: {&"gold": 100, &"materials": 2, &"minutes": 120}, + 3: {&"gold": 200, &"materials": 4, &"minutes": 240}, +} + +var current_minute := 0 +var last_error := "" + +var _world: Variant +var _resource_state: Variant +var _hardness: Dictionary = {} +var _jobs: Dictionary = {} +var _cell_jobs: Dictionary = {} +var _next_job_sequence := 1 +var _external_mutation_in_progress := false +var _quarantined := false +var _quarantine_reason := "" + + +func _init(world: Variant = null, resource_state: Variant = null) -> void: + _world = world + _resource_state = resource_state + + +func configure(world: Variant, resource_state: Variant) -> ExcavationService: + _world = world + _resource_state = resource_state + return self + + +func set_hardness(cell_or_position: Variant, hardness: int) -> bool: + if not _mutation_allowed(): + return false + var position: Variant = _spatial_position(cell_or_position) + if position == null or hardness < 0 or hardness > MAX_HARDNESS: + return _fail("Excavation hardness or position is invalid.") + var front := Vector3i(position.x, position.y, 0) + if not _world_ready() or not _call_bool(_world, &"is_in_bounds", [front]) or position.y >= 0: + return _fail("Hardness can only be assigned to an in-bounds underground cell.") + if _cell_jobs.has(position) or _call_bool(_world, &"is_excavated", [front]): + return _fail("Hardness is immutable after excavation has started.") + if hardness == 0: + _hardness.erase(position) + else: + _hardness[position] = hardness + last_error = "" + return true + + +func get_hardness(cell_or_position: Variant) -> int: + var position: Variant = _spatial_position(cell_or_position) + if position == null: + return -1 + return int(_hardness.get(position, 0)) + + +func get_rule(hardness: int) -> Dictionary: + if not HARDNESS_RULES.has(hardness): + return {} + return (HARDNESS_RULES[hardness] as Dictionary).duplicate(true) + + +func validate_excavation(cell: Vector3i) -> Dictionary: + var position := Vector2i(cell.x, cell.y) + var errors: Array[StringName] = [] + var hardness := get_hardness(position) + var rule := get_rule(hardness) + var report := { + &"ok": false, + &"position": position, + &"cells": [ + Vector3i(position.x, position.y, 0), + Vector3i(position.x, position.y, 1), + ], + &"hardness": hardness, + &"cost": _rule_cost(rule), + &"build_minutes": int(rule.get(&"minutes", 0)), + &"errors": errors, + &"missing_resources": {}, + &"support_required": false, + &"entrance_connected": false, + } + if _quarantined or not _world_ready(): + _add_error(errors, ERROR_MISSING_DEPENDENCY) + report[&"errors"] = errors + return report + var front := Vector3i(position.x, position.y, 0) + if not _call_bool(_world, &"is_in_bounds", [front]): + _add_error(errors, ERROR_OUT_OF_BOUNDS) + if position.y >= 0: + _add_error(errors, ERROR_NOT_UNDERGROUND) + if errors.is_empty() and _call_bool(_world, &"is_excavated", [front]): + _add_error(errors, ERROR_ALREADY_EXCAVATED) + if _cell_jobs.has(position): + _add_error(errors, ERROR_ALREADY_RESERVED) + + if errors.is_empty(): + var excavated_neighbors := _excavated_neighbors(position) + if excavated_neighbors.is_empty(): + _add_error(errors, ERROR_NO_EXCAVATED_NEIGHBOR) + else: + var connected := false + for neighbor: Vector2i in excavated_neighbors: + if _has_boundary_connection(neighbor): + connected = true + break + report[&"entrance_connected"] = connected + if not connected: + _add_error(errors, ERROR_NO_ENTRANCE_CONNECTION) + + if errors.is_empty() and hardness >= 2: + var hard_component := _prospective_hard_span(position) + var support_required := hard_component.size() >= 4 + report[&"support_required"] = support_required + if support_required and not _component_has_support(hard_component): + _add_error(errors, ERROR_SUPPORT_REQUIRED) + + var missing: Dictionary = {} + if _resource_state == null: + _add_error(errors, ERROR_MISSING_DEPENDENCY) + else: + for resource_name: StringName in [&"gold", &"materials"]: + var required := int(report[&"cost"].get(resource_name, 0)) + var available := _resource_amount(resource_name) + if available < required: + missing[resource_name] = required - available + if not missing.is_empty(): + _add_error(errors, ERROR_INSUFFICIENT_RESOURCES) + report[&"missing_resources"] = missing + report[&"errors"] = errors + report[&"ok"] = errors.is_empty() + return report + + +func start_excavation(cell: Vector3i, created_at: int = -1) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + if _jobs.size() >= MAX_JOBS or _next_job_sequence > MAX_SEQUENCE: + return _failure("EXCAVATION_LIMIT", "Excavation job capacity has been reached.") + var report := validate_excavation(cell) + if not bool(report[&"ok"]): + return _failure("INVALID_EXCAVATION", "Excavation cannot start.", {&"report": report}) + var resource_snapshot: Variant = _capture_resource_state() + var cost: Dictionary = report[&"cost"] + _external_mutation_in_progress = true + if not _spend_cost(cost): + var rolled_back := _restore_resource_state(resource_snapshot, cost) + _external_mutation_in_progress = false + if not rolled_back: + _quarantine("Excavation payment failed after mutating resources and could not be rolled back.") + return _failure("PAYMENT_FAILED", "Excavation resources changed before payment.", { + &"cost": cost, + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + }) + _external_mutation_in_progress = false + var job_id := StringName("excavation_%08d" % _next_job_sequence) + var position: Vector2i = report[&"position"] + var job := { + &"job_id": job_id, + &"position": position, + &"hardness": int(report[&"hardness"]), + &"cost": cost.duplicate(true), + &"build_minutes": int(report[&"build_minutes"]), + &"work_units_done": 0, + &"progress": 0, + &"state": STATE_ACTIVE, + &"state_reason": &"", + &"created_at": current_minute if created_at < 0 else created_at, + &"started_at": -1, + &"completed_at": -1, + &"last_progress_at": -1, + } + if _cell_jobs.has(position): + _external_mutation_in_progress = true + var rolled_back := _restore_resource_state(resource_snapshot, cost) + _external_mutation_in_progress = false + if not rolled_back: + _quarantine("Excavation payment rollback failed after a concurrent reservation.") + return _failure("RESERVATION_FAILED", "The excavation cell was reserved concurrently.", { + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + }) + _jobs[job_id] = job + _cell_jobs[position] = job_id + _next_job_sequence += 1 + last_error = "" + job_created.emit(job.duplicate(true)) + return _success({&"job_id": job_id, &"report": report}) + + +func get_job(job_id: StringName) -> Dictionary: + if not _jobs.has(job_id): + return {} + return (_jobs[job_id] as Dictionary).duplicate(true) + + +func get_jobs() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for job_id: StringName in _sorted_job_ids(): + result.append((_jobs[job_id] as Dictionary).duplicate(true)) + return result + + +func pause_job(job_id: StringName, reason: StringName = &"PLAYER_PAUSED") -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + if not _jobs.has(job_id): + return _failure("NOT_FOUND", "Excavation job does not exist.") + var job: Dictionary = _jobs[job_id] + if job[&"state"] == STATE_COMPLETED: + return _failure("TERMINAL_STATE", "Completed excavation cannot be paused.") + if job[&"state"] == STATE_PAUSED: + var replay := _success({&"job_id": job_id, &"progress": job[&"progress"]}) + replay.is_replay = true + return replay + job[&"state"] = STATE_PAUSED + job[&"state_reason"] = reason + job_changed.emit(job.duplicate(true)) + return _success({&"job_id": job_id, &"progress": job[&"progress"]}) + + +func resume_job(job_id: StringName) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + if not _jobs.has(job_id): + return _failure("NOT_FOUND", "Excavation job does not exist.") + var job: Dictionary = _jobs[job_id] + if job[&"state"] == STATE_COMPLETED: + return _failure("TERMINAL_STATE", "Completed excavation cannot resume.") + if job[&"state"] == STATE_ACTIVE: + var replay := _success({&"job_id": job_id, &"progress": job[&"progress"]}) + replay.is_replay = true + return replay + if not _can_resume(job): + return _failure("EXCAVATION_DISCONNECTED", "The excavation route no longer reaches an entrance.") + job[&"state"] = STATE_ACTIVE + job[&"state_reason"] = &"" + job_changed.emit(job.duplicate(true)) + return _success({&"job_id": job_id, &"progress": job[&"progress"]}) + + +func advance_one_minute( + absolute_minute: int = -1, + work_rates: Dictionary = {} +) -> Array[CommandResult]: + if not _mutation_allowed(): + return [_unavailable_result()] + if absolute_minute < 0: + current_minute += 1 + else: + current_minute = maxi(current_minute, absolute_minute) + var results: Array[CommandResult] = [] + for job_id: StringName in _sorted_job_ids(): + var job: Dictionary = _jobs[job_id] + if job[&"state"] != STATE_ACTIVE: + continue + var rate := int(work_rates.get(job_id, NORMAL_WORK_RATE)) + results.append(progress_job(job_id, rate, current_minute)) + return results + + +func progress_job( + job_id: StringName, + work_rate_basis_points: int = NORMAL_WORK_RATE, + absolute_minute: int = -1 +) -> CommandResult: + if not _mutation_allowed(): + return _unavailable_result() + if not _jobs.has(job_id): + return _failure("NOT_FOUND", "Excavation job does not exist.") + var job: Dictionary = _jobs[job_id] + if job[&"state"] == STATE_COMPLETED: + return _failure("TERMINAL_STATE", "Completed excavation cannot progress.") + if job[&"state"] == STATE_PAUSED: + return _failure("PAUSED", "Paused excavation must be resumed first.") + if work_rate_basis_points <= 0 or work_rate_basis_points > MAX_WORK_RATE: + return _failure("INVALID_WORK_RATE", "Excavation work rate must be positive.") + if not _can_resume(job): + job[&"state"] = STATE_PAUSED + job[&"state_reason"] = &"ENTRANCE_DISCONNECTED" + job_changed.emit(job.duplicate(true)) + return _failure("EXCAVATION_DISCONNECTED", "Excavation lost its entrance connection.") + var minute := current_minute if absolute_minute < 0 else absolute_minute + if minute < 0: + return _failure("INVALID_MINUTE", "Excavation progress minute cannot be negative.") + if minute <= int(job[&"last_progress_at"]): + var replay := _success({&"job_id": job_id, &"progress": job[&"progress"], &"state": job[&"state"]}) + replay.is_replay = true + return replay + if int(job[&"started_at"]) < 0: + job[&"started_at"] = minute + job[&"last_progress_at"] = minute + job[&"work_units_done"] = mini( + MAX_WORK_UNITS, + int(job[&"work_units_done"]) + work_rate_basis_points + ) + job[&"progress"] = mini( + MAX_PROGRESS, + int(job[&"work_units_done"]) / int(job[&"build_minutes"]) + ) + if int(job[&"progress"]) < MAX_PROGRESS: + job_changed.emit(job.duplicate(true)) + return _success({&"job_id": job_id, &"progress": job[&"progress"], &"state": STATE_ACTIVE}) + return _complete_job(job_id, minute) + + +func validate_invariants() -> Dictionary: + var errors: Array[Dictionary] = [] + if _next_job_sequence < 1 or _next_job_sequence > MAX_SEQUENCE + 1: + errors.append({&"code": &"INVALID_SEQUENCE"}) + if _jobs.size() > MAX_JOBS or _cell_jobs.size() > _jobs.size(): + errors.append({&"code": &"JOB_LIMIT"}) + var seen_positions: Dictionary = {} + var high_water := 0 + for job_id: StringName in _sorted_job_ids(): + var job: Variant = _jobs[job_id] + var parsed := _validate_job(job, job_id) + if not bool(parsed[&"ok"]): + errors.append({&"code": &"INVALID_JOB", &"job_id": job_id}) + continue + var position: Vector2i = job[&"position"] + if seen_positions.has(position): + errors.append({&"code": &"DUPLICATE_JOB_CELL", &"job_id": job_id}) + seen_positions[position] = true + var sequence := _sequence_from_job_id(job_id) + if sequence <= 0: + errors.append({&"code": &"INVALID_JOB_ID", &"job_id": job_id}) + else: + high_water = maxi(high_water, sequence) + if job[&"state"] == STATE_COMPLETED: + if _cell_jobs.has(position): + errors.append({&"code": &"COMPLETED_CELL_RESERVED", &"job_id": job_id}) + if _world_ready() and not _call_bool(_world, &"is_excavated", [Vector3i(position.x, position.y, 0)]): + errors.append({&"code": &"COMPLETED_CELL_NOT_EXCAVATED", &"job_id": job_id}) + else: + if _cell_jobs.get(position, &"") != job_id: + errors.append({&"code": &"MISSING_CELL_RESERVATION", &"job_id": job_id}) + if _world_ready() and _call_bool(_world, &"is_excavated", [Vector3i(position.x, position.y, 0)]): + errors.append({&"code": &"RESERVED_CELL_ALREADY_EXCAVATED", &"job_id": job_id}) + if job[&"state"] == STATE_ACTIVE and not _can_resume(job): + errors.append({&"code": &"ACTIVE_JOB_DISCONNECTED", &"job_id": job_id}) + var rule := get_rule(int(job[&"hardness"])) + if ( + int(job[&"hardness"]) != int(_hardness.get(position, 0)) + or job[&"cost"] != _rule_cost(rule) + or int(job[&"build_minutes"]) != int(rule.get(&"minutes", 0)) + ): + errors.append({&"code": &"JOB_RULE_MISMATCH", &"job_id": job_id}) + if high_water >= _next_job_sequence: + errors.append({&"code": &"SEQUENCE_NOT_ABOVE_HIGH_WATER"}) + for position: Variant in _hardness: + if ( + not position is Vector2i + or not _hardness[position] is int + or int(_hardness[position]) < 1 + or int(_hardness[position]) > MAX_HARDNESS + or position.y >= 0 + or (_world_ready() and not _call_bool(_world, &"is_in_bounds", [Vector3i(position.x, position.y, 0)])) + ): + errors.append({&"code": &"INVALID_HARDNESS"}) + if _quarantined and _quarantine_reason.is_empty(): + errors.append({&"code": &"INVALID_QUARANTINE"}) + if not _quarantined and not _quarantine_reason.is_empty(): + errors.append({&"code": &"INVALID_QUARANTINE"}) + return {&"ok": errors.is_empty(), &"errors": errors} + + +func create_snapshot() -> Dictionary: + var hardness_entries: Array[Dictionary] = [] + var hardness_positions: Array[Vector2i] = [] + for position: Variant in _hardness: + hardness_positions.append(position) + hardness_positions.sort_custom(_position_less) + for position: Vector2i in hardness_positions: + hardness_entries.append({&"position": [position.x, position.y], &"hardness": _hardness[position]}) + var jobs: Array[Dictionary] = [] + for job_id: StringName in _sorted_job_ids(): + jobs.append(_serialize_job(_jobs[job_id])) + return { + &"schema_version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"next_job_sequence": _next_job_sequence, + &"hardness": hardness_entries, + &"jobs": jobs, + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _external_mutation_in_progress: + last_error = "Excavation state cannot be restored during an external mutation." + return false + var parsed := _parse_snapshot(snapshot) + if not bool(parsed[&"ok"]): + last_error = String(parsed[&"error"]) + return false + var previous_hardness := _hardness + var previous_jobs := _jobs + var previous_cell_jobs := _cell_jobs + var previous_minute := current_minute + var previous_sequence := _next_job_sequence + var previous_quarantined := _quarantined + var previous_quarantine_reason := _quarantine_reason + _hardness = parsed[&"hardness"] + _jobs = parsed[&"jobs"] + _cell_jobs = parsed[&"cell_jobs"] + current_minute = parsed[&"current_minute"] + _next_job_sequence = parsed[&"next_job_sequence"] + _quarantined = parsed[&"quarantined"] + _quarantine_reason = parsed[&"quarantine_reason"] + var invariants := validate_invariants() + if not bool(invariants[&"ok"]): + _hardness = previous_hardness + _jobs = previous_jobs + _cell_jobs = previous_cell_jobs + current_minute = previous_minute + _next_job_sequence = previous_sequence + _quarantined = previous_quarantined + _quarantine_reason = previous_quarantine_reason + last_error = "Excavation snapshot violates live world invariants." + return false + last_error = "" + return true + + +func _complete_job(job_id: StringName, minute: int) -> CommandResult: + var job: Dictionary = _jobs[job_id] + var position: Vector2i = job[&"position"] + var front := Vector3i(position.x, position.y, 0) + var world_snapshot := _capture_world_state() + _external_mutation_in_progress = true + var committed := _call_bool(_world, &"set_excavated", [front, true]) + _external_mutation_in_progress = false + if not committed: + _external_mutation_in_progress = true + var rolled_back := _restore_world_state(world_snapshot, front) + _external_mutation_in_progress = false + job[&"state"] = STATE_PAUSED + job[&"state_reason"] = &"WORLD_COMMIT_FAILED" + if not rolled_back: + _quarantine("World excavation commit and rollback both failed.") + job_changed.emit(job.duplicate(true)) + return _failure("WORLD_COMMIT_FAILED", "The world rejected completed excavation.", { + &"rolled_back": rolled_back, + &"rollback_failed": not rolled_back, + }) + job[&"progress"] = MAX_PROGRESS + job[&"state"] = STATE_COMPLETED + job[&"state_reason"] = &"" + job[&"completed_at"] = minute + _cell_jobs.erase(position) + var report := { + &"job_id": job_id, + &"position": position, + &"cells": [front, Vector3i(position.x, position.y, 1)], + &"hardness": job[&"hardness"], + &"completed_at": minute, + } + job_changed.emit(job.duplicate(true)) + excavation_completed.emit(report.duplicate(true)) + return _success(report) + + +func _can_resume(job: Dictionary) -> bool: + var position: Vector2i = job[&"position"] + if not _world_ready() or _call_bool(_world, &"is_excavated", [Vector3i(position.x, position.y, 0)]): + return false + for neighbor: Vector2i in _excavated_neighbors(position): + if _has_boundary_connection(neighbor): + return true + return false + + +func _excavated_neighbors(position: Vector2i) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + for neighbor: Vector2i in _neighbors(position): + var front := Vector3i(neighbor.x, neighbor.y, 0) + if _call_bool(_world, &"is_in_bounds", [front]) and _call_bool(_world, &"is_excavated", [front]): + result.append(neighbor) + return result + + +func _has_boundary_connection(start: Vector2i) -> bool: + if start.y == 0: + return true + var queue: Array[Vector2i] = [start] + var visited := {start: true} + var index := 0 + while index < queue.size(): + var current := queue[index] + index += 1 + for neighbor: Vector2i in _neighbors(current): + if visited.has(neighbor): + continue + var front := Vector3i(neighbor.x, neighbor.y, 0) + if not _call_bool(_world, &"is_in_bounds", [front]) or not _call_bool(_world, &"is_excavated", [front]): + continue + if neighbor.y == 0: + return true + visited[neighbor] = true + queue.append(neighbor) + return false + + +func _prospective_hard_span(position: Vector2i) -> Array[Vector2i]: + var horizontal := _hard_run(position, Vector2i.RIGHT) + var vertical := _hard_run(position, Vector2i.DOWN) + if vertical.size() > horizontal.size(): + return vertical + return horizontal + + +func _hard_run(position: Vector2i, direction: Vector2i) -> Array[Vector2i]: + var result: Array[Vector2i] = [position] + for sign_value: int in [-1, 1]: + var cursor := position + direction * sign_value + while get_hardness(cursor) >= 2: + var front := Vector3i(cursor.x, cursor.y, 0) + if not _call_bool(_world, &"is_in_bounds", [front]) or not _call_bool(_world, &"is_excavated", [front]): + break + result.append(cursor) + cursor += direction * sign_value + result.sort_custom(_position_less) + return result + + +func _component_has_support(component: Array[Vector2i]) -> bool: + if not _world.has_method(&"get_support_capacity") or not _world.has_method(&"is_support_anchor"): + return false + var candidates: Dictionary = {} + for position: Vector2i in component: + candidates[position] = true + for neighbor: Vector2i in _neighbors(position): + candidates[neighbor] = true + for position: Variant in candidates: + for layer: int in [0, 1]: + var cell := Vector3i(position.x, position.y, layer) + if not _call_bool(_world, &"is_in_bounds", [cell]): + continue + if _call_bool(_world, &"is_support_anchor", [cell]) or _call_int(_world, &"get_support_capacity", [cell], 0) > 0: + return true + return false + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + var raw_version: Variant = snapshot.get(&"schema_version", null) + if not raw_version is int or int(raw_version) not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + return {&"ok": false, &"error": "Unsupported excavation snapshot version."} + var version := int(raw_version) + var top_level_keys: Array[StringName] = [ + &"schema_version", &"current_minute", &"next_job_sequence", &"hardness", &"jobs", + ] + if version == SNAPSHOT_VERSION: + top_level_keys.append_array([&"quarantined", &"quarantine_reason"]) + if not _has_exact_keys(snapshot, top_level_keys): + return {&"ok": false, &"error": "Excavation snapshot schema is not exact."} + var minute: Variant = snapshot.get(&"current_minute", null) + var sequence: Variant = snapshot.get(&"next_job_sequence", null) + var hardness_values: Variant = snapshot.get(&"hardness", null) + var job_values: Variant = snapshot.get(&"jobs", null) + if ( + not minute is int + or int(minute) < 0 + or not sequence is int + or int(sequence) < 1 + or int(sequence) > MAX_SEQUENCE + 1 + ): + return {&"ok": false, &"error": "Excavation snapshot counters are invalid."} + if not hardness_values is Array or not job_values is Array or job_values.size() > MAX_JOBS: + return {&"ok": false, &"error": "Excavation snapshot collections are invalid."} + var quarantined := false + var quarantine_reason := "" + if version == SNAPSHOT_VERSION: + var raw_quarantined: Variant = snapshot[&"quarantined"] + var raw_reason: Variant = snapshot[&"quarantine_reason"] + if not raw_quarantined is bool or not raw_reason is String: + return {&"ok": false, &"error": "Excavation quarantine state is invalid."} + quarantined = raw_quarantined + quarantine_reason = raw_reason + if quarantined == quarantine_reason.is_empty(): + return {&"ok": false, &"error": "Excavation quarantine state is inconsistent."} + var hardness: Dictionary = {} + for value: Variant in hardness_values: + if not value is Dictionary or not _has_exact_keys(value, [&"position", &"hardness"]): + return {&"ok": false, &"error": "Hardness entry is invalid."} + var position: Variant = _parse_position(value.get(&"position", null)) + var amount: Variant = value.get(&"hardness", null) + if position == null or not amount is int or int(amount) < 1 or int(amount) > MAX_HARDNESS or hardness.has(position): + return {&"ok": false, &"error": "Hardness entry is invalid."} + hardness[position] = int(amount) + var jobs: Dictionary = {} + var cell_jobs: Dictionary = {} + for value: Variant in job_values: + if not value is Dictionary or not _validate_serialized_job(value, version): + return {&"ok": false, &"error": "Excavation job entry is invalid."} + var job := _deserialize_job(value, version, int(minute)) + var job_id := StringName(job.get(&"job_id", &"")) + if job_id.is_empty() or jobs.has(job_id) or not bool(_validate_job(job, job_id)[&"ok"]): + return {&"ok": false, &"error": "Excavation job entry is invalid."} + var position: Vector2i = job[&"position"] + if cell_jobs.has(position): + return {&"ok": false, &"error": "Excavation jobs overlap."} + jobs[job_id] = job + if job[&"state"] != STATE_COMPLETED: + cell_jobs[position] = job_id + return { + &"ok": true, + &"current_minute": int(minute), + &"next_job_sequence": int(sequence), + &"hardness": hardness, + &"jobs": jobs, + &"cell_jobs": cell_jobs, + &"quarantined": quarantined, + &"quarantine_reason": quarantine_reason, + } + + +func _validate_job(value: Variant, expected_id: StringName) -> Dictionary: + if not value is Dictionary: + return {&"ok": false} + var required: Array[StringName] = [ + &"job_id", &"position", &"hardness", &"cost", &"build_minutes", + &"work_units_done", &"progress", &"state", &"state_reason", &"created_at", + &"started_at", &"completed_at", &"last_progress_at", + ] + if not _has_exact_keys(value, required): + return {&"ok": false} + if not value[&"job_id"] is StringName or value[&"job_id"] != expected_id or not value[&"position"] is Vector2i: + return {&"ok": false} + for integer_key: StringName in [ + &"hardness", &"build_minutes", &"work_units_done", &"progress", &"created_at", + &"started_at", &"completed_at", &"last_progress_at", + ]: + if not value[integer_key] is int: + return {&"ok": false} + if int(value[&"hardness"]) < 0 or int(value[&"hardness"]) > MAX_HARDNESS: + return {&"ok": false} + if not _valid_cost(value[&"cost"]) or int(value[&"build_minutes"]) <= 0: + return {&"ok": false} + if ( + int(value[&"work_units_done"]) < 0 + or int(value[&"work_units_done"]) > MAX_WORK_UNITS + or int(value[&"progress"]) < 0 + or int(value[&"progress"]) > MAX_PROGRESS + ): + return {&"ok": false} + var expected_progress := mini(MAX_PROGRESS, int(value[&"work_units_done"]) / int(value[&"build_minutes"])) + if int(value[&"progress"]) != expected_progress: + return {&"ok": false} + if not value[&"state"] is StringName or not value[&"state_reason"] is StringName: + return {&"ok": false} + var state: StringName = value[&"state"] + if state not in [STATE_ACTIVE, STATE_PAUSED, STATE_COMPLETED]: + return {&"ok": false} + if ( + int(value[&"created_at"]) < 0 + or int(value[&"started_at"]) < -1 + or int(value[&"completed_at"]) < -1 + or int(value[&"last_progress_at"]) < -1 + ): + return {&"ok": false} + if int(value[&"work_units_done"]) == 0 and (int(value[&"started_at"]) != -1 or int(value[&"last_progress_at"]) != -1): + return {&"ok": false} + if int(value[&"work_units_done"]) > 0 and ( + int(value[&"started_at"]) < 0 + or int(value[&"last_progress_at"]) < int(value[&"started_at"]) + ): + return {&"ok": false} + if state == STATE_COMPLETED and (int(value[&"progress"]) != MAX_PROGRESS or int(value[&"completed_at"]) < 0): + return {&"ok": false} + if state != STATE_COMPLETED and int(value[&"completed_at"]) != -1: + return {&"ok": false} + return {&"ok": true} + + +func _validate_serialized_job(value: Dictionary, version: int) -> bool: + var required: Array[StringName] = [ + &"job_id", &"position", &"hardness", &"cost", &"build_minutes", + &"work_units_done", &"progress", &"state", &"state_reason", &"created_at", + &"started_at", &"completed_at", + ] + if version == SNAPSHOT_VERSION: + required.append(&"last_progress_at") + if not _has_exact_keys(value, required): + return false + if not value[&"job_id"] is String or String(value[&"job_id"]).is_empty(): + return false + if _parse_position(value[&"position"]) == null: + return false + if not value[&"state"] is String or not value[&"state_reason"] is String: + return false + for integer_key: StringName in [ + &"hardness", &"build_minutes", &"work_units_done", &"progress", &"created_at", + &"started_at", &"completed_at", + ]: + if not value[integer_key] is int: + return false + if version == SNAPSHOT_VERSION and not value[&"last_progress_at"] is int: + return false + return _valid_cost(value[&"cost"]) + + +func _serialize_job(job: Dictionary) -> Dictionary: + var result := job.duplicate(true) + var position: Vector2i = job[&"position"] + result[&"job_id"] = String(job[&"job_id"]) + result[&"position"] = [position.x, position.y] + result[&"state"] = String(job[&"state"]) + result[&"state_reason"] = String(job[&"state_reason"]) + return result + + +func _deserialize_job(value: Dictionary, version: int, snapshot_minute: int) -> Dictionary: + var result := value.duplicate(true) + result[&"job_id"] = StringName(value[&"job_id"]) + result[&"position"] = _parse_position(value[&"position"]) + result[&"state"] = StringName(value[&"state"]) + result[&"state_reason"] = StringName(value[&"state_reason"]) + if version == LEGACY_SNAPSHOT_VERSION: + result[&"last_progress_at"] = -1 + if int(result[&"work_units_done"]) > 0: + result[&"last_progress_at"] = maxi(int(result[&"started_at"]), snapshot_minute) + return result + + +func _world_ready() -> bool: + return ( + _world != null + and _world.has_method(&"is_in_bounds") + and _world.has_method(&"is_excavated") + and _world.has_method(&"set_excavated") + ) + + +func _resource_amount(resource_name: StringName) -> int: + if _resource_state == null: + return 0 + if _resource_state.has_method(&"get_resource"): + var returned: Variant = _resource_state.call(&"get_resource", resource_name) + if returned is int: + return returned + return 0 + + +func _spend_cost(cost: Dictionary) -> bool: + if _resource_state == null: + return false + if _resource_state.has_method(&"spend"): + return _operation_succeeded(_resource_state.call(&"spend", cost)) + if not _resource_state.has_method(&"apply_resource_deltas"): + return false + var deltas: Dictionary = {} + for resource_name: Variant in cost: + if _resource_amount(StringName(resource_name)) < int(cost[resource_name]): + return false + deltas[StringName(resource_name)] = -int(cost[resource_name]) + return _operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas)) + + +func _capture_resource_state() -> Variant: + var captured := { + &"amounts": { + &"gold": _resource_amount(&"gold"), + &"materials": _resource_amount(&"materials"), + }, + &"has_native": false, + &"native_snapshot": null, + } + if _resource_state != null and _resource_state.has_method(&"create_snapshot"): + captured[&"has_native"] = true + captured[&"native_snapshot"] = _resource_state.call(&"create_snapshot") + return captured + + +func _restore_resource_state(snapshot: Variant, cost: Dictionary) -> bool: + if not snapshot is Dictionary or _resource_state == null: + return false + var attempted := false + if ( + bool(snapshot.get(&"has_native", false)) + and _resource_state.has_method(&"restore_snapshot") + ): + attempted = true + _operation_succeeded(_resource_state.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null))) + elif _resource_state.has_method(&"apply_resource_deltas"): + attempted = true + var expected: Variant = snapshot.get(&"amounts", null) + if not expected is Dictionary: + return false + var deltas: Dictionary = {} + for resource_name: StringName in [&"gold", &"materials"]: + var expected_amount: Variant = expected.get(resource_name, null) + if not expected_amount is int: + return false + deltas[resource_name] = int(expected_amount) - _resource_amount(resource_name) + _operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas)) + if not attempted: + return false + return _resource_amounts_match(snapshot.get(&"amounts", {})) + + +func _capture_world_state() -> Dictionary: + var captured := { + &"has_native": false, + &"native_snapshot": null, + } + if _world != null and _world.has_method(&"create_snapshot"): + captured[&"has_native"] = true + captured[&"native_snapshot"] = _world.call(&"create_snapshot") + return captured + + +func _restore_world_state(snapshot: Variant, front: Vector3i) -> bool: + if _world == null: + return false + if ( + snapshot is Dictionary + and bool(snapshot.get(&"has_native", false)) + and _world.has_method(&"restore_snapshot") + ): + _operation_succeeded(_world.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null))) + elif _world.has_method(&"set_excavated"): + _call_bool(_world, &"set_excavated", [front, false]) + else: + return false + return not _call_bool(_world, &"is_excavated", [front]) + + +func _resource_amounts_match(expected: Variant) -> bool: + if not expected is Dictionary: + return false + for resource_name: StringName in [&"gold", &"materials"]: + var amount: Variant = expected.get(resource_name, null) + if not amount is int or _resource_amount(resource_name) != int(amount): + return false + return true + + +func _rule_cost(rule: Dictionary) -> Dictionary: + return { + &"gold": int(rule.get(&"gold", 0)), + &"materials": int(rule.get(&"materials", 0)), + } + + +func _sorted_job_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for job_id: Variant in _jobs: + result.append(StringName(job_id)) + result.sort_custom(func(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + ) + return result + + +func _sequence_from_job_id(job_id: StringName) -> int: + var text := String(job_id) + if not text.begins_with("excavation_") or text.length() != 19: + return -1 + var suffix := text.substr(11) + if not suffix.is_valid_int(): + return -1 + return int(suffix) + + +static func _spatial_position(value: Variant) -> Variant: + if value is Vector2i: + return value + if value is Vector3i: + return Vector2i(value.x, value.y) + return null + + +static func _parse_position(value: Variant) -> Variant: + if not value is Array or value.size() != 2 or not value[0] is int or not value[1] is int: + return null + return Vector2i(int(value[0]), int(value[1])) + + +static func _valid_cost(value: Variant) -> bool: + if not value is Dictionary or not _has_exact_keys(value, [&"gold", &"materials"]): + return false + return ( + value[&"gold"] is int + and value[&"materials"] is int + and int(value[&"gold"]) >= 0 + and int(value[&"materials"]) >= 0 + ) + + +static func _has_exact_keys(value: Dictionary, expected: Array) -> bool: + if value.size() != expected.size(): + return false + for key: Variant in expected: + if not value.has(key): + return false + return true + + +static func _call_bool(target: Variant, method_name: StringName, arguments: Array) -> bool: + if target == null or not target.has_method(method_name): + return false + var returned: Variant = target.callv(method_name, arguments) + return returned if returned is bool else false + + +static func _call_int( + target: Variant, + method_name: StringName, + arguments: Array, + fallback: int +) -> int: + if target == null or not target.has_method(method_name): + return fallback + var returned: Variant = target.callv(method_name, arguments) + return returned if returned is int else fallback + + +static func _operation_succeeded(returned: Variant) -> bool: + if returned is bool: + return returned + if returned is Object and returned.has_method(&"is_success"): + var result: Variant = returned.call(&"is_success") + return result if result is bool else false + return false + + +static func _neighbors(position: Vector2i) -> Array[Vector2i]: + return [ + position + Vector2i.LEFT, + position + Vector2i.RIGHT, + position + Vector2i.UP, + position + Vector2i.DOWN, + ] + + +static func _position_less(left: Vector2i, right: Vector2i) -> bool: + if left.y != right.y: + return left.y < right.y + return left.x < right.x + + +static func _add_error(errors: Array[StringName], code: StringName) -> void: + if not errors.has(code): + errors.append(code) + + +func _success(details: Dictionary = {}) -> CommandResult: + return CommandResultType.succeeded(0, details) + + +func is_quarantined() -> bool: + return _quarantined + + +func get_quarantine_reason() -> String: + return _quarantine_reason + + +func _mutation_allowed() -> bool: + if _external_mutation_in_progress: + last_error = "A reentrant excavation mutation was rejected." + return false + if _quarantined: + last_error = "Excavation service is quarantined: %s" % _quarantine_reason + return false + return true + + +func _unavailable_result() -> CommandResult: + var code: StringName = &"SERVICE_QUARANTINED" if _quarantined else &"REENTRANT_MUTATION" + return _failure(code, last_error, {&"quarantine_reason": _quarantine_reason}) + + +func _quarantine(reason: String) -> void: + _quarantined = true + _quarantine_reason = reason + last_error = reason + + +func _failure(code: StringName, message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + var merged := details.duplicate(true) + merged[&"code"] = code + return CommandResultType.failed(CommandResultType.Code.VALIDATION_FAILED, 0, message, merged) + + +func _fail(message: String) -> bool: + last_error = message + return false diff --git a/scripts/construction/excavation_service.gd.uid b/scripts/construction/excavation_service.gd.uid new file mode 100644 index 0000000..e495570 --- /dev/null +++ b/scripts/construction/excavation_service.gd.uid @@ -0,0 +1 @@ +uid://b6sa4hwanps1l diff --git a/scripts/construction/placement_report.gd b/scripts/construction/placement_report.gd new file mode 100644 index 0000000..dcea859 --- /dev/null +++ b/scripts/construction/placement_report.gd @@ -0,0 +1,116 @@ +class_name PlacementReport +extends RefCounted + +const ERROR_INVALID_DEFINITION := &"INVALID_DEFINITION" +const ERROR_MISSING_DEPENDENCY := &"MISSING_DEPENDENCY" +const ERROR_OUT_OF_BOUNDS := &"OUT_OF_BOUNDS" +const ERROR_ZONE_MISMATCH := &"ZONE_MISMATCH" +const ERROR_LAYER_MISMATCH := &"LAYER_MISMATCH" +const ERROR_OVERLAP := &"OVERLAP" +const ERROR_NOT_EXCAVATED := &"NOT_EXCAVATED" +const ERROR_UNSUPPORTED := &"UNSUPPORTED" +const ERROR_INSUFFICIENT_RESOURCES := &"INSUFFICIENT_RESOURCES" + +var can_build := false +var error_codes: Array[StringName] = [] +var reasons: Array[Dictionary] = [] + +var cells: Array[Vector3i] = [] +var out_of_bounds_cells: Array[Vector3i] = [] +var zone_mismatch_cells: Array[Vector3i] = [] +var layer_mismatch_cells: Array[Vector3i] = [] +var occupied_cells: Array[Vector3i] = [] +var unexcavated_cells: Array[Vector3i] = [] +var unsupported_cells: Array[Vector3i] = [] +var access_points: Array[Vector3i] = [] +var mana_connection := false + +var total_cost: Dictionary = {} +var missing_resources: Dictionary = {} +var build_time := 0 +var predicted_environment_delta: Dictionary = {} +var predicted_operating_cost: Dictionary = {} +var predicted_heat := 0 + + +func add_error( + code: StringName, + reason: String, + affected_cells: Array = [], + details: Dictionary = {} +) -> void: + if not error_codes.has(code): + error_codes.append(code) + var normalized_cells: Array[Vector3i] = [] + for cell: Variant in affected_cells: + if cell is Vector3i: + normalized_cells.append(cell) + reasons.append({ + &"code": code, + &"message": reason, + &"cells": normalized_cells, + &"details": details.duplicate(true), + }) + can_build = false + + +func finalize() -> PlacementReport: + can_build = error_codes.is_empty() + return self + + +func has_error(code: StringName) -> bool: + return error_codes.has(code) + + +func reason_for(code: StringName) -> Dictionary: + for reason: Dictionary in reasons: + if StringName(reason.get(&"code", &"")) == code: + return reason.duplicate(true) + return {} + + +func to_dictionary() -> Dictionary: + var serialized_reasons: Array[Dictionary] = [] + for reason: Dictionary in reasons: + var serialized := reason.duplicate(true) + serialized[&"code"] = String(reason.get(&"code", &"")) + serialized[&"cells"] = _serialize_cells(reason.get(&"cells", [])) + serialized_reasons.append(serialized) + return { + &"can_build": can_build, + &"error_codes": _string_names_to_strings(error_codes), + &"reasons": serialized_reasons, + &"cells": _serialize_cells(cells), + &"out_of_bounds_cells": _serialize_cells(out_of_bounds_cells), + &"zone_mismatch_cells": _serialize_cells(zone_mismatch_cells), + &"layer_mismatch_cells": _serialize_cells(layer_mismatch_cells), + &"occupied_cells": _serialize_cells(occupied_cells), + &"unexcavated_cells": _serialize_cells(unexcavated_cells), + &"unsupported_cells": _serialize_cells(unsupported_cells), + &"access_points": _serialize_cells(access_points), + &"mana_connection": mana_connection, + &"total_cost": total_cost.duplicate(true), + &"missing_resources": missing_resources.duplicate(true), + &"build_time": build_time, + &"predicted_environment_delta": predicted_environment_delta.duplicate(true), + &"predicted_operating_cost": predicted_operating_cost.duplicate(true), + &"predicted_heat": predicted_heat, + } + + +static func _serialize_cells(source: Variant) -> Array[Array]: + var serialized: Array[Array] = [] + if not source is Array: + return serialized + for cell: Variant in source: + if cell is Vector3i: + serialized.append([cell.x, cell.y, cell.z]) + return serialized + + +static func _string_names_to_strings(source: Array[StringName]) -> Array[String]: + var serialized: Array[String] = [] + for value: StringName in source: + serialized.append(String(value)) + return serialized diff --git a/scripts/construction/placement_report.gd.uid b/scripts/construction/placement_report.gd.uid new file mode 100644 index 0000000..f28046c --- /dev/null +++ b/scripts/construction/placement_report.gd.uid @@ -0,0 +1 @@ +uid://bqgwlfqero41j diff --git a/scripts/core/command.gd b/scripts/core/command.gd new file mode 100644 index 0000000..e6f99a3 --- /dev/null +++ b/scripts/core/command.gd @@ -0,0 +1,90 @@ +class_name SimulationCommand +extends RefCounted + +var command_id := 0 +var issued_tick := 0 +var execute_tick := 0 +var priority := 0 +var command_type: StringName = &"" +var actor_id: StringName = &"" +var target_id: StringName = &"" +var payload: Dictionary = {} +var idempotency_key: StringName = &"" + + +func _init( + p_command_id: int = 0, + p_issued_tick: int = 0, + p_execute_tick: int = 0, + p_priority: int = 0, + p_command_type: StringName = &"", + p_actor_id: StringName = &"", + p_target_id: StringName = &"", + p_payload: Dictionary = {}, + p_idempotency_key: StringName = &"" +) -> void: + command_id = p_command_id + issued_tick = p_issued_tick + execute_tick = p_execute_tick + priority = p_priority + command_type = p_command_type + actor_id = p_actor_id + target_id = p_target_id + payload = p_payload.duplicate(true) + idempotency_key = p_idempotency_key + + +func validation_error() -> String: + if command_id <= 0: + return "command_id must be greater than zero." + if issued_tick < 0: + return "issued_tick cannot be negative." + if execute_tick < issued_tick: + return "execute_tick cannot be earlier than issued_tick." + return "" + + +func is_valid() -> bool: + return validation_error().is_empty() + + +func copy() -> SimulationCommand: + return SimulationCommand.new( + command_id, + issued_tick, + execute_tick, + priority, + command_type, + actor_id, + target_id, + payload, + idempotency_key + ) + + +func to_dictionary() -> Dictionary: + return { + "command_id": command_id, + "issued_tick": issued_tick, + "execute_tick": execute_tick, + "priority": priority, + "command_type": String(command_type), + "actor_id": String(actor_id), + "target_id": String(target_id), + "payload": payload.duplicate(true), + "idempotency_key": String(idempotency_key), + } + + +static func from_dictionary(data: Dictionary) -> SimulationCommand: + return SimulationCommand.new( + int(data.get("command_id", 0)), + int(data.get("issued_tick", 0)), + int(data.get("execute_tick", 0)), + int(data.get("priority", 0)), + StringName(data.get("command_type", "")), + StringName(data.get("actor_id", "")), + StringName(data.get("target_id", "")), + data.get("payload", {}) as Dictionary, + StringName(data.get("idempotency_key", "")) + ) diff --git a/scripts/core/command.gd.uid b/scripts/core/command.gd.uid new file mode 100644 index 0000000..cc73e88 --- /dev/null +++ b/scripts/core/command.gd.uid @@ -0,0 +1 @@ +uid://crauxmerklv67 diff --git a/scripts/core/command_queue.gd b/scripts/core/command_queue.gd new file mode 100644 index 0000000..60109b6 --- /dev/null +++ b/scripts/core/command_queue.gd @@ -0,0 +1,407 @@ +class_name CommandQueue +extends RefCounted + +const SNAPSHOT_VERSION := 2 +const LEGACY_SNAPSHOT_VERSION := 1 + +var _pending: Array[SimulationCommand] = [] +var _in_flight: Dictionary = {} +var _known_command_ids: Dictionary = {} +var _idempotency_to_command_id: Dictionary = {} +var _processed_results: Dictionary = {} +var _accepted_metadata: Dictionary = {} + + +func enqueue(command: SimulationCommand) -> CommandResult: + if command == null: + return CommandResult.failed( + CommandResult.Code.INVALID_COMMAND, + 0, + "Command is null." + ) + + var validation_error := command.validation_error() + if not validation_error.is_empty(): + return CommandResult.failed( + CommandResult.Code.INVALID_COMMAND, + command.command_id, + validation_error + ) + + if _known_command_ids.has(command.command_id): + return CommandResult.failed( + CommandResult.Code.DUPLICATE_COMMAND_ID, + command.command_id, + "command_id has already been accepted.", + {"original_command_id": command.command_id} + ) + + if not command.idempotency_key.is_empty(): + var key := command.idempotency_key + if _idempotency_to_command_id.has(key): + var original_id: int = _idempotency_to_command_id[key] + var duplicate_details := { + "idempotency_key": String(key), + "original_command_id": original_id, + "processed": _processed_results.has(original_id), + } + if _processed_results.has(original_id): + var prior: CommandResult = _processed_results[original_id] + duplicate_details["original_result"] = prior.to_dictionary() + return CommandResult.failed( + CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, + command.command_id, + "idempotency_key has already been accepted.", + duplicate_details + ) + + var queued_command := command.copy() + _pending.append(queued_command) + _known_command_ids[queued_command.command_id] = true + _accepted_metadata[queued_command.command_id] = { + "command_id": queued_command.command_id, + "idempotency_key": String(queued_command.idempotency_key), + } + if not queued_command.idempotency_key.is_empty(): + _idempotency_to_command_id[queued_command.idempotency_key] = queued_command.command_id + _pending.sort_custom(_comes_before) + return CommandResult.succeeded(queued_command.command_id) + + +func drain_ready(current_tick: int) -> Array[SimulationCommand]: + var ready: Array[SimulationCommand] = [] + while not _pending.is_empty(): + var command := _pending[0] + if command.execute_tick > current_tick: + break + _pending.pop_front() + _in_flight[command.command_id] = command + ready.append(command) + return ready + + +func execute_ready(current_tick: int, executor: Callable) -> Array[CommandResult]: + var results: Array[CommandResult] = [] + for command in drain_ready(current_tick): + var result: CommandResult + if not executor.is_valid(): + result = CommandResult.failed( + CommandResult.Code.EXECUTOR_FAILED, + command.command_id, + "Executor is not callable." + ) + else: + var returned: Variant = executor.call(command) + if returned is CommandResult: + result = returned + elif returned is bool and returned: + result = CommandResult.succeeded(command.command_id) + else: + result = CommandResult.failed( + CommandResult.Code.EXECUTOR_FAILED, + command.command_id, + "Executor did not return a successful CommandResult." + ) + + if result.command_id == 0: + result.command_id = command.command_id + complete(command.command_id, result) + results.append(result.copy()) + return results + + +func complete(command_id: int, result: CommandResult) -> CommandResult: + if not _in_flight.has(command_id): + return CommandResult.failed( + CommandResult.Code.NOT_FOUND, + command_id, + "Command is not in flight." + ) + if result == null: + return CommandResult.failed( + CommandResult.Code.INVALID_COMMAND, + command_id, + "Result is null." + ) + + var stored_result := result.copy() + stored_result.command_id = command_id + _processed_results[command_id] = stored_result + _in_flight.erase(command_id) + return stored_result.copy() + + +func get_processed_result(command_id: int) -> CommandResult: + if not _processed_results.has(command_id): + return null + var result: CommandResult = _processed_results[command_id] + return result.copy() + + +func get_result_by_idempotency_key(key: StringName) -> CommandResult: + if not _idempotency_to_command_id.has(key): + return null + var command_id: int = _idempotency_to_command_id[key] + if not _processed_results.has(command_id): + return null + var result: CommandResult = _processed_results[command_id] + return result.replay_copy() + + +func has_idempotency_key(key: StringName) -> bool: + return _idempotency_to_command_id.has(key) + + +func pending_count() -> int: + return _pending.size() + + +func in_flight_count() -> int: + return _in_flight.size() + + +func processed_count() -> int: + return _processed_results.size() + + +func get_accepted_command_metadata() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var command_ids: Array = _accepted_metadata.keys() + command_ids.sort() + for command_id: Variant in command_ids: + result.append((_accepted_metadata[command_id] as Dictionary).duplicate(true)) + return result + + +func next_execute_tick() -> int: + if _pending.is_empty(): + return -1 + return _pending[0].execute_tick + + +func create_snapshot() -> Dictionary: + var pending_data: Array[Dictionary] = [] + for command in _pending: + pending_data.append(command.to_dictionary()) + var in_flight_ids: Array = _in_flight.keys() + in_flight_ids.sort() + var in_flight_data: Array[Dictionary] = [] + for command_id: Variant in in_flight_ids: + var command: SimulationCommand = _in_flight[command_id] + in_flight_data.append(command.to_dictionary()) + var processed_ids: Array = _processed_results.keys() + processed_ids.sort() + var processed_data: Array[Dictionary] = [] + for command_id: Variant in processed_ids: + var result: CommandResult = _processed_results[command_id] + processed_data.append(result.to_dictionary()) + var idempotency_data := {} + var idempotency_keys: Array = _idempotency_to_command_id.keys() + idempotency_keys.sort() + for key: Variant in idempotency_keys: + idempotency_data[String(key)] = int(_idempotency_to_command_id[key]) + return { + "version": SNAPSHOT_VERSION, + "pending": pending_data, + "in_flight": in_flight_data, + "processed": processed_data, + "idempotency": idempotency_data, + "accepted": get_accepted_command_metadata(), + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + var version := LEGACY_SNAPSHOT_VERSION + if snapshot.has("version"): + if typeof(snapshot["version"]) != TYPE_INT: + return false + version = int(snapshot["version"]) + if version not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + return false + if version == SNAPSHOT_VERSION and not _has_exact_keys( + snapshot, + ["version", "pending", "in_flight", "processed", "idempotency", "accepted"] + ): + return false + for required_key in ["pending", "in_flight", "processed", "idempotency"]: + if not snapshot.has(required_key): + return false + if not snapshot["pending"] is Array \ + or not snapshot["in_flight"] is Array \ + or not snapshot["processed"] is Array \ + or not snapshot["idempotency"] is Dictionary \ + or (version == SNAPSHOT_VERSION and not snapshot.get("accepted", null) is Array): + return false + + var restored_pending: Array[SimulationCommand] = [] + var restored_in_flight := {} + var restored_known_ids := {} + var restored_processed := {} + var restored_idempotency := {} + var command_keys_by_id := {} + for command_data: Variant in snapshot["pending"]: + if not command_data is Dictionary: + return false + if version == SNAPSHOT_VERSION and not _strict_command_dictionary(command_data): + return false + var command := SimulationCommand.from_dictionary(command_data) + if not command.is_valid() or restored_known_ids.has(command.command_id): + return false + restored_pending.append(command) + restored_known_ids[command.command_id] = true + command_keys_by_id[command.command_id] = command.idempotency_key + for command_data: Variant in snapshot["in_flight"]: + if not command_data is Dictionary: + return false + if version == SNAPSHOT_VERSION and not _strict_command_dictionary(command_data): + return false + var command := SimulationCommand.from_dictionary(command_data) + if not command.is_valid() or restored_known_ids.has(command.command_id): + return false + restored_in_flight[command.command_id] = command + restored_known_ids[command.command_id] = true + command_keys_by_id[command.command_id] = command.idempotency_key + for result_data: Variant in snapshot["processed"]: + if not result_data is Dictionary: + return false + if version == SNAPSHOT_VERSION and not _strict_result_dictionary(result_data): + return false + var result := CommandResult.from_dictionary(result_data) + if result.command_id <= 0 or restored_known_ids.has(result.command_id): + return false + restored_processed[result.command_id] = result + restored_known_ids[result.command_id] = true + var mapped_key_by_id := {} + for key: Variant in snapshot["idempotency"]: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(key).is_empty() \ + or typeof(snapshot["idempotency"][key]) != TYPE_INT: + return false + var mapped_command_id := int(snapshot["idempotency"][key]) + if not restored_known_ids.has(mapped_command_id) \ + or mapped_key_by_id.has(mapped_command_id): + return false + restored_idempotency[StringName(key)] = mapped_command_id + mapped_key_by_id[mapped_command_id] = StringName(key) + for command_id: Variant in command_keys_by_id: + var command_key := StringName(command_keys_by_id[command_id]) + if command_key.is_empty(): + if mapped_key_by_id.has(command_id): + return false + elif mapped_key_by_id.get(command_id, &"") != command_key: + return false + + var restored_metadata := {} + if version == SNAPSHOT_VERSION: + var previous_command_id := 0 + for metadata_value: Variant in snapshot["accepted"]: + if not metadata_value is Dictionary or not _has_exact_keys( + metadata_value, ["command_id", "idempotency_key"] + ): + return false + var command_id_value: Variant = metadata_value.get("command_id", null) + var idempotency_value: Variant = metadata_value.get("idempotency_key", null) + if typeof(command_id_value) != TYPE_INT \ + or int(command_id_value) <= previous_command_id \ + or typeof(idempotency_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var command_id := int(command_id_value) + var idempotency_key := StringName(idempotency_value) + if not restored_known_ids.has(command_id) or restored_metadata.has(command_id): + return false + if command_keys_by_id.has(command_id) \ + and StringName(command_keys_by_id[command_id]) != idempotency_key: + return false + if idempotency_key.is_empty(): + if mapped_key_by_id.has(command_id): + return false + elif mapped_key_by_id.get(command_id, &"") != idempotency_key: + return false + restored_metadata[command_id] = { + "command_id": command_id, + "idempotency_key": String(idempotency_key), + } + previous_command_id = command_id + if restored_metadata.size() != restored_known_ids.size(): + return false + else: + # Legacy snapshots did not retain processed Command input. Their reverse + # idempotency map still lets migration preserve the exactly-once key. + var legacy_ids: Array = restored_known_ids.keys() + legacy_ids.sort() + for command_id: Variant in legacy_ids: + var idempotency_key := StringName(command_keys_by_id.get( + command_id, mapped_key_by_id.get(command_id, &"") + )) + restored_metadata[command_id] = { + "command_id": int(command_id), + "idempotency_key": String(idempotency_key), + } + + restored_pending.sort_custom(_comes_before) + _pending = restored_pending + _in_flight = restored_in_flight + _known_command_ids = restored_known_ids + _processed_results = restored_processed + _idempotency_to_command_id = restored_idempotency + _accepted_metadata = restored_metadata + return true + + +func _strict_command_dictionary(data: Dictionary) -> bool: + if not _has_exact_keys(data, [ + "command_id", "issued_tick", "execute_tick", "priority", "command_type", + "actor_id", "target_id", "payload", "idempotency_key", + ]): + return false + for key: String in ["command_id", "issued_tick", "execute_tick", "priority"]: + if typeof(data.get(key, null)) != TYPE_INT: + return false + for key: String in ["command_type", "actor_id", "target_id", "idempotency_key"]: + if typeof(data.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + return data.get("payload", null) is Dictionary + + +func _strict_result_dictionary(data: Dictionary) -> bool: + if not _has_exact_keys(data, [ + "code", "command_id", "target_id", "message", "resolution_hints", + "details", "is_replay", + ]): + return false + var code_value: Variant = data.get("code", null) + if typeof(code_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or CommandResult.Code.keys().find(String(code_value)) < 0 \ + or typeof(data.get("command_id", null)) != TYPE_INT \ + or typeof(data.get("target_id", null)) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(data.get("message", null)) != TYPE_STRING \ + or not data.get("resolution_hints", null) is Array \ + or not data.get("details", null) is Dictionary \ + or typeof(data.get("is_replay", null)) != TYPE_BOOL: + return false + for hint: Variant in data["resolution_hints"]: + if typeof(hint) != TYPE_STRING: + return false + return true + + +func _has_exact_keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + var seen: Dictionary = {} + for raw_key: Variant in source: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var key := String(raw_key) + if key not in expected or seen.has(key): + return false + seen[key] = true + return seen.size() == expected.size() + + +func _comes_before(left: SimulationCommand, right: SimulationCommand) -> bool: + if left.execute_tick != right.execute_tick: + return left.execute_tick < right.execute_tick + if left.priority != right.priority: + return left.priority > right.priority + return left.command_id < right.command_id diff --git a/scripts/core/command_queue.gd.uid b/scripts/core/command_queue.gd.uid new file mode 100644 index 0000000..6c072c1 --- /dev/null +++ b/scripts/core/command_queue.gd.uid @@ -0,0 +1 @@ +uid://dscjo2hyabf7 diff --git a/scripts/core/command_result.gd b/scripts/core/command_result.gd new file mode 100644 index 0000000..e58abde --- /dev/null +++ b/scripts/core/command_result.gd @@ -0,0 +1,104 @@ +class_name CommandResult +extends RefCounted + +enum Code { + OK, + INVALID_COMMAND, + INVALID_PAYLOAD, + DUPLICATE_COMMAND_ID, + DUPLICATE_IDEMPOTENCY_KEY, + NOT_FOUND, + VALIDATION_FAILED, + TRANSACTION_CLOSED, + TRANSACTION_APPLY_FAILED, + EXECUTOR_FAILED, +} + +var code: Code = Code.OK +var command_id := 0 +var target_id: StringName = &"" +var message := "" +var resolution_hints: Array[String] = [] +var details: Dictionary = {} +var is_replay := false + + +func _init( + p_code: Code = Code.OK, + p_command_id: int = 0, + p_message: String = "", + p_details: Dictionary = {} +) -> void: + code = p_code + command_id = p_command_id + message = p_message + details = p_details.duplicate(true) + + +static func succeeded( + p_command_id: int = 0, + p_details: Dictionary = {} +) -> CommandResult: + return CommandResult.new(Code.OK, p_command_id, "", p_details) + + +static func failed( + p_code: Code, + p_command_id: int, + p_message: String, + p_details: Dictionary = {} +) -> CommandResult: + assert(p_code != Code.OK, "Use CommandResult.succeeded() for a successful result.") + return CommandResult.new(p_code, p_command_id, p_message, p_details) + + +func is_success() -> bool: + return code == Code.OK + + +func copy() -> CommandResult: + var result := CommandResult.new(code, command_id, message, details) + result.target_id = target_id + result.resolution_hints.assign(resolution_hints) + result.is_replay = is_replay + return result + + +func replay_copy() -> CommandResult: + var result := copy() + result.is_replay = true + return result + + +func to_dictionary() -> Dictionary: + return { + "code": Code.keys()[code], + "command_id": command_id, + "target_id": String(target_id), + "message": message, + "resolution_hints": resolution_hints.duplicate(), + "details": details.duplicate(true), + "is_replay": is_replay, + } + + +static func from_dictionary(data: Dictionary) -> CommandResult: + var code_name := String(data.get("code", "INVALID_COMMAND")) + var parsed_code := Code.INVALID_COMMAND + var code_names := Code.keys() + var code_index := code_names.find(code_name) + if code_index >= 0: + parsed_code = code_index as Code + var result := CommandResult.new( + parsed_code, + int(data.get("command_id", 0)), + String(data.get("message", "")), + data.get("details", {}) as Dictionary + ) + result.target_id = StringName(data.get("target_id", "")) + var saved_hints: Variant = data.get("resolution_hints", []) + if saved_hints is Array: + for hint: Variant in saved_hints: + result.resolution_hints.append(String(hint)) + result.is_replay = bool(data.get("is_replay", false)) + return result diff --git a/scripts/core/command_result.gd.uid b/scripts/core/command_result.gd.uid new file mode 100644 index 0000000..ff7cc9f --- /dev/null +++ b/scripts/core/command_result.gd.uid @@ -0,0 +1 @@ +uid://b4gb8xmpwer3o diff --git a/scripts/core/definition_registry.gd b/scripts/core/definition_registry.gd new file mode 100644 index 0000000..ca7bdbc --- /dev/null +++ b/scripts/core/definition_registry.gd @@ -0,0 +1,762 @@ +class_name DefinitionRegistry +extends RefCounted + +## Loads, validates, and indexes immutable game definitions. +## +## Invalid definitions are reported but never exposed through lookup/query APIs. +## All returned definition and issue lists use a deterministic ID-first order. + +const DEFAULT_FACILITY_DIRECTORY := "res://data/facilities" +const FACILITY_FILE_EXTENSION := "tres" + +const SEVERITY_ERROR := &"error" +const SEVERITY_WARNING := &"warning" + +var _facilities_by_id: Dictionary = {} +var _ordered_facility_ids: Array[StringName] = [] +var _errors: Array[Dictionary] = [] +var _warnings: Array[Dictionary] = [] +var _source_count := 0 + + +## Scans the default facility directory and rebuilds the registry. +func rebuild(directory_path: String = DEFAULT_FACILITY_DIRECTORY) -> Dictionary: + _reset() + var paths := _discover_facility_paths(directory_path) + if not _errors.is_empty(): + _finish_rebuild() + return get_report() + _load_and_index(paths) + _finish_rebuild() + return get_report() + + +## Rebuilds from an explicit path list. Primarily useful for content packs and tests. +func rebuild_from_paths(paths: Array[String]) -> Dictionary: + _reset() + var ordered_paths := paths.duplicate() + ordered_paths.sort() + _load_and_index(ordered_paths) + _finish_rebuild() + return get_report() + + +func is_valid() -> bool: + return _errors.is_empty() + + +func has_fatal_errors() -> bool: + return not _errors.is_empty() + + +func facility_count() -> int: + return _ordered_facility_ids.size() + + +func source_count() -> int: + return _source_count + + +func has_facility(facility_id: StringName) -> bool: + return _facilities_by_id.has(facility_id) + + +func get_facility(facility_id: StringName) -> FacilityDefinition: + return _facilities_by_id.get(facility_id) as FacilityDefinition + + +func get_facility_ids() -> Array[StringName]: + return _ordered_facility_ids.duplicate() + + +func get_all_facilities() -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for facility_id in _ordered_facility_ids: + result.append(_facilities_by_id[facility_id] as FacilityDefinition) + return result + + +func query_facilities_by_tag(tag: StringName) -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for facility_id in _ordered_facility_ids: + var definition := _facilities_by_id[facility_id] as FacilityDefinition + if tag in definition.tags: + result.append(definition) + return result + + +func query_facilities_by_zone(zone: int) -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for facility_id in _ordered_facility_ids: + var definition := _facilities_by_id[facility_id] as FacilityDefinition + if definition.zone == zone: + result.append(definition) + return result + + +func query_facilities_by_layer(layer: int) -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for facility_id in _ordered_facility_ids: + var definition := _facilities_by_id[facility_id] as FacilityDefinition + if definition.layer == layer: + result.append(definition) + return result + + +func get_fatal_errors() -> Array[Dictionary]: + return _errors.duplicate(true) + + +func get_warnings() -> Array[Dictionary]: + return _warnings.duplicate(true) + + +func get_report() -> Dictionary: + return { + &"valid": is_valid(), + &"source_count": _source_count, + &"definition_count": facility_count(), + &"error_count": _errors.size(), + &"warning_count": _warnings.size(), + &"errors": get_fatal_errors(), + &"warnings": get_warnings(), + } + + +func _reset() -> void: + _facilities_by_id.clear() + _ordered_facility_ids.clear() + _errors.clear() + _warnings.clear() + _source_count = 0 + + +func _discover_facility_paths(directory_path: String) -> Array[String]: + var result: Array[String] = [] + var directory := DirAccess.open(directory_path) + if directory == null: + _add_error( + &"directory_unreadable", + &"", + directory_path, + &"resource_path", + "Facility definition directory cannot be opened." + ) + return result + + directory.list_dir_begin() + var file_name := directory.get_next() + while not file_name.is_empty(): + if not directory.current_is_dir() and file_name.get_extension().to_lower() == FACILITY_FILE_EXTENSION: + result.append(directory_path.path_join(file_name)) + file_name = directory.get_next() + directory.list_dir_end() + result.sort() + + if result.is_empty(): + _add_warning( + &"no_definitions_found", + &"", + directory_path, + &"resource_path", + "No facility definitions were found." + ) + return result + + +func _load_and_index(paths: Array[String]) -> void: + var candidates: Array[Dictionary] = [] + var seen_paths: Dictionary = {} + + for path in paths: + _source_count += 1 + if seen_paths.has(path): + _add_warning( + &"duplicate_source_path", + &"", + path, + &"resource_path", + "The same source path was supplied more than once." + ) + seen_paths[path] = true + + if not _validate_requested_path(path): + continue + var resource := ResourceLoader.load(path) + if resource == null: + _add_error( + &"resource_load_failed", + &"", + path, + &"resource_path", + "The resource could not be loaded." + ) + continue + if not resource is FacilityDefinition: + _add_error( + &"wrong_resource_type", + &"", + path, + &"resource", + "Expected FacilityDefinition, got %s." % resource.get_class() + ) + continue + + var definition := resource as FacilityDefinition + candidates.append({ + &"definition": definition, + &"path": path, + &"valid": _validate_facility(definition, path), + }) + + candidates.sort_custom(_candidate_less) + var counts_by_id: Dictionary = {} + var paths_by_id: Dictionary = {} + for candidate in candidates: + var definition := candidate[&"definition"] as FacilityDefinition + if definition.id.is_empty(): + continue + counts_by_id[definition.id] = int(counts_by_id.get(definition.id, 0)) + 1 + if not paths_by_id.has(definition.id): + paths_by_id[definition.id] = [] + var id_paths := paths_by_id[definition.id] as Array + id_paths.append(String(candidate[&"path"])) + + for candidate in candidates: + var definition := candidate[&"definition"] as FacilityDefinition + var path := String(candidate[&"path"]) + if definition.id.is_empty(): + continue + if int(counts_by_id[definition.id]) > 1: + var duplicate_paths := paths_by_id[definition.id] as Array + _add_error( + &"duplicate_id", + definition.id, + path, + &"id", + "Facility ID is duplicated by: %s." % ", ".join(duplicate_paths) + ) + continue + if not bool(candidate[&"valid"]): + continue + _facilities_by_id[definition.id] = definition + _ordered_facility_ids.append(definition.id) + + +func _validate_requested_path(path: String) -> bool: + var valid := true + if path.is_empty(): + _add_error(&"empty_resource_path", &"", path, &"resource_path", "Resource path is empty.") + return false + if not path.begins_with("res://"): + _add_error( + &"non_project_resource_path", + &"", + path, + &"resource_path", + "Facility definitions must use a res:// project path." + ) + valid = false + if path.get_extension().to_lower() != FACILITY_FILE_EXTENSION: + _add_error( + &"wrong_resource_extension", + &"", + path, + &"resource_path", + "Facility definitions must use the .tres extension." + ) + valid = false + if not FileAccess.file_exists(path): + _add_error( + &"resource_not_found", + &"", + path, + &"resource_path", + "Facility definition file does not exist." + ) + valid = false + return valid + + +func _validate_facility(definition: FacilityDefinition, requested_path: String) -> bool: + var valid := true + var facility_id := definition.id + var actual_path := definition.resource_path + + if facility_id.is_empty(): + _add_error(&"missing_required_value", facility_id, requested_path, &"id", "Facility ID is required.") + valid = false + elif not _is_snake_case_id(String(facility_id)): + _add_error( + &"invalid_id_format", + facility_id, + requested_path, + &"id", + "Facility ID must be lowercase ASCII snake_case and begin with a letter." + ) + valid = false + + if definition.display_name.strip_edges().is_empty(): + _add_error( + &"missing_required_value", + facility_id, + requested_path, + &"display_name", + "Display name is required." + ) + valid = false + if definition.description.strip_edges().is_empty(): + _add_warning( + &"empty_description", + facility_id, + requested_path, + &"description", + "Description is empty." + ) + + if actual_path.is_empty(): + _add_error( + &"missing_resource_path", + facility_id, + requested_path, + &"resource_path", + "Loaded FacilityDefinition has no resource_path." + ) + valid = false + elif actual_path != requested_path: + _add_error( + &"resource_path_mismatch", + facility_id, + requested_path, + &"resource_path", + "Loaded resource_path '%s' does not match its requested path." % actual_path + ) + valid = false + + if definition.zone < FacilityDefinition.MapZone.ANY \ + or definition.zone > FacilityDefinition.MapZone.UNDERGROUND: + _add_error(&"value_out_of_range", facility_id, requested_path, &"zone", "Zone enum value is out of range.") + valid = false + if definition.layer < FacilityDefinition.ViewLayer.FRONT \ + or definition.layer > FacilityDefinition.ViewLayer.BACK: + _add_error(&"value_out_of_range", facility_id, requested_path, &"layer", "Layer enum value is out of range.") + valid = false + if definition.footprint.x <= 0 or definition.footprint.y <= 0: + _add_error( + &"value_out_of_range", + facility_id, + requested_path, + &"footprint", + "Footprint dimensions must both be greater than zero." + ) + valid = false + if definition.build_minutes <= 0: + _add_error(&"value_out_of_range", facility_id, requested_path, &"build_minutes", "Build time must be greater than zero.") + valid = false + if definition.weight_per_cell not in [1, 2, 3]: + _add_error(&"value_out_of_range", facility_id, requested_path, &"weight_per_cell", "Structural weight must be 1, 2, or 3.") + valid = false + if definition.allowed_rotations.is_empty(): + _add_error(&"missing_required_value", facility_id, requested_path, &"allowed_rotations", "At least one rotation is required.") + valid = false + else: + var seen_rotations := {} + for rotation in definition.allowed_rotations: + if rotation not in [0, 90, 180, 270] or seen_rotations.has(rotation): + _add_error(&"invalid_rotation", facility_id, requested_path, &"allowed_rotations", "Rotations must be unique quarter turns.") + valid = false + seen_rotations[rotation] = true + if definition.staff_min < 0 or definition.staff_max < definition.staff_min: + _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Staff limits are inconsistent.") + valid = false + if definition.capacity < 0 or definition.queue_capacity < 0: + _add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Service and queue capacities cannot be negative.") + valid = false + if definition.base_service_minutes <= 0 or definition.base_price < 0: + _add_error(&"value_out_of_range", facility_id, requested_path, &"base_service_minutes", "Service duration must be positive and price cannot be negative.") + valid = false + for item_id: Variant in definition.inventory_inputs: + if StringName(item_id).is_empty() or int(definition.inventory_inputs[item_id]) <= 0: + _add_error(&"invalid_inventory_requirement", facility_id, requested_path, &"inventory_inputs", "Inventory input ids and quantities must be positive.") + valid = false + for item_id: Variant in definition.inventory_outputs: + if StringName(item_id).is_empty() or int(definition.inventory_outputs[item_id]) <= 0: + _add_error(&"invalid_inventory_output", facility_id, requested_path, &"inventory_outputs", "Inventory output ids and quantities must be positive.") + valid = false + + if not _validate_construction_cost(definition.construction_cost, facility_id, requested_path): + valid = false + if definition.maintenance_mana < 0: + _add_error( + &"value_out_of_range", + facility_id, + requested_path, + &"maintenance_mana", + "Maintenance mana cannot be negative." + ) + valid = false + if definition.staff_min < 0: + _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Minimum staff cannot be negative.") + valid = false + if definition.staff_max < 0: + _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_max", "Maximum staff cannot be negative.") + valid = false + if definition.staff_max < definition.staff_min: + _add_error( + &"invalid_value_relation", + facility_id, + requested_path, + &"staff_max", + "Maximum staff must be greater than or equal to minimum staff." + ) + valid = false + if definition.capacity < 0: + _add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Capacity cannot be negative.") + valid = false + + if not _validate_tags(definition.tags, facility_id, requested_path): + valid = false + if not _validate_detailed_profile(definition, requested_path): + valid = false + if not facility_id.is_empty() and requested_path.get_file().get_basename() != String(facility_id): + _add_warning( + &"id_filename_mismatch", + facility_id, + requested_path, + &"id", + "Facility ID does not match the resource filename." + ) + return valid + + +func _validate_detailed_profile(definition: FacilityDefinition, path: String) -> bool: + var facility_id := definition.id + if definition.definition_schema_version == FacilityDefinition.LEGACY_SCHEMA_VERSION: + return true + if definition.definition_schema_version != FacilityDefinition.DETAILED_SCHEMA_VERSION: + _add_error(&"unsupported_definition_schema", facility_id, path, &"definition_schema_version", "Facility definition schema is unsupported.") + return false + var valid := true + if definition.category.is_empty() or not _is_snake_case_id(String(definition.category)): + _add_error(&"invalid_category", facility_id, path, &"category", "Detailed facilities require one snake_case category.") + valid = false + if not _validate_service_profiles(definition, path): + valid = false + for field_row: Array in [ + [&"service_types", definition.service_types, true], + [&"required_connection_tags", definition.required_connection_tags, false], + [&"preferred_adjacency_tags", definition.preferred_adjacency_tags, false], + [&"forbidden_adjacency_tags", definition.forbidden_adjacency_tags, false], + [&"allowed_guest_tags", definition.allowed_guest_tags, false], + [&"forbidden_guest_tags", definition.forbidden_guest_tags, false], + [&"research_requirements", definition.research_requirements, false], + [&"statistics_keys", definition.statistics_keys, true], + ]: + if not _validate_name_list(field_row[1], bool(field_row[2]), facility_id, path, field_row[0]): + valid = false + if not _validate_access_offsets(definition, definition.guest_access_offsets, &"guest_access_offsets", path): + valid = false + if not _validate_access_offsets(definition, definition.staff_access_offsets, &"staff_access_offsets", path): + valid = false + if not definition.pass_through and definition.guest_access_offsets.is_empty() and definition.staff_access_offsets.is_empty(): + _add_error(&"missing_access_point", facility_id, path, &"guest_access_offsets", "A non-route detailed facility requires a guest or staff access point.") + valid = false + if definition.max_level < 1 or definition.max_level > 5 or definition.upgrade_costs.size() != definition.max_level - 1: + _add_error(&"invalid_upgrade_ladder", facility_id, path, &"upgrade_costs", "Upgrade costs must contain exactly max_level - 1 entries.") + valid = false + for index: int in definition.upgrade_costs.size(): + if not _validate_construction_cost(definition.upgrade_costs[index], facility_id, path): + valid = false + if definition.demolition_refund_percent < 0 or definition.demolition_refund_percent > 100: + _add_error(&"value_out_of_range", facility_id, path, &"demolition_refund_percent", "Demolition refund percent must be from 0 through 100.") + valid = false + if definition.unlock_rank < 1 or definition.unlock_rank > 7: + _add_error(&"value_out_of_range", facility_id, path, &"unlock_rank", "Unlock rank must be from 1 through 7.") + valid = false + var expected_schedule_keys: Array[StringName] = [&"morning", &"day", &"night"] + if definition.opening_schedule.size() != expected_schedule_keys.size() \ + or not definition.opening_schedule.has(&"morning") \ + or not definition.opening_schedule.has(&"day") \ + or not definition.opening_schedule.has(&"night"): + _add_error(&"invalid_opening_schedule", facility_id, path, &"opening_schedule", "Opening schedule must contain exactly morning, day, and night.") + valid = false + else: + for time_band: StringName in expected_schedule_keys: + if not definition.opening_schedule[time_band] is bool: + _add_error(&"wrong_value_type", facility_id, path, &"opening_schedule", "Opening schedule values must be booleans.") + valid = false + if not _validate_nonnegative_int_map(definition.incident_weights, facility_id, path, &"incident_weights"): + valid = false + if not _validate_policy_schema(definition.policy_schema, facility_id, path): + valid = false + if definition.adult_content and (not definition.tags.has(&"adult_only") or definition.content_replacement_id.is_empty()): + _add_error(&"unsafe_adult_content_profile", facility_id, path, &"adult_content", "Adult content requires adult_only and a safe content replacement ID.") + valid = false + if not definition.adult_content and not definition.content_replacement_id.is_empty(): + _add_warning(&"unused_content_replacement", facility_id, path, &"content_replacement_id", "A non-adult facility does not use a content replacement.") + return valid + + +func _validate_service_profiles(definition: FacilityDefinition, path: String) -> bool: + var valid := true + if definition.default_service_type.is_empty() or not definition.service_types.has(definition.default_service_type): + _add_error(&"invalid_default_service", definition.id, path, &"default_service_type", "Default service must reference one declared service type.") + valid = false + if definition.service_profiles.size() != definition.service_types.size(): + _add_error(&"service_profile_mismatch", definition.id, path, &"service_profiles", "Every declared service type requires exactly one profile.") + valid = false + for service_type: StringName in definition.service_types: + if not definition.service_profiles.has(service_type) or not definition.service_profiles[service_type] is Dictionary: + _add_error(&"missing_service_profile", definition.id, path, &"service_profiles", "Service '%s' requires a Dictionary profile." % service_type) + valid = false + continue + var profile := definition.service_profiles[service_type] as Dictionary + for required_key: StringName in [&"duration_minutes", &"base_quality", &"base_price", &"capacity_units", &"need_effects"]: + if not profile.has(required_key): + _add_error(&"missing_service_profile_field", definition.id, path, &"service_profiles", "Service '%s' is missing '%s'." % [service_type, required_key]) + valid = false + if profile.has(&"duration_minutes") and (not profile[&"duration_minutes"] is int or int(profile[&"duration_minutes"]) <= 0): + _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' duration_minutes must be a positive integer." % service_type) + valid = false + if profile.has(&"base_quality") and (not profile[&"base_quality"] is int or int(profile[&"base_quality"]) < 0 or int(profile[&"base_quality"]) > 100): + _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' base_quality must be an integer from 0 through 100." % service_type) + valid = false + if profile.has(&"base_price") and (not profile[&"base_price"] is int or int(profile[&"base_price"]) < 0): + _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' base_price must be a non-negative integer." % service_type) + valid = false + if profile.has(&"capacity_units") and (not profile[&"capacity_units"] is int or int(profile[&"capacity_units"]) <= 0): + _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' capacity_units must be a positive integer." % service_type) + valid = false + if not profile.has(&"need_effects"): + pass # The missing-field issue was emitted above. + elif not profile[&"need_effects"] is Dictionary: + _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' need_effects must be a Dictionary." % service_type) + valid = false + elif not _validate_signed_int_map(profile[&"need_effects"], -100, 100, definition.id, path, &"service_profiles"): + valid = false + return valid + + +func _validate_name_list(values: Array[StringName], required: bool, facility_id: StringName, path: String, field: StringName) -> bool: + if required and values.is_empty(): + _add_error(&"missing_required_value", facility_id, path, field, "%s requires at least one ID." % field) + return false + var valid := true + var seen: Dictionary = {} + for value: StringName in values: + if value.is_empty() or not _is_snake_case_id(String(value)) or seen.has(value): + _add_error(&"invalid_id_list", facility_id, path, field, "%s must contain unique snake_case IDs." % field) + valid = false + seen[value] = true + return valid + + +func _validate_access_offsets(definition: FacilityDefinition, offsets: Array[Vector2i], field: StringName, path: String) -> bool: + var valid := true + var seen: Dictionary = {} + for offset: Vector2i in offsets: + if offset.x < 0 or offset.y < 0 or offset.x >= definition.footprint.x or offset.y >= definition.footprint.y or seen.has(offset): + _add_error(&"invalid_access_offset", definition.id, path, field, "Access points must be unique cells inside the unrotated footprint.") + valid = false + seen[offset] = true + return valid + + +func _validate_nonnegative_int_map(values: Dictionary, facility_id: StringName, path: String, field: StringName) -> bool: + var valid := true + for raw_key: Variant in values: + var key := StringName(raw_key) + if key.is_empty() or not _is_snake_case_id(String(key)) or not values[raw_key] is int or int(values[raw_key]) < 0: + _add_error(&"invalid_weight_map", facility_id, path, field, "%s requires snake_case IDs and non-negative integer weights." % field) + valid = false + return valid + + +func _validate_signed_int_map(values: Dictionary, minimum: int, maximum: int, facility_id: StringName, path: String, field: StringName) -> bool: + var valid := true + for raw_key: Variant in values: + var key := StringName(raw_key) + if key.is_empty() or not _is_snake_case_id(String(key)) or not values[raw_key] is int or int(values[raw_key]) < minimum or int(values[raw_key]) > maximum: + _add_error(&"invalid_signed_value_map", facility_id, path, field, "%s requires snake_case IDs and integer values from %d through %d." % [field, minimum, maximum]) + valid = false + return valid + + +func _validate_policy_schema(values: Dictionary, facility_id: StringName, path: String) -> bool: + var valid := true + for raw_key: Variant in values: + var key := StringName(raw_key) + var row: Variant = values[raw_key] + if key.is_empty() or not _is_snake_case_id(String(key)) or not row is Dictionary: + _add_error(&"invalid_policy_schema", facility_id, path, &"policy_schema", "Policy entries require snake_case IDs and Dictionary schemas.") + valid = false + continue + var schema := row as Dictionary + if not schema.has(&"type") or not schema.has(&"default") or StringName(schema[&"type"]) not in [&"bool", &"enum", &"int"]: + _add_error(&"invalid_policy_schema", facility_id, path, &"policy_schema", "Every policy requires type and default; type must be bool, enum, or int.") + valid = false + return valid + + +func _validate_construction_cost(cost: Dictionary, facility_id: StringName, path: String) -> bool: + var valid := true + for required_key in [&"gold", &"materials"]: + if not cost.has(required_key): + _add_error( + &"missing_required_key", + facility_id, + path, + &"construction_cost", + "Construction cost is missing '%s'." % required_key + ) + valid = false + continue + var value: Variant = cost[required_key] + if not value is int: + _add_error( + &"wrong_value_type", + facility_id, + path, + &"construction_cost", + "Construction cost '%s' must be an integer." % required_key + ) + valid = false + elif int(value) < 0: + _add_error( + &"value_out_of_range", + facility_id, + path, + &"construction_cost", + "Construction cost '%s' cannot be negative." % required_key + ) + valid = false + + var cost_keys := cost.keys() + cost_keys.sort() + for key in cost_keys: + if key != &"gold" and key != &"materials": + _add_warning( + &"unknown_cost_key", + facility_id, + path, + &"construction_cost", + "Unknown construction cost key '%s' is ignored by the base game." % key + ) + return valid + + +func _validate_tags(tags: Array[StringName], facility_id: StringName, path: String) -> bool: + var valid := true + var seen: Dictionary = {} + for tag in tags: + if tag.is_empty() or not _is_snake_case_id(String(tag)): + _add_error( + &"invalid_tag_format", + facility_id, + path, + &"tags", + "Tags must be non-empty lowercase ASCII snake_case identifiers." + ) + valid = false + if seen.has(tag): + _add_warning(&"duplicate_tag", facility_id, path, &"tags", "Duplicate tag '%s'." % tag) + seen[tag] = true + return valid + + +func _is_snake_case_id(value: String) -> bool: + if value.is_empty(): + return false + var first := value.unicode_at(0) + if first < 97 or first > 122: + return false + var previous_was_underscore := false + for index in value.length(): + var character := value.unicode_at(index) + var is_letter := character >= 97 and character <= 122 + var is_number := character >= 48 and character <= 57 + var is_underscore := character == 95 + if not is_letter and not is_number and not is_underscore: + return false + if is_underscore and previous_was_underscore: + return false + previous_was_underscore = is_underscore + return not previous_was_underscore + + +func _finish_rebuild() -> void: + _ordered_facility_ids.sort_custom(_id_less) + _errors.sort_custom(_issue_less) + _warnings.sort_custom(_issue_less) + + +func _add_error( + code: StringName, + definition_id: StringName, + resource_path: String, + field: StringName, + message: String +) -> void: + _errors.append(_make_issue(SEVERITY_ERROR, code, definition_id, resource_path, field, message)) + + +func _add_warning( + code: StringName, + definition_id: StringName, + resource_path: String, + field: StringName, + message: String +) -> void: + _warnings.append(_make_issue(SEVERITY_WARNING, code, definition_id, resource_path, field, message)) + + +func _make_issue( + severity: StringName, + code: StringName, + definition_id: StringName, + resource_path: String, + field: StringName, + message: String +) -> Dictionary: + return { + &"severity": severity, + &"code": code, + &"definition_id": definition_id, + &"resource_path": resource_path, + &"field": field, + &"message": message, + } + + +func _candidate_less(left: Dictionary, right: Dictionary) -> bool: + var left_definition := left[&"definition"] as FacilityDefinition + var right_definition := right[&"definition"] as FacilityDefinition + var left_id := String(left_definition.id) + var right_id := String(right_definition.id) + if left_id != right_id: + return left_id < right_id + return String(left[&"path"]) < String(right[&"path"]) + + +func _id_less(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + + +func _issue_less(left: Dictionary, right: Dictionary) -> bool: + var left_id := String(left[&"definition_id"]) + var right_id := String(right[&"definition_id"]) + if left_id != right_id: + return left_id < right_id + var left_path := String(left[&"resource_path"]) + var right_path := String(right[&"resource_path"]) + if left_path != right_path: + return left_path < right_path + var left_field := String(left[&"field"]) + var right_field := String(right[&"field"]) + if left_field != right_field: + return left_field < right_field + var left_code := String(left[&"code"]) + var right_code := String(right[&"code"]) + if left_code != right_code: + return left_code < right_code + return String(left[&"message"]) < String(right[&"message"]) diff --git a/scripts/core/definition_registry.gd.uid b/scripts/core/definition_registry.gd.uid new file mode 100644 index 0000000..e9ed594 --- /dev/null +++ b/scripts/core/definition_registry.gd.uid @@ -0,0 +1 @@ +uid://c51liwjesdyql diff --git a/scripts/core/deterministic_rng.gd b/scripts/core/deterministic_rng.gd new file mode 100644 index 0000000..9a4dc72 --- /dev/null +++ b/scripts/core/deterministic_rng.gd @@ -0,0 +1,154 @@ +class_name DeterministicRng +extends RefCounted + +## A small deterministic random stream for simulation code. +## +## Each subsystem must create its own stream from the shared root seed and a +## stable stream ID. Saving both state and counter allows an exact continuation +## without coupling unrelated systems through one global random sequence. + +const ALGORITHM_ID: StringName = &"xorshift32-sha256-v1" +const SNAPSHOT_VERSION := 1 +const UINT32_MASK := 0xffffffff +const UINT32_RANGE := 0x100000000 +const FALLBACK_NON_ZERO_STATE := 0x6d2b79f5 + +var _root_seed := 0 +var _stream_id: StringName = &"default" +var _state := FALLBACK_NON_ZERO_STATE +var _counter := 0 + + +static func from_root_seed(root_seed: int, stream_id: StringName) -> Variant: + # Loading by resource path keeps this factory usable on a clean checkout, + # before Godot has generated its global class-name cache. + var result: Variant = load("res://scripts/core/deterministic_rng.gd").new() + result.reset(root_seed, stream_id) + return result + + +func reset(root_seed: int, stream_id: StringName) -> void: + _root_seed = root_seed + _stream_id = stream_id + _state = _derive_non_zero_state(root_seed, stream_id) + _counter = 0 + + +func next_u32() -> int: + # xorshift32 operates only on non-negative 32-bit values. Intermediates stay + # below the signed 64-bit limit used by GDScript integers. + var value := _state + value ^= (value << 13) & UINT32_MASK + value ^= value >> 17 + value ^= (value << 5) & UINT32_MASK + _state = value & UINT32_MASK + _counter += 1 + return _state + + +func randi_range(minimum: int, maximum: int) -> int: + if minimum > maximum: + push_error("DeterministicRng range minimum must not exceed maximum") + return minimum + + # A crossing-zero subtraction can overflow only for ranges much larger than + # this generator supports, so reject it before evaluating the subtraction. + var width := 0 + if minimum < 0 and maximum > 0: + if maximum > minimum + UINT32_MASK: + push_error("DeterministicRng supports ranges of at most 2^32 values") + return minimum + width = maximum - minimum + else: + width = maximum - minimum + if width > UINT32_MASK: + push_error("DeterministicRng supports ranges of at most 2^32 values") + return minimum + + var span := width + 1 + if span == 1: + return minimum + + # Rejection sampling avoids modulo bias for spans which do not divide 2^32. + var acceptance_limit := UINT32_RANGE - (UINT32_RANGE % span) + while true: + var sample := next_u32() + if sample < acceptance_limit: + return minimum + (sample % span) + return minimum + + +func snapshot() -> Dictionary: + return { + "version": SNAPSHOT_VERSION, + "algorithm": String(ALGORITHM_ID), + "root_seed": _root_seed, + "stream_id": String(_stream_id), + "state": _state, + "counter": _counter, + } + + +func restore(saved_state: Dictionary) -> bool: + if int(saved_state.get("version", -1)) != SNAPSHOT_VERSION: + push_error("Unsupported DeterministicRng snapshot version") + return false + if StringName(saved_state.get("algorithm", "")) != ALGORITHM_ID: + push_error("DeterministicRng snapshot algorithm does not match") + return false + + var restored_state := int(saved_state.get("state", 0)) + var restored_counter := int(saved_state.get("counter", -1)) + if restored_state <= 0 or restored_state > UINT32_MASK: + push_error("DeterministicRng snapshot contains an invalid state") + return false + if restored_counter < 0: + push_error("DeterministicRng snapshot contains an invalid counter") + return false + + _root_seed = int(saved_state.get("root_seed", 0)) + _stream_id = StringName(saved_state.get("stream_id", "default")) + _state = restored_state + _counter = restored_counter + return true + + +func get_root_seed() -> int: + return _root_seed + + +func get_stream_id() -> StringName: + return _stream_id + + +func get_state() -> int: + return _state + + +func get_counter() -> int: + return _counter + + +static func _derive_non_zero_state(root_seed: int, stream_id: StringName) -> int: + var stream_bytes := String(stream_id).to_utf8_buffer() + var context := HashingContext.new() + if context.start(HashingContext.HASH_SHA256) != OK: + push_error("Could not initialize the deterministic RNG seed hash") + return FALLBACK_NON_ZERO_STATE + context.update("the-tree:deterministic-rng:v1\n".to_utf8_buffer()) + context.update(("%d\n%d\n" % [root_seed, stream_bytes.size()]).to_utf8_buffer()) + context.update(stream_bytes) + var digest := context.finish() + + var derived_state := 0 + for byte_index in range(4): + derived_state = (derived_state << 8) | int(digest[byte_index]) + if derived_state == 0: + # The all-zero state is absorbing for xorshift32. A second digest word is + # already independently distributed; retain a literal fallback as a final + # guard for the astronomically unlikely double-zero case. + for byte_index in range(4, 8): + derived_state = (derived_state << 8) | int(digest[byte_index]) + if derived_state == 0: + derived_state = FALLBACK_NON_ZERO_STATE + return derived_state diff --git a/scripts/core/deterministic_rng.gd.uid b/scripts/core/deterministic_rng.gd.uid new file mode 100644 index 0000000..02b1326 --- /dev/null +++ b/scripts/core/deterministic_rng.gd.uid @@ -0,0 +1 @@ +uid://d3kcft1dnys2a diff --git a/scripts/core/domain_event.gd b/scripts/core/domain_event.gd new file mode 100644 index 0000000..3c8146f --- /dev/null +++ b/scripts/core/domain_event.gd @@ -0,0 +1,75 @@ +class_name SimulationDomainEvent +extends RefCounted + +var event_id: StringName = &"" +var event_type: StringName = &"" +var absolute_minute := 0 +var source_service: StringName = &"" +var entity_ids: Array[StringName] = [] +var payload: Dictionary = {} +var causation_command_id := 0 + +func _init( + p_event_id: StringName = &"", + p_event_type: StringName = &"", + p_absolute_minute := 0, + p_source_service: StringName = &"", + p_entity_ids: Array[StringName] = [], + p_payload: Dictionary = {}, + p_causation_command_id := 0 +) -> void: + event_id = p_event_id + event_type = p_event_type + absolute_minute = p_absolute_minute + source_service = p_source_service + entity_ids = p_entity_ids.duplicate() + payload = p_payload.duplicate(true) + causation_command_id = p_causation_command_id + +func is_valid() -> bool: + return not event_id.is_empty() \ + and not event_type.is_empty() \ + and not source_service.is_empty() \ + and absolute_minute >= 0 \ + and causation_command_id >= 0 + +func copy() -> SimulationDomainEvent: + return SimulationDomainEvent.new( + event_id, + event_type, + absolute_minute, + source_service, + entity_ids, + payload, + causation_command_id + ) + +func to_dictionary() -> Dictionary: + var saved_entity_ids: Array[String] = [] + for entity_id in entity_ids: + saved_entity_ids.append(String(entity_id)) + return { + "event_id": String(event_id), + "event_type": String(event_type), + "absolute_minute": absolute_minute, + "source_service": String(source_service), + "entity_ids": saved_entity_ids, + "payload": payload.duplicate(true), + "causation_command_id": causation_command_id, + } + +static func from_dictionary(data: Dictionary) -> SimulationDomainEvent: + var restored_entity_ids: Array[StringName] = [] + var saved_entity_ids: Variant = data.get("entity_ids", []) + if saved_entity_ids is Array: + for entity_id: Variant in saved_entity_ids: + restored_entity_ids.append(StringName(entity_id)) + return SimulationDomainEvent.new( + StringName(data.get("event_id", "")), + StringName(data.get("event_type", "")), + int(data.get("absolute_minute", -1)), + StringName(data.get("source_service", "")), + restored_entity_ids, + data.get("payload", {}) as Dictionary, + int(data.get("causation_command_id", 0)) + ) diff --git a/scripts/core/domain_event.gd.uid b/scripts/core/domain_event.gd.uid new file mode 100644 index 0000000..af29eb5 --- /dev/null +++ b/scripts/core/domain_event.gd.uid @@ -0,0 +1 @@ +uid://y0x57c1kmwj7 diff --git a/scripts/core/domain_event_stream.gd b/scripts/core/domain_event_stream.gd new file mode 100644 index 0000000..11b1e31 --- /dev/null +++ b/scripts/core/domain_event_stream.gd @@ -0,0 +1,144 @@ +class_name DomainEventStream +extends RefCounted + +const SNAPSHOT_VERSION := 1 +const WILDCARD_EVENT_TYPE: StringName = &"*" +const RECENT_EVENT_LIMIT := 256 +const MAX_BATCH_SIZE := 4_096 + +var _subscribers: Dictionary = {} +var _published_ids: Dictionary = {} +var _recent_events: Array[SimulationDomainEvent] = [] +var _pending_deliveries: Array[SimulationDomainEvent] = [] +var _delivery_active := false + +func subscribe(event_type: StringName, subscriber_id: StringName, callback: Callable) -> bool: + if event_type.is_empty() or subscriber_id.is_empty() or not callback.is_valid(): + return false + if not _subscribers.has(event_type): + _subscribers[event_type] = {} + var subscribers: Dictionary = _subscribers[event_type] + if subscribers.has(subscriber_id): + return false + subscribers[subscriber_id] = callback + return true + +func unsubscribe(event_type: StringName, subscriber_id: StringName) -> void: + if _subscribers.has(event_type): + var subscribers: Dictionary = _subscribers[event_type] + subscribers.erase(subscriber_id) + +func publish(event: SimulationDomainEvent) -> bool: + return publish_batch([event]) + + +## Validate and commit the entire event set before the first subscriber runs. +## Reentrant publications join the end of the delivery queue, so callbacks can +## never observe or deliver only a prefix of the committed batch. +func publish_batch(events: Array) -> bool: + if events.size() > MAX_BATCH_SIZE: + return false + var committed: Array[SimulationDomainEvent] = [] + var batch_ids: Dictionary = {} + for event_value: Variant in events: + if not event_value is SimulationDomainEvent: + return false + var event: SimulationDomainEvent = event_value + if event == null or not event.is_valid() \ + or _published_ids.has(event.event_id) or batch_ids.has(event.event_id): + return false + batch_ids[event.event_id] = true + committed.append(event.copy()) + for event: SimulationDomainEvent in committed: + _published_ids[event.event_id] = true + _recent_events.append(event.copy()) + if _recent_events.size() > RECENT_EVENT_LIMIT: + _recent_events.pop_front() + _pending_deliveries.append(event.copy()) + _drain_deliveries() + return true + + +func _drain_deliveries() -> void: + if _delivery_active: + return + _delivery_active = true + while not _pending_deliveries.is_empty(): + var event: SimulationDomainEvent = _pending_deliveries.pop_front() + _deliver(event) + _delivery_active = false + + +func _deliver(event: SimulationDomainEvent) -> void: + var callbacks := {} + _collect_callbacks(event.event_type, callbacks) + _collect_callbacks(WILDCARD_EVENT_TYPE, callbacks) + var subscriber_ids: Array = callbacks.keys() + subscriber_ids.sort() + for subscriber_id: Variant in subscriber_ids: + var callback: Callable = callbacks[subscriber_id] + if callback.is_valid(): + callback.call(event.copy()) + +func has_published(event_id: StringName) -> bool: + return _published_ids.has(event_id) + +func recent_events() -> Array[SimulationDomainEvent]: + var result: Array[SimulationDomainEvent] = [] + for event in _recent_events: + result.append(event.copy()) + return result + +func create_snapshot() -> Dictionary: + var published_ids: Array = _published_ids.keys() + published_ids.sort_custom(func(left: Variant, right: Variant) -> bool: + return String(left) < String(right) + ) + var saved_ids: Array[String] = [] + for event_id: Variant in published_ids: + saved_ids.append(String(event_id)) + var saved_recent: Array[Dictionary] = [] + for event in _recent_events: + saved_recent.append(event.to_dictionary()) + return { + "version": SNAPSHOT_VERSION, + "published_ids": saved_ids, + "recent_events": saved_recent, + } + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _delivery_active or not _pending_deliveries.is_empty(): + return false + if int(snapshot.get("version", -1)) != SNAPSHOT_VERSION: + return false + if not snapshot.get("published_ids", null) is Array \ + or not snapshot.get("recent_events", null) is Array: + return false + var restored_ids := {} + for event_id: Variant in snapshot["published_ids"]: + var normalized_id := StringName(event_id) + if normalized_id.is_empty() or restored_ids.has(normalized_id): + return false + restored_ids[normalized_id] = true + var restored_recent: Array[SimulationDomainEvent] = [] + for event_data: Variant in snapshot["recent_events"]: + if not event_data is Dictionary: + return false + var event := SimulationDomainEvent.from_dictionary(event_data) + if not event.is_valid() or not restored_ids.has(event.event_id): + return false + restored_recent.append(event) + if restored_recent.size() > RECENT_EVENT_LIMIT: + return false + _published_ids = restored_ids + _recent_events = restored_recent + _pending_deliveries.clear() + return true + +func _collect_callbacks(event_type: StringName, target: Dictionary) -> void: + if not _subscribers.has(event_type): + return + var source: Dictionary = _subscribers[event_type] + for subscriber_id: Variant in source: + if not target.has(subscriber_id): + target[subscriber_id] = source[subscriber_id] diff --git a/scripts/core/domain_event_stream.gd.uid b/scripts/core/domain_event_stream.gd.uid new file mode 100644 index 0000000..b807adf --- /dev/null +++ b/scripts/core/domain_event_stream.gd.uid @@ -0,0 +1 @@ +uid://d21vwgov273if diff --git a/scripts/core/performance_budget.gd b/scripts/core/performance_budget.gd new file mode 100644 index 0000000..4e79afb --- /dev/null +++ b/scripts/core/performance_budget.gd @@ -0,0 +1,174 @@ +class_name SimulationPerformanceBudget +extends RefCounted + +## Lightweight runtime profiler with explicit integer-microsecond samples. +## It is diagnostic state only and deliberately stays outside Save snapshots. + +signal budget_exceeded(metric_id: StringName, report: Dictionary) + +enum Aggregation { + AVERAGE, + P95, + MAXIMUM, +} + +const DEFAULT_SAMPLE_CAPACITY := 512 + +const DEFAULT_BUDGETS := { + &"simulation_x1": {&"aggregation": Aggregation.AVERAGE, &"limit_us": 4_000}, + &"simulation_x4": {&"aggregation": Aggregation.P95, &"limit_us": 12_000}, + &"path_query": {&"aggregation": Aggregation.P95, &"limit_us": 2_000}, + &"main_thread_stall": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 50_000}, + &"save": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 5_000_000}, + &"load": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 8_000_000}, +} + +var sample_capacity := DEFAULT_SAMPLE_CAPACITY +var _budgets: Dictionary = {} +var _samples: Dictionary = {} +var _totals: Dictionary = {} +var _sample_counts: Dictionary = {} + + +func _init(capacity := DEFAULT_SAMPLE_CAPACITY) -> void: + sample_capacity = maxi(int(capacity), 1) + for raw_metric_id: Variant in DEFAULT_BUDGETS: + _budgets[StringName(raw_metric_id)] = ( + DEFAULT_BUDGETS[raw_metric_id] as Dictionary + ).duplicate(true) + + +func set_budget( + metric_id: StringName, + limit_us: int, + aggregation: Aggregation = Aggregation.P95 +) -> bool: + if metric_id.is_empty() or limit_us <= 0 \ + or aggregation < Aggregation.AVERAGE or aggregation > Aggregation.MAXIMUM: + return false + _budgets[metric_id] = { + &"aggregation": aggregation, + &"limit_us": limit_us, + } + return true + + +func record_sample(metric_id: StringName, duration_us: int) -> Dictionary: + if metric_id.is_empty() or duration_us < 0: + return {&"ok": false, &"error": "Metric ID and non-negative duration are required."} + if not _samples.has(metric_id): + _samples[metric_id] = [] + var values: Array = _samples[metric_id] + values.append(duration_us) + if values.size() > sample_capacity: + values.pop_front() + _totals[metric_id] = int(_totals.get(metric_id, 0)) + duration_us + _sample_counts[metric_id] = int(_sample_counts.get(metric_id, 0)) + 1 + var report := get_report(metric_id) + if bool(report.get(&"budget_exceeded", false)): + budget_exceeded.emit(metric_id, report.duplicate(true)) + return {&"ok": true, &"report": report} + + +func record_simulation_tick(duration_us: int, speed: int) -> Dictionary: + var metric_id := &"simulation_x4" if speed == 4 else &"simulation_x1" + return record_sample(metric_id, duration_us) + + +## Records the full main-thread cost of one rendered/process frame, including +## all game-minute ticks consumed at accelerated speed. This keeps x4 from +## accidentally reporting four individually-fast ticks while the frame stalls. +func record_simulation_frame(duration_us: int, speed: int, ticks_run: int) -> Dictionary: + if ticks_run <= 0: + return {&"ok": false, &"error": "At least one completed tick is required."} + var simulation_result := record_simulation_tick(duration_us, speed) + var stall_result := record_sample(&"main_thread_stall", duration_us) + return { + &"ok": bool(simulation_result[&"ok"]) and bool(stall_result[&"ok"]), + &"ticks_run": ticks_run, + &"simulation": simulation_result, + &"main_thread": stall_result, + } + + +func get_report(metric_id: StringName) -> Dictionary: + var values: Array = _samples.get(metric_id, []) + var count := values.size() + var lifetime_count := int(_sample_counts.get(metric_id, 0)) + var lifetime_total := int(_totals.get(metric_id, 0)) + var average_us := lifetime_total / lifetime_count if lifetime_count > 0 else 0 + var maximum_us := 0 + for raw_value: Variant in values: + maximum_us = maxi(maximum_us, int(raw_value)) + var p95_us := _percentile(values, 95) + var budget: Dictionary = _budgets.get(metric_id, {}) + var aggregation := int(budget.get(&"aggregation", Aggregation.P95)) + var measured_us := _aggregated_value(aggregation, average_us, p95_us, maximum_us) + var limit_us := int(budget.get(&"limit_us", 0)) + return { + &"metric_id": metric_id, + &"window_sample_count": count, + &"lifetime_sample_count": lifetime_count, + &"average_us": average_us, + &"p95_us": p95_us, + &"maximum_us": maximum_us, + &"aggregation": aggregation, + &"measured_us": measured_us, + &"limit_us": limit_us, + &"budget_exceeded": limit_us > 0 and measured_us > limit_us, + } + + +func get_all_reports() -> Array[Dictionary]: + var metric_ids: Array[StringName] = [] + for raw_metric_id: Variant in _budgets: + metric_ids.append(StringName(raw_metric_id)) + for raw_metric_id: Variant in _samples: + var metric_id := StringName(raw_metric_id) + if not metric_ids.has(metric_id): + metric_ids.append(metric_id) + metric_ids.sort_custom(func(a: StringName, b: StringName) -> bool: + return String(a) < String(b) + ) + var reports: Array[Dictionary] = [] + for metric_id: StringName in metric_ids: + reports.append(get_report(metric_id)) + return reports + + +func clear(metric_id: StringName = &"") -> void: + if metric_id.is_empty(): + _samples.clear() + _totals.clear() + _sample_counts.clear() + return + _samples.erase(metric_id) + _totals.erase(metric_id) + _sample_counts.erase(metric_id) + + +func _aggregated_value( + aggregation: int, + average_us: int, + p95_us: int, + maximum_us: int +) -> int: + match aggregation: + Aggregation.AVERAGE: + return average_us + Aggregation.MAXIMUM: + return maximum_us + _: + return p95_us + + +func _percentile(raw_values: Array, percentile: int) -> int: + if raw_values.is_empty(): + return 0 + var values: Array[int] = [] + for raw_value: Variant in raw_values: + values.append(int(raw_value)) + values.sort() + var rank_numerator := clampi(percentile, 0, 100) * values.size() + var index := maxi((rank_numerator + 99) / 100 - 1, 0) + return values[mini(index, values.size() - 1)] diff --git a/scripts/core/performance_budget.gd.uid b/scripts/core/performance_budget.gd.uid new file mode 100644 index 0000000..c59130e --- /dev/null +++ b/scripts/core/performance_budget.gd.uid @@ -0,0 +1 @@ +uid://dt8ia8mm76hc1 diff --git a/scripts/core/save_migration_registry.gd b/scripts/core/save_migration_registry.gd new file mode 100644 index 0000000..49c374a --- /dev/null +++ b/scripts/core/save_migration_registry.gd @@ -0,0 +1,142 @@ +class_name SaveMigrationRegistry +extends RefCounted + +## A deterministic, in-memory registry for adjacent save-format migrations. +## +## Every registered step migrates exactly N -> N + 1. The callback receives a +## deep copy of the current value plus the two version numbers and must return +## either the migrated Dictionary, or { ok = true, data = Dictionary }. A +## callback can reject a migration with { ok = false, code = ..., message = ... }. +## The caller-owned source Dictionary is never passed to migration code. + +var _steps: Dictionary = {} +var _migration_ids: Dictionary = {} + + +func register_step(from_version: int, migration_id: StringName, migrator: Callable) -> Dictionary: + if from_version < 0: + return _failure(&"INVALID_FROM_VERSION", "from_version must be non-negative") + if migration_id.is_empty(): + return _failure(&"INVALID_MIGRATION_ID", "migration_id must not be empty") + if not migrator.is_valid(): + return _failure(&"INVALID_MIGRATOR", str(migration_id)) + if _steps.has(from_version): + return _failure(&"DUPLICATE_MIGRATION_STEP", _required_step_id(from_version)) + if _migration_ids.has(migration_id): + return _failure(&"DUPLICATE_MIGRATION_ID", str(migration_id)) + _steps[from_version] = { + &"id": migration_id, + &"migrator": migrator, + } + _migration_ids[migration_id] = from_version + return { + &"ok": true, + &"code": &"OK", + &"migration_id": migration_id, + &"from_version": from_version, + &"to_version": from_version + 1, + } + + +func has_step(from_version: int) -> bool: + return _steps.has(from_version) + + +func get_step_id(from_version: int) -> StringName: + if not _steps.has(from_version): + return StringName() + return StringName(_steps[from_version][&"id"]) + + +func get_registered_steps() -> Array[Dictionary]: + var versions: Array = _steps.keys() + versions.sort() + var result: Array[Dictionary] = [] + for version_value: Variant in versions: + var from_version := int(version_value) + result.append({ + &"id": get_step_id(from_version), + &"from_version": from_version, + &"to_version": from_version + 1, + }) + return result + + +func migrate(source: Dictionary, from_version: int, target_version: int) -> Dictionary: + if from_version < 0 or target_version < 0 or target_version < from_version: + return _failure(&"INVALID_VERSION_RANGE", "%d -> %d" % [from_version, target_version]) + var source_guard := source.duplicate(true) + var working := source.duplicate(true) + var applied_ids: Array[StringName] = [] + for version in range(from_version, target_version): + if not _steps.has(version): + return { + &"ok": false, + &"code": &"MIGRATION_STEP_MISSING", + &"message": "missing adjacent migration %d -> %d" % [version, version + 1], + &"failed_from_version": version, + &"failed_to_version": version + 1, + &"required_migration_id": _required_step_id(version), + &"applied_migration_ids": applied_ids, + } + var step: Dictionary = _steps[version] + var migration_id := StringName(step[&"id"]) + var migrator: Callable = step[&"migrator"] + # Each callback receives its own deep copy so even a failing mutator cannot + # contaminate the last accepted intermediate value. + var callback_value: Variant = migrator.call(working.duplicate(true), version, version + 1) + var unpacked := _unpack_callback_result(callback_value) + if not unpacked[&"ok"]: + return { + &"ok": false, + &"code": StringName(unpacked.get(&"code", &"MIGRATION_STEP_FAILED")), + &"message": str(unpacked.get(&"message", migration_id)), + &"failed_migration_id": migration_id, + &"failed_from_version": version, + &"failed_to_version": version + 1, + &"applied_migration_ids": applied_ids, + } + working = unpacked[&"data"].duplicate(true) + applied_ids.append(migration_id) + if source != source_guard: + # This should be unreachable because callbacks only see deep copies. Keep + # the guard explicit so purity regressions fail closed. + return _failure(&"SOURCE_MUTATED", "migration source changed unexpectedly") + return { + &"ok": true, + &"code": &"OK", + &"data": working.duplicate(true), + &"from_version": from_version, + &"target_version": target_version, + &"applied_migration_ids": applied_ids, + } + + +func _unpack_callback_result(value: Variant) -> Dictionary: + if not value is Dictionary: + return _failure(&"MIGRATION_INVALID_RESULT", "migration callback must return a Dictionary") + var result: Dictionary = value + if result.has(&"ok") or result.has("ok"): + var result_ok := bool(result.get(&"ok", result.get("ok", false))) + if not result_ok: + return _failure( + StringName(result.get(&"code", result.get("code", &"MIGRATION_STEP_FAILED"))), + str(result.get(&"message", result.get("message", "migration rejected"))) + ) + var data_value: Variant = result.get(&"data", result.get("data", null)) + if not data_value is Dictionary: + return _failure(&"MIGRATION_INVALID_RESULT", "successful migration has no Dictionary data") + return {&"ok": true, &"code": &"OK", &"data": data_value} + return {&"ok": true, &"code": &"OK", &"data": result} + + +func _required_step_id(from_version: int) -> StringName: + return StringName("format_%d_to_%d" % [from_version, from_version + 1]) + + +func _failure(code: StringName, message: String) -> Dictionary: + return { + &"ok": false, + &"code": code, + &"message": message, + } diff --git a/scripts/core/save_migration_registry.gd.uid b/scripts/core/save_migration_registry.gd.uid new file mode 100644 index 0000000..8b78faa --- /dev/null +++ b/scripts/core/save_migration_registry.gd.uid @@ -0,0 +1 @@ +uid://dclgekguin7m2 diff --git a/scripts/core/save_service.gd b/scripts/core/save_service.gd new file mode 100644 index 0000000..83ce59f --- /dev/null +++ b/scripts/core/save_service.gd @@ -0,0 +1,1632 @@ +class_name SaveService +extends RefCounted + +const FORMAT_VERSION := 1 +const MAGIC := "TTWB" +const WORLD_FILE := "world.bin" +const MANIFEST_FILE := "manifest.json" +const THUMBNAIL_FILE := "thumbnail.png" +const JOURNAL_FILE := "journal.log" + +const DEFAULT_AUTOSAVE_GENERATIONS := 3 +const MAX_AUTOSAVE_GENERATIONS := 16 +const AUTOSAVE_SUFFIX := ".autosave." +const MIGRATION_BACKUP_SUFFIX := ".pre_migration.bak" +const SAVE_TRANSACTION_SUFFIX := ".save_transaction" +const SAVE_TRANSACTION_MARKER := "THE_TREE_FORMAL_SAVE_V1\n" + +static var _performance_budget := SimulationPerformanceBudget.new() + + +static func save_snapshot(save_path: String, snapshot: Dictionary, metadata := {}) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return _complete_timed_operation(resolved, &"save", started_at_us) + var result := _save_snapshot_absolute( + resolved[&"path"], + save_path, + snapshot, + metadata, + FORMAT_VERSION, + resolved[&"path"], + resolved[&"path"] + ) + return _complete_timed_operation(result, &"save", started_at_us) + + +static func load_snapshot(save_path: String) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return _complete_timed_operation(resolved, &"load", started_at_us) + var reconcile := _reconcile_interrupted_formal_save(resolved[&"path"]) + if not reconcile[&"ok"]: + return _complete_timed_operation(reconcile, &"load", started_at_us) + var result := _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION) + result[&"interrupted_save_reconciled"] = bool(reconcile.get(&"reconciled", false)) + return _complete_timed_operation(result, &"load", started_at_us) + + +static func get_performance_report(metric_id: StringName) -> Dictionary: + return _performance_budget.get_report(metric_id) + + +static func get_performance_reports() -> Array[Dictionary]: + return _performance_budget.get_all_reports() + + +static func clear_performance_reports() -> void: + _performance_budget.clear() + + +## Writes an autosave without touching the formal save or its .bak package. +## Generation zero is newest. Existing generations are moved transactionally; +## if candidate creation or rotation fails, the previously formal save remains +## byte-for-byte untouched. +static func save_autosave( + save_path: String, + snapshot: Dictionary, + metadata := {}, + generation_count := DEFAULT_AUTOSAVE_GENERATIONS +) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + if generation_count < 1 or generation_count > MAX_AUTOSAVE_GENERATIONS: + return _complete_timed_operation( + _failure(&"INVALID_GENERATION_COUNT", str(generation_count)), + &"save", + started_at_us + ) + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return _complete_timed_operation(resolved, &"save", started_at_us) + var target_path: String = resolved[&"path"] + var pending_path := target_path + ".autosave.pending" + var reconcile := _reconcile_autosave_rotation(target_path, pending_path, generation_count) + if not reconcile[&"ok"]: + return _complete_timed_operation(reconcile, &"save", started_at_us) + var owned_paths: Array[String] = [pending_path, pending_path + ".tmp", pending_path + ".bak"] + for generation in range(MAX_AUTOSAVE_GENERATIONS): + owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation)) + owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp") + if not _operation_paths_are_safe(owned_paths): + return _complete_timed_operation( + _failure(&"UNSAFE_PATH", "autosave path contains a symbolic link"), + &"save", + started_at_us + ) + var prune_preflight := _preflight_autosave_prune(target_path, generation_count) + if not prune_preflight[&"ok"]: + return _complete_timed_operation( + _with_context(prune_preflight, {&"formal_save_preserved": true}), + &"save", + started_at_us + ) + for artifact_path: String in [pending_path, pending_path + ".tmp", pending_path + ".bak"]: + if not _path_is_missing_or_package(artifact_path): + return _complete_timed_operation( + _failure(&"PACKAGE_PATH_CONFLICT", artifact_path), + &"save", + started_at_us + ) + _remove_tree(pending_path) + _remove_tree(pending_path + ".tmp") + _remove_tree(pending_path + ".bak") + var candidate_metadata: Dictionary = metadata.duplicate(true) if metadata is Dictionary else {} + candidate_metadata[&"save_kind"] = &"autosave" + candidate_metadata[&"autosave_generation"] = 0 + var candidate := _save_snapshot_absolute( + pending_path, + get_autosave_path(save_path, 0), + snapshot, + candidate_metadata, + FORMAT_VERSION, + target_path + AUTOSAVE_SUFFIX + "0", + target_path, + false + ) + if not candidate[&"ok"]: + _remove_tree(pending_path) + _remove_tree(pending_path + ".bak") + return _complete_timed_operation( + _with_context(candidate, {&"formal_save_preserved": true}), + &"save", + started_at_us + ) + + var rotation := _rotate_autosaves(target_path, pending_path, generation_count) + _remove_tree(pending_path + ".bak") + if not rotation[&"ok"]: + return _complete_timed_operation( + _with_context(rotation, {&"formal_save_preserved": true}), + &"save", + started_at_us + ) + var pruned_generations: Array[int] = [] + for generation_value: Variant in prune_preflight[&"generations"]: + var generation := int(generation_value) + _remove_tree(target_path + AUTOSAVE_SUFFIX + str(generation)) + pruned_generations.append(generation) + var result := { + &"ok": true, + &"code": &"OK", + &"path": save_path, + &"autosave_path": get_autosave_path(save_path, 0), + &"generation": 0, + &"generation_count": generation_count, + &"manifest": candidate[&"manifest"], + &"formal_save_preserved": true, + &"interrupted_rotation_reconciled": bool(reconcile.get(&"reconciled", false)), + &"pruned_generations": pruned_generations, + } + return _complete_timed_operation(result, &"save", started_at_us) + + +static func get_autosave_path(save_path: String, generation: int) -> String: + if generation < 0: + return "" + return save_path + AUTOSAVE_SUFFIX + str(generation) + + +## Returns checksum/schema status for every recovery candidate. Before reading, +## it completes or rolls back interrupted package renames using only verified +## packages. Selection priority is explicit and stable. +static func inspect_recovery_candidates( + save_path: String, + generation_count := DEFAULT_AUTOSAVE_GENERATIONS +) -> Dictionary: + if generation_count < 1 or generation_count > MAX_AUTOSAVE_GENERATIONS: + return _failure(&"INVALID_GENERATION_COUNT", str(generation_count)) + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return resolved + var target_path: String = resolved[&"path"] + var formal_reconcile := _reconcile_interrupted_formal_save(target_path) + if not formal_reconcile[&"ok"]: + return formal_reconcile + var migration_reconcile := _reconcile_interrupted_migration(target_path) + if not migration_reconcile[&"ok"]: + return migration_reconcile + var autosave_reconcile := _reconcile_autosave_rotation( + target_path, + target_path + ".autosave.pending", + generation_count + ) + if not autosave_reconcile[&"ok"]: + return autosave_reconcile + var descriptors: Array[Dictionary] = [ + {&"id": &"formal", &"kind": &"formal", &"generation": -1, &"path": target_path}, + {&"id": &"backup", &"kind": &"backup", &"generation": -1, &"path": target_path + ".bak"}, + {&"id": &"migration_original", &"kind": &"migration_original", &"generation": -1, &"path": target_path + ".migration.original"}, + {&"id": &"pre_migration_backup", &"kind": &"migration_backup", &"generation": -1, &"path": target_path + MIGRATION_BACKUP_SUFFIX}, + ] + for generation in range(generation_count): + descriptors.append({ + &"id": StringName("autosave_%d" % generation), + &"kind": &"autosave", + &"generation": generation, + &"path": target_path + AUTOSAVE_SUFFIX + str(generation), + }) + + var candidates: Array[Dictionary] = [] + var selected: Dictionary = {} + var selected_snapshot: Dictionary = {} + for priority in range(descriptors.size()): + var descriptor: Dictionary = descriptors[priority] + var loaded := _load_snapshot_absolute(descriptor[&"path"], -1, false) + var package_valid := bool(loaded[&"ok"]) + var current_schema_valid := false + var current_format := false + if package_valid: + current_format = int(loaded[&"manifest"].get("format_version", -1)) == FORMAT_VERSION + current_schema_valid = bool(SimulationSnapshot.validate(loaded[&"snapshot"])[&"ok"]) + var loadable := package_valid and current_format and current_schema_valid + var candidate_code: StringName = StringName(loaded[&"code"]) + if package_valid and not current_format: + candidate_code = &"UNSUPPORTED_FORMAT" + elif package_valid and not current_schema_valid: + candidate_code = &"INVALID_SNAPSHOT" + var candidate_info := { + &"id": descriptor[&"id"], + &"kind": descriptor[&"kind"], + &"generation": descriptor[&"generation"], + &"priority": priority, + &"path": descriptor[&"path"], + &"valid": loadable, + &"package_valid": package_valid, + &"requires_migration": package_valid and not loadable, + &"code": candidate_code, + &"message": str(loaded.get(&"message", "")), + } + if package_valid: + candidate_info[&"manifest"] = loaded[&"manifest"].duplicate(true) + if loadable and selected.is_empty(): + selected = candidate_info.duplicate(true) + selected_snapshot = loaded[&"snapshot"].duplicate(true) + candidates.append(candidate_info) + if selected.is_empty(): + return { + &"ok": false, + &"code": &"NO_VALID_RECOVERY_CANDIDATE", + &"message": save_path, + &"selection_rule": &"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST", + &"candidates": candidates, + &"reconciliation": { + &"formal_save": formal_reconcile, + &"migration": migration_reconcile, + &"autosave": autosave_reconcile, + }, + } + return { + &"ok": true, + &"code": &"OK", + &"selection_rule": &"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST", + &"selected": selected, + &"snapshot": selected_snapshot, + &"manifest": selected[&"manifest"].duplicate(true), + &"candidates": candidates, + &"reconciliation": { + &"formal_save": formal_reconcile, + &"migration": migration_reconcile, + &"autosave": autosave_reconcile, + }, + &"recovery_report": create_recovery_report(selected_snapshot, { + &"candidate_id": selected[&"id"], + &"candidate_path": selected[&"path"], + }), + } + + +static func list_recovery_candidates( + save_path: String, + generation_count := DEFAULT_AUTOSAVE_GENERATIONS +) -> Dictionary: + return inspect_recovery_candidates(save_path, generation_count) + + +static func load_with_recovery( + save_path: String, + generation_count := DEFAULT_AUTOSAVE_GENERATIONS +) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var result := inspect_recovery_candidates(save_path, generation_count) + return _complete_timed_operation(result, &"load", started_at_us) + + +## Loads one explicitly named recovery package. This is read-only: it never +## promotes the selected package and never repairs the snapshot. +static func load_recovery_candidate( + save_path: String, + candidate_id: StringName, + generation := -1 +) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var result := _load_recovery_candidate_impl(save_path, candidate_id, generation) + return _complete_timed_operation(result, &"load", started_at_us) + + +static func _load_recovery_candidate_impl( + save_path: String, + candidate_id: StringName, + generation: int +) -> Dictionary: + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return resolved + var target_path: String = resolved[&"path"] + var formal_reconcile := _reconcile_interrupted_formal_save(target_path) + if not formal_reconcile[&"ok"]: + return formal_reconcile + var candidate_path := "" + match candidate_id: + &"formal": + candidate_path = target_path + &"backup": + candidate_path = target_path + ".bak" + &"migration_original": + candidate_path = target_path + ".migration.original" + &"pre_migration_backup": + candidate_path = target_path + MIGRATION_BACKUP_SUFFIX + &"autosave": + if generation < 0 or generation >= MAX_AUTOSAVE_GENERATIONS: + return _failure(&"INVALID_AUTOSAVE_GENERATION", str(generation)) + candidate_path = target_path + AUTOSAVE_SUFFIX + str(generation) + _: + var candidate_text := str(candidate_id) + if candidate_text.begins_with("autosave_") and candidate_text.trim_prefix("autosave_").is_valid_int(): + generation = candidate_text.trim_prefix("autosave_").to_int() + if generation < 0 or generation >= MAX_AUTOSAVE_GENERATIONS: + return _failure(&"INVALID_AUTOSAVE_GENERATION", str(generation)) + candidate_path = target_path + AUTOSAVE_SUFFIX + str(generation) + else: + return _failure(&"UNKNOWN_RECOVERY_CANDIDATE", str(candidate_id)) + var result := _load_snapshot_absolute(candidate_path, FORMAT_VERSION) + if not result[&"ok"]: + return result + result[&"candidate_id"] = candidate_id + result[&"candidate_path"] = candidate_path + result[&"read_only"] = true + result[&"recovery_report"] = create_recovery_report(result[&"snapshot"], { + &"candidate_id": candidate_id, + &"candidate_path": candidate_path, + }) + return result + + +## Performs a continuous, explicit format migration and preserves an exact +## pre-migration package at .pre_migration.bak before promotion. The +## formal package is replaced only after both the migrated candidate and backup +## have passed checksum verification. +static func migrate_save( + save_path: String, + registry: SaveMigrationRegistry, + target_version := FORMAT_VERSION +) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var result := _migrate_save_impl(save_path, registry, target_version) + return _complete_timed_operation(result, &"save", started_at_us) + + +static func _migrate_save_impl( + save_path: String, + registry: SaveMigrationRegistry, + target_version: int +) -> Dictionary: + if registry == null: + return _failure(&"MIGRATION_REGISTRY_REQUIRED", save_path) + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return resolved + var target_path: String = resolved[&"path"] + var formal_reconcile := _reconcile_interrupted_formal_save(target_path) + if not formal_reconcile[&"ok"]: + return formal_reconcile + var reconcile := _reconcile_interrupted_migration(target_path) + if not reconcile[&"ok"]: + return reconcile + var operation_paths: Array[String] = [ + target_path, + target_path + ".migration.tmp", + target_path + ".migration.original", + target_path + MIGRATION_BACKUP_SUFFIX, + target_path + MIGRATION_BACKUP_SUFFIX + ".tmp", + target_path + MIGRATION_BACKUP_SUFFIX + ".old", + ] + if not _operation_paths_are_safe(operation_paths): + return _failure(&"UNSAFE_PATH", "migration path contains a symbolic link") + for artifact_path: String in [ + target_path + ".migration.tmp", + target_path + ".migration.original", + target_path + MIGRATION_BACKUP_SUFFIX + ".tmp", + target_path + MIGRATION_BACKUP_SUFFIX + ".old", + ]: + if not _path_is_missing_or_package(artifact_path): + return _failure(&"PACKAGE_PATH_CONFLICT", artifact_path) + # Old format snapshots are intentionally decoded and checksum-verified before + # migration, but validated only after the final adjacent migration step. + var source := _load_snapshot_absolute(target_path, -1, false) + if not source[&"ok"]: + return _with_context(source, {&"migration_started": false}) + var from_version := int(source[&"manifest"].get("format_version", -1)) + if target_version < from_version: + return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [from_version, target_version]) + if target_version == from_version: + var current_validation := SimulationSnapshot.validate(source[&"snapshot"]) + if target_version == FORMAT_VERSION and not current_validation[&"ok"]: + return _failure(&"INVALID_SNAPSHOT", ", ".join(current_validation[&"errors"])) + return { + &"ok": true, + &"code": &"ALREADY_CURRENT", + &"path": save_path, + &"from_version": from_version, + &"target_version": target_version, + &"applied_migration_ids": [], + &"backup_created": false, + } + var payload := { + &"manifest": source[&"manifest"].duplicate(true), + &"snapshot": source[&"snapshot"].duplicate(true), + } + var migrated := registry.migrate(payload, from_version, target_version) + if not migrated[&"ok"]: + return _with_context(migrated, { + &"path": save_path, + &"from_version": from_version, + &"target_version": target_version, + &"migration_started": false, + }) + var migrated_payload: Dictionary = migrated[&"data"] + if not migrated_payload.get(&"manifest", null) is Dictionary: + return _failure(&"MIGRATION_INVALID_MANIFEST", save_path) + if not migrated_payload.get(&"snapshot", null) is Dictionary: + return _failure(&"MIGRATION_INVALID_SNAPSHOT", save_path) + var migrated_snapshot: Dictionary = migrated_payload[&"snapshot"] + var validation := SimulationSnapshot.validate(migrated_snapshot) + if not validation[&"ok"]: + return _failure(&"MIGRATION_INVALID_SNAPSHOT", ", ".join(validation[&"errors"])) + var migrated_manifest: Dictionary = migrated_payload[&"manifest"].duplicate(true) + migrated_manifest[&"format_version"] = target_version + + var candidate_path := target_path + ".migration.tmp" + var backup_path := target_path + MIGRATION_BACKUP_SUFFIX + var backup_temp_path := backup_path + ".tmp" + var backup_old_path := backup_path + ".old" + var original_hold_path := target_path + ".migration.original" + _remove_tree(candidate_path) + _remove_tree(backup_temp_path) + var candidate_write := _write_package_directory( + candidate_path, + migrated_snapshot, + migrated_manifest, + target_version, + target_path + ) + if not candidate_write[&"ok"]: + _remove_tree(candidate_path) + return _with_context(candidate_write, {&"migration_started": false}) + var candidate_verify := _load_snapshot_absolute(candidate_path, target_version) + if not candidate_verify[&"ok"]: + _remove_tree(candidate_path) + return _failure(&"MIGRATION_VERIFY_FAILED", str(candidate_verify.get(&"message", candidate_verify[&"code"]))) + + var copy_error := _copy_tree(target_path, backup_temp_path) + if copy_error != OK: + _remove_tree(candidate_path) + _remove_tree(backup_temp_path) + return _failure(&"MIGRATION_BACKUP_COPY_FAILED", error_string(copy_error)) + var backup_verify := _load_snapshot_absolute(backup_temp_path, from_version, false) + if not backup_verify[&"ok"]: + _remove_tree(candidate_path) + _remove_tree(backup_temp_path) + return _failure(&"MIGRATION_BACKUP_VERIFY_FAILED", str(backup_verify.get(&"message", backup_verify[&"code"]))) + + if DirAccess.dir_exists_absolute(backup_path): + var old_backup_error := DirAccess.rename_absolute(backup_path, backup_old_path) + if old_backup_error != OK: + _remove_tree(candidate_path) + _remove_tree(backup_temp_path) + return _failure(&"MIGRATION_BACKUP_ROTATE_FAILED", error_string(old_backup_error)) + var backup_promote_error := DirAccess.rename_absolute(backup_temp_path, backup_path) + if backup_promote_error != OK: + if DirAccess.dir_exists_absolute(backup_old_path): + DirAccess.rename_absolute(backup_old_path, backup_path) + _remove_tree(candidate_path) + _remove_tree(backup_temp_path) + return _failure(&"MIGRATION_BACKUP_PROMOTE_FAILED", error_string(backup_promote_error)) + + var hold_error := DirAccess.rename_absolute(target_path, original_hold_path) + if hold_error != OK: + _restore_previous_migration_backup(backup_path, backup_old_path) + _remove_tree(candidate_path) + return _failure(&"MIGRATION_HOLD_FAILED", error_string(hold_error)) + var promote_error := DirAccess.rename_absolute(candidate_path, target_path) + if promote_error != OK: + DirAccess.rename_absolute(original_hold_path, target_path) + _restore_previous_migration_backup(backup_path, backup_old_path) + _remove_tree(candidate_path) + return _failure(&"MIGRATION_PROMOTE_FAILED", error_string(promote_error)) + var promoted_verify := _load_snapshot_absolute(target_path, target_version) + if not promoted_verify[&"ok"]: + _remove_tree(target_path) + DirAccess.rename_absolute(original_hold_path, target_path) + _restore_previous_migration_backup(backup_path, backup_old_path) + return _failure( + &"MIGRATION_PROMOTED_VERIFY_FAILED", + str(promoted_verify.get(&"message", promoted_verify[&"code"])) + ) + _remove_tree(original_hold_path) + _remove_tree(backup_old_path) + return { + &"ok": true, + &"code": &"OK", + &"path": save_path, + &"backup_path": save_path + MIGRATION_BACKUP_SUFFIX, + &"from_version": from_version, + &"target_version": target_version, + &"applied_migration_ids": migrated[&"applied_migration_ids"].duplicate(), + &"backup_created": true, + &"manifest": candidate_verify[&"manifest"].duplicate(true), + } + + +## Boundary for post-load diagnostics. It intentionally does not inspect or +## mutate domain ownership such as Task reservations or Event actor locks; those +## authoritative services can append findings and decide repairs transactionally. +static func create_recovery_report(snapshot: Dictionary, source := {}) -> Dictionary: + var validation := SimulationSnapshot.validate(snapshot) + return { + &"report_version": 1, + &"snapshot_schema_valid": bool(validation[&"ok"]), + &"snapshot_errors": validation[&"errors"].duplicate(), + &"source": source.duplicate(true) if source is Dictionary else {}, + &"automatic_repairs_applied": false, + &"repair_policy": &"REPORT_ONLY_AUTHORITATIVE_SERVICE_REQUIRED", + &"findings": { + &"orphan_tasks": [], + &"orphan_event_locks": [], + &"orphan_inventory_reservations": [], + }, + } + + +static func delete_save(save_path: String) -> bool: + var resolved := _resolve_safe_save_path(save_path) + if not resolved[&"ok"]: + return false + var target_path: String = resolved[&"path"] + if DirAccess.dir_exists_absolute(target_path) and not _looks_like_save_package(target_path): + # A save slot is a package directory, never an arbitrary parent directory. + return false + var owned_paths: Array[String] = [ + target_path, + target_path + ".tmp", + target_path + ".bak", + target_path + ".autosave.pending", + target_path + ".autosave.pending.tmp", + target_path + ".autosave.pending.bak", + target_path + MIGRATION_BACKUP_SUFFIX, + target_path + MIGRATION_BACKUP_SUFFIX + ".tmp", + target_path + MIGRATION_BACKUP_SUFFIX + ".old", + target_path + ".migration.tmp", + target_path + ".migration.original", + target_path + SAVE_TRANSACTION_SUFFIX, + ] + for generation in range(MAX_AUTOSAVE_GENERATIONS): + owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation)) + owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp") + if not _operation_paths_are_safe(owned_paths): + return false + var transaction_marker_path := target_path + SAVE_TRANSACTION_SUFFIX + if (FileAccess.file_exists(transaction_marker_path) or DirAccess.dir_exists_absolute(transaction_marker_path)) \ + and not _formal_transaction_marker_is_valid(transaction_marker_path): + return false + for path: String in owned_paths: + if not FileAccess.file_exists(path) and not DirAccess.dir_exists_absolute(path): + continue + if path == target_path + SAVE_TRANSACTION_SUFFIX: + if _formal_transaction_marker_is_valid(path): + DirAccess.remove_absolute(path) + elif _looks_like_save_package(path): + _remove_tree(path) + return true + + +static func _save_snapshot_absolute( + target_path: String, + reported_path: String, + snapshot: Dictionary, + metadata: Variant, + format_version: int, + logical_target_path := "", + identity_source_path := "", + use_transaction_marker := true +) -> Dictionary: + var validation := SimulationSnapshot.validate(snapshot) + if not validation[&"ok"]: + return _failure(&"INVALID_SNAPSHOT", ", ".join(validation[&"errors"])) + var temporary_path := target_path + ".tmp" + var backup_path := target_path + ".bak" + var transaction_marker_path := target_path + SAVE_TRANSACTION_SUFFIX + if not _operation_paths_are_safe([target_path, temporary_path, backup_path, transaction_marker_path]): + return _failure(&"UNSAFE_PATH", "save path contains a symbolic link") + var interrupted_reconcile := {&"ok": true, &"reconciled": false, &"action": &"none"} + if use_transaction_marker: + interrupted_reconcile = _reconcile_interrupted_formal_save(target_path) + if not interrupted_reconcile[&"ok"]: + return interrupted_reconcile + if not _path_is_missing_or_package(target_path): + return _failure(&"NOT_A_SAVE_PACKAGE", target_path) + if not _path_is_missing_or_package(temporary_path): + return _failure(&"PACKAGE_PATH_CONFLICT", temporary_path) + if not _path_is_missing_or_package(backup_path): + return _failure(&"BACKUP_PATH_CONFLICT", backup_path) + var logical_path: String = logical_target_path if not logical_target_path.is_empty() else target_path + var identity_path: String = identity_source_path if not identity_source_path.is_empty() else target_path + var effective_metadata := _merge_save_metadata( + metadata if metadata is Dictionary else {}, + _read_manifest_identity(identity_path), + logical_path + ) + if not effective_metadata.has(&"thumbnail_png"): + var previous_thumbnail_path := identity_path.path_join(THUMBNAIL_FILE) + if FileAccess.file_exists(previous_thumbnail_path): + effective_metadata[&"_thumbnail_png"] = FileAccess.get_file_as_bytes(previous_thumbnail_path) + _remove_tree(temporary_path) + var write_result := _write_package_directory( + temporary_path, + snapshot, + effective_metadata, + format_version, + logical_path + ) + if not write_result[&"ok"]: + _remove_tree(temporary_path) + return write_result + var verification := _load_snapshot_absolute(temporary_path, format_version) + if not verification[&"ok"]: + _remove_tree(temporary_path) + return _failure(&"VERIFY_FAILED", str(verification.get(&"message", "unknown"))) + if use_transaction_marker: + var marker_error := _write_formal_transaction_marker(transaction_marker_path) + if marker_error != OK: + _remove_tree(temporary_path) + return _failure(&"TRANSACTION_MARKER_WRITE_FAILED", error_string(marker_error)) + + _remove_tree(backup_path) + if DirAccess.dir_exists_absolute(target_path): + var backup_error := DirAccess.rename_absolute(target_path, backup_path) + if backup_error != OK: + _remove_tree(temporary_path) + _remove_formal_transaction_marker(transaction_marker_path) + return _failure(&"BACKUP_FAILED", error_string(backup_error)) + var promote_error := DirAccess.rename_absolute(temporary_path, target_path) + if promote_error != OK: + var rollback_verified := false + if DirAccess.dir_exists_absolute(backup_path): + var rollback_error := DirAccess.rename_absolute(backup_path, target_path) + rollback_verified = rollback_error == OK and bool(_package_status(target_path)[&"current_loadable"]) + _remove_tree(temporary_path) + if rollback_verified: + _remove_formal_transaction_marker(transaction_marker_path) + return _failure(&"PROMOTE_FAILED", error_string(promote_error)) + var promoted_status := _package_status(target_path) + if not promoted_status[&"current_loadable"]: + if _looks_like_save_package(target_path): + _remove_tree(target_path) + var rollback_verified := false + if DirAccess.dir_exists_absolute(backup_path): + var rollback_error := DirAccess.rename_absolute(backup_path, target_path) + rollback_verified = rollback_error == OK and bool(_package_status(target_path)[&"current_loadable"]) + if rollback_verified: + _remove_formal_transaction_marker(transaction_marker_path) + return _failure(&"PROMOTE_VERIFY_FAILED", str(promoted_status.get(&"code", &"INVALID_PACKAGE"))) + if use_transaction_marker: + _remove_formal_transaction_marker(transaction_marker_path) + return { + &"ok": true, + &"code": &"OK", + &"path": reported_path, + &"manifest": verification[&"manifest"].duplicate(true), + &"interrupted_save_reconciled": bool(interrupted_reconcile.get(&"reconciled", false)), + } + + +static func _write_package_directory( + package_path: String, + snapshot: Dictionary, + metadata: Dictionary, + format_version: int, + logical_target_path := "" +) -> Dictionary: + if not _operation_paths_are_safe([package_path]): + return _failure(&"UNSAFE_PATH", "package path contains a symbolic link") + if not _path_is_missing_or_package(package_path): + return _failure(&"PACKAGE_PATH_CONFLICT", package_path) + _remove_tree(package_path) + var make_error := DirAccess.make_dir_recursive_absolute(package_path) + if make_error != OK: + return _failure(&"CREATE_DIRECTORY_FAILED", error_string(make_error)) + var raw_world := var_to_bytes(snapshot) + var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD) + if compressed_world.is_empty() and not raw_world.is_empty(): + _remove_tree(package_path) + return _failure(&"COMPRESSION_FAILED", WORLD_FILE) + var checksum := _sha256(compressed_world) + var world_error := _write_world( + package_path.path_join(WORLD_FILE), + raw_world.size(), + compressed_world, + format_version + ) + if world_error != OK: + _remove_tree(package_path) + return _failure(&"WRITE_WORLD_FAILED", error_string(world_error)) + + var logical_name := ( + logical_target_path.get_file() + if not logical_target_path.is_empty() + else package_path.get_file() + ) + var now := Time.get_datetime_string_from_system(true) + var manifest := { + &"format_version": format_version, + &"game_version": str(ProjectSettings.get_setting("application/config/version", "0.1.0")), + &"save_id": str(metadata.get(&"save_id", logical_name)), + &"display_name": str(metadata.get(&"display_name", logical_name)), + &"created_at_real": str(metadata.get(&"created_at_real", now)), + &"updated_at_real": now, + &"absolute_minute": int(snapshot[&"clock"][&"absolute_minute"]), + &"day": int(snapshot[&"clock"].get(&"day", int(snapshot[&"clock"][&"absolute_minute"]) / 1_440 + 1)), + &"seed": int(metadata.get(&"seed", _snapshot_seed(snapshot))), + &"content_pack_ids": _metadata_array(metadata.get(&"content_pack_ids", [])), + &"enabled_filter_profile": metadata.get(&"enabled_filter_profile", &"default"), + &"checksum_world": checksum, + &"uncompressed_size": raw_world.size(), + } + for key: Variant in metadata: + if key in [&"thumbnail_png", &"thumbnail_path", &"_thumbnail_png"]: + continue + if not manifest.has(key): + manifest[key] = metadata[key] + var manifest_error := _write_text(package_path.path_join(MANIFEST_FILE), JSON.stringify(manifest, "\t")) + if manifest_error != OK: + _remove_tree(package_path) + return _failure(&"WRITE_MANIFEST_FAILED", error_string(manifest_error)) + var thumbnail_error := _ensure_thumbnail(package_path, metadata) + if thumbnail_error != OK: + _remove_tree(package_path) + return _failure(&"WRITE_THUMBNAIL_FAILED", error_string(thumbnail_error)) + var journal_error := _write_text( + package_path.path_join(JOURNAL_FILE), + "save_completed %s\n" % manifest[&"updated_at_real"] + ) + if journal_error != OK: + _remove_tree(package_path) + return _failure(&"WRITE_JOURNAL_FAILED", error_string(journal_error)) + return {&"ok": true, &"code": &"OK", &"manifest": manifest} + + +static func _load_snapshot_absolute( + target_path: String, + expected_format_version: int, + validate_snapshot := true +) -> Dictionary: + var read_boundary := _validate_package_read_boundary(target_path) + if not read_boundary[&"ok"]: + return read_boundary + var manifest_path := target_path.path_join(MANIFEST_FILE) + var world_path := target_path.path_join(WORLD_FILE) + if not FileAccess.file_exists(manifest_path) or not FileAccess.file_exists(world_path): + return _failure(&"SAVE_NOT_FOUND", target_path) + # Recheck the exact entry immediately before each open. The recursive package + # scan above protects all known package entries; these checks narrow the + # remaining TOCTOU window at the two authoritative reads. + if not _package_file_is_safe_for_read(manifest_path): + return _failure(&"UNSAFE_PATH", "symbolic link at package read boundary: %s" % manifest_path) + var manifest_file := FileAccess.open(manifest_path, FileAccess.READ) + if manifest_file == null: + return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error())) + var manifest_text := manifest_file.get_as_text() + manifest_file = null + var manifest_value: Variant = JSON.parse_string(manifest_text) + if not manifest_value is Dictionary: + return _failure(&"INVALID_MANIFEST", manifest_path) + var manifest: Dictionary = manifest_value + var manifest_version := int(manifest.get("format_version", manifest.get(&"format_version", -1))) + if manifest_version < 0: + return _failure(&"INVALID_MANIFEST", "missing format_version") + if expected_format_version >= 0 and manifest_version != expected_format_version: + return _failure(&"UNSUPPORTED_FORMAT", str(manifest_version)) + + if not _package_file_is_safe_for_read(world_path): + return _failure(&"UNSAFE_PATH", "symbolic link at package read boundary: %s" % world_path) + var world_file := FileAccess.open(world_path, FileAccess.READ) + if world_file == null: + return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error())) + if world_file.get_length() < 16: + return _failure(&"INVALID_WORLD_HEADER", world_path) + var magic := world_file.get_buffer(4).get_string_from_ascii() + var stored_version := world_file.get_32() + var uncompressed_size := world_file.get_64() + var compressed_size := world_file.get_length() - world_file.get_position() + if compressed_size < 0: + return _failure(&"INVALID_WORLD_HEADER", world_path) + var compressed_world := world_file.get_buffer(compressed_size) + if magic != MAGIC or stored_version != manifest_version: + return _failure(&"INVALID_WORLD_HEADER", world_path) + var expected_checksum := str(manifest.get("checksum_world", manifest.get(&"checksum_world", ""))) + if expected_checksum.is_empty() or _sha256(compressed_world) != expected_checksum: + return _failure(&"CHECKSUM_MISMATCH", world_path) + var manifest_size := int(manifest.get("uncompressed_size", manifest.get(&"uncompressed_size", -1))) + if manifest_size >= 0 and manifest_size != uncompressed_size: + return _failure(&"SIZE_MISMATCH", world_path) + var raw_world := compressed_world.decompress(uncompressed_size, FileAccess.COMPRESSION_ZSTD) + if raw_world.size() != uncompressed_size: + return _failure(&"DECOMPRESSION_FAILED", world_path) + var snapshot_value: Variant = bytes_to_var(raw_world) + if not snapshot_value is Dictionary: + return _failure(&"INVALID_WORLD_DATA", world_path) + var snapshot: Dictionary = snapshot_value + if validate_snapshot: + var validation := SimulationSnapshot.validate(snapshot) + if not validation[&"ok"]: + return _failure(&"INVALID_SNAPSHOT", ", ".join(validation[&"errors"])) + return { + &"ok": true, + &"code": &"OK", + &"manifest": manifest, + &"snapshot": snapshot, + } + + +## Resolves a normal-save promotion interrupted after the old formal package +## moved to .bak. Only checksum/schema-verified packages may become formal. +## The marker is durable, but verified .tmp/.bak artifacts also recover saves +## created by older builds which could crash before this marker existed. +static func _reconcile_interrupted_formal_save(target_path: String) -> Dictionary: + var temporary_path := target_path + ".tmp" + var backup_path := target_path + ".bak" + var marker_path := target_path + SAVE_TRANSACTION_SUFFIX + if not _operation_paths_are_safe([target_path, temporary_path, backup_path, marker_path]): + return _failure(&"UNSAFE_PATH", "formal save recovery path contains a symbolic link") + var marker_exists := FileAccess.file_exists(marker_path) or DirAccess.dir_exists_absolute(marker_path) + if marker_exists and not _formal_transaction_marker_is_valid(marker_path): + return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path) + + var target_status := _package_status(target_path) + var temporary_status := _package_status(temporary_path) + var backup_status := _package_status(backup_path) + if target_status[&"current_loadable"]: + if not marker_exists: + return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"} + if temporary_status[&"exists"]: + if not _looks_like_save_package(temporary_path): + return _failure(&"FORMAL_RECOVERY_TEMP_CONFLICT", temporary_path) + _remove_tree(temporary_path) + _remove_formal_transaction_marker(marker_path) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"kept_verified_formal", + } + + # Without a durable marker, an invalid visible formal package remains a + # recovery candidate and may fall back read-only to .bak/autosaves. Never + # destroy it merely because it failed verification. + if target_status[&"exists"] and not marker_exists: + return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"} + if target_status[&"exists"]: + if not _looks_like_save_package(target_path): + return _failure(&"FORMAL_RECOVERY_TARGET_CONFLICT", target_path) + var replacement_path := "" + var replacement_action: StringName = &"" + if temporary_status[&"current_loadable"]: + replacement_path = temporary_path + replacement_action = &"replaced_invalid_formal_with_candidate" + elif backup_status[&"current_loadable"]: + replacement_path = backup_path + replacement_action = &"replaced_invalid_formal_with_backup" + if replacement_path.is_empty(): + return _failure(&"FORMAL_RECOVERY_NO_VALID_PACKAGE", target_path) + _remove_tree(target_path) + var replace_error := DirAccess.rename_absolute(replacement_path, target_path) + if replace_error != OK: + return _failure(&"FORMAL_RECOVERY_PROMOTE_FAILED", error_string(replace_error)) + if not bool(_package_status(target_path)[&"current_loadable"]): + return _failure(&"FORMAL_RECOVERY_VERIFY_FAILED", target_path) + if replacement_path != temporary_path and temporary_status[&"exists"] \ + and _looks_like_save_package(temporary_path): + _remove_tree(temporary_path) + _remove_formal_transaction_marker(marker_path) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": replacement_action, + } + + var source_path := "" + var action: StringName = &"" + if temporary_status[&"current_loadable"]: + source_path = temporary_path + action = &"promoted_verified_candidate" + elif backup_status[&"current_loadable"]: + source_path = backup_path + action = &"restored_verified_backup" + if source_path.is_empty(): + if marker_exists: + return _failure(&"FORMAL_RECOVERY_NO_VALID_PACKAGE", target_path) + return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"} + var promote_error := DirAccess.rename_absolute(source_path, target_path) + if promote_error != OK: + return _failure(&"FORMAL_RECOVERY_PROMOTE_FAILED", error_string(promote_error)) + if not bool(_package_status(target_path)[&"current_loadable"]): + return _failure(&"FORMAL_RECOVERY_VERIFY_FAILED", target_path) + if source_path != temporary_path and marker_exists and temporary_status[&"exists"] \ + and _looks_like_save_package(temporary_path): + _remove_tree(temporary_path) + _remove_formal_transaction_marker(marker_path) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": action, + } + + +static func _rotate_autosaves(target_path: String, pending_path: String, generation_count: int) -> Dictionary: + var reconciliation := _reconcile_autosave_rotation(target_path, pending_path, generation_count) + if not reconciliation[&"ok"]: + _remove_tree(pending_path) + return reconciliation + var moved_to_transaction: Array[int] = [] + for generation in range(generation_count): + var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation) + if not DirAccess.dir_exists_absolute(generation_path): + continue + var transaction_path := generation_path + ".rotate_tmp" + var hold_error := DirAccess.rename_absolute(generation_path, transaction_path) + if hold_error != OK: + _rollback_autosave_holds(target_path, moved_to_transaction) + _remove_tree(pending_path) + return _failure(&"AUTOSAVE_ROTATION_HOLD_FAILED", error_string(hold_error)) + moved_to_transaction.append(generation) + + var newest_path := target_path + AUTOSAVE_SUFFIX + "0" + var promote_error := DirAccess.rename_absolute(pending_path, newest_path) + if promote_error != OK: + _rollback_autosave_holds(target_path, moved_to_transaction) + _remove_tree(pending_path) + return _failure(&"AUTOSAVE_PROMOTE_FAILED", error_string(promote_error)) + + var shifted: Array[int] = [] + for generation in moved_to_transaction: + if generation >= generation_count - 1: + continue + var transaction_path := target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp" + var destination_path := target_path + AUTOSAVE_SUFFIX + str(generation + 1) + var shift_error := DirAccess.rename_absolute(transaction_path, destination_path) + if shift_error != OK: + _remove_tree(newest_path) + for shifted_generation in shifted: + var shifted_path := target_path + AUTOSAVE_SUFFIX + str(shifted_generation + 1) + var original_hold := target_path + AUTOSAVE_SUFFIX + str(shifted_generation) + ".rotate_tmp" + DirAccess.rename_absolute(shifted_path, original_hold) + _rollback_autosave_holds(target_path, moved_to_transaction) + return _failure(&"AUTOSAVE_ROTATION_SHIFT_FAILED", error_string(shift_error)) + shifted.append(generation) + + for generation in moved_to_transaction: + _remove_tree(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp") + return {&"ok": true, &"code": &"OK"} + + +## Retention shrink is allowed to delete only checksum-valid packages which +## occupy generation names owned by this slot. Preflight happens before the new +## autosave is committed so a foreign directory or link cannot cause a partial +## rotation followed by a prune failure. +static func _preflight_autosave_prune(target_path: String, generation_count: int) -> Dictionary: + var generations: Array[int] = [] + for generation in range(generation_count, MAX_AUTOSAVE_GENERATIONS): + var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation) + if not FileAccess.file_exists(generation_path) and not DirAccess.dir_exists_absolute(generation_path): + continue + var status := _package_status(generation_path) + if not status[&"package_valid"]: + return _failure(&"AUTOSAVE_PRUNE_CONFLICT", generation_path) + generations.append(generation) + return {&"ok": true, &"code": &"OK", &"generations": generations} + + +## Repairs a rotation interrupted at any rename boundary. If the pending +## candidate still exists, the old generations are rolled back. Once generation +## zero has been promoted, the remaining shifts are completed instead. +static func _reconcile_autosave_rotation( + target_path: String, + pending_path: String, + generation_count: int +) -> Dictionary: + var paths: Array[String] = [pending_path] + var held_generations: Array[int] = [] + # Scan the full supported range because the configured retention count may + # have changed between the interrupted run and recovery. + for generation in range(MAX_AUTOSAVE_GENERATIONS): + var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation) + var hold_path := generation_path + ".rotate_tmp" + paths.append(generation_path) + paths.append(hold_path) + if DirAccess.dir_exists_absolute(hold_path): + held_generations.append(generation) + if not _operation_paths_are_safe(paths): + return _failure(&"UNSAFE_PATH", "autosave recovery path contains a symbolic link") + if held_generations.is_empty(): + return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"} + + var pending_exists := DirAccess.dir_exists_absolute(pending_path) + var newest_exists := DirAccess.dir_exists_absolute(target_path + AUTOSAVE_SUFFIX + "0") + if pending_exists or not newest_exists: + # A hold and its original slot may never coexist in a valid rollback state. + for generation in held_generations: + if DirAccess.dir_exists_absolute(target_path + AUTOSAVE_SUFFIX + str(generation)): + return _failure( + &"AUTOSAVE_ROTATION_AMBIGUOUS", + "generation %d has both live and held packages" % generation + ) + _rollback_autosave_holds(target_path, held_generations) + for generation in held_generations: + if DirAccess.dir_exists_absolute(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp"): + return _failure( + &"AUTOSAVE_RECONCILE_ROLLBACK_FAILED", + "generation %d remained held" % generation + ) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"rolled_back", + } + + # The new generation zero is already visible: complete every remaining + # oldest-to-newest shift. Preflight conflicts before making any rename. + for generation in held_generations: + if generation >= generation_count - 1: + continue + var destination_path := target_path + AUTOSAVE_SUFFIX + str(generation + 1) + if DirAccess.dir_exists_absolute(destination_path): + return _failure( + &"AUTOSAVE_ROTATION_AMBIGUOUS", + "held generation %d conflicts with destination" % generation + ) + for generation in held_generations: + var hold_path := target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp" + if generation >= generation_count - 1: + continue + var destination_path := target_path + AUTOSAVE_SUFFIX + str(generation + 1) + var shift_error := DirAccess.rename_absolute(hold_path, destination_path) + if shift_error != OK: + return _failure(&"AUTOSAVE_RECONCILE_SHIFT_FAILED", error_string(shift_error)) + for generation in held_generations: + _remove_tree(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp") + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"completed", + } + + +static func _rollback_autosave_holds(target_path: String, generations: Array[int]) -> void: + for generation in generations: + var transaction_path := target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp" + var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation) + if DirAccess.dir_exists_absolute(transaction_path) and not DirAccess.dir_exists_absolute(generation_path): + DirAccess.rename_absolute(transaction_path, generation_path) + + +## Resolves the two durable migration markers. The source hold is never removed +## until a current target and a checksum-valid pre-migration backup both exist. +static func _reconcile_interrupted_migration(target_path: String) -> Dictionary: + var candidate_path := target_path + ".migration.tmp" + var original_path := target_path + ".migration.original" + var backup_path := target_path + MIGRATION_BACKUP_SUFFIX + var backup_temp_path := backup_path + ".tmp" + var backup_old_path := backup_path + ".old" + var operation_paths: Array[String] = [ + target_path, + candidate_path, + original_path, + backup_path, + backup_temp_path, + backup_old_path, + ] + if not _operation_paths_are_safe(operation_paths): + return _failure(&"UNSAFE_PATH", "migration recovery path contains a symbolic link") + + var original_status := _package_status(original_path) + var target_status := _package_status(target_path) + var candidate_status := _package_status(candidate_path) + var backup_status := _package_status(backup_path) + var backup_temp_status := _package_status(backup_temp_path) + var backup_old_status := _package_status(backup_old_path) + + # Recover a backup rename that was interrupted before the formal source moved. + if not backup_status[&"exists"]: + if backup_temp_status[&"package_valid"]: + var temp_promote_error := DirAccess.rename_absolute(backup_temp_path, backup_path) + if temp_promote_error != OK: + return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(temp_promote_error)) + backup_status = _package_status(backup_path) + elif backup_old_status[&"package_valid"]: + var old_restore_error := DirAccess.rename_absolute(backup_old_path, backup_path) + if old_restore_error != OK: + return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(old_restore_error)) + backup_status = _package_status(backup_path) + elif backup_status[&"package_valid"]: + if backup_temp_status[&"package_valid"]: + _remove_tree(backup_temp_path) + if backup_old_status[&"package_valid"] and not original_status[&"exists"]: + _remove_tree(backup_old_path) + else: + # A torn backup package never outranks either verified durable neighbor. + # It is removed only when it still has recognizable package contents. + if not _looks_like_save_package(backup_path): + return _failure(&"MIGRATION_BACKUP_PATH_CONFLICT", backup_path) + if backup_temp_status[&"package_valid"]: + _remove_tree(backup_path) + var temp_replace_error := DirAccess.rename_absolute(backup_temp_path, backup_path) + if temp_replace_error != OK: + return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(temp_replace_error)) + backup_status = _package_status(backup_path) + elif backup_old_status[&"package_valid"]: + _remove_tree(backup_path) + var old_replace_error := DirAccess.rename_absolute(backup_old_path, backup_path) + if old_replace_error != OK: + return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(old_replace_error)) + backup_status = _package_status(backup_path) + + if original_status[&"exists"]: + if not original_status[&"package_valid"]: + return _failure( + &"MIGRATION_RECOVERY_SOURCE_INVALID", + str(original_status.get(&"code", &"INVALID_PACKAGE")) + ) + if target_status[&"current_loadable"]: + if not backup_status[&"package_valid"]: + var backup_result := _create_verified_migration_backup( + original_path, + backup_path, + backup_temp_path, + backup_old_path + ) + if not backup_result[&"ok"]: + return backup_result + _remove_tree(original_path) + if candidate_status[&"package_valid"]: + _remove_tree(candidate_path) + if DirAccess.dir_exists_absolute(backup_old_path): + _remove_tree(backup_old_path) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"finalized_promoted_target", + } + + if not target_status[&"exists"] and candidate_status[&"current_loadable"] \ + and backup_status[&"package_valid"]: + var candidate_promote_error := DirAccess.rename_absolute(candidate_path, target_path) + if candidate_promote_error != OK: + return _failure(&"MIGRATION_RECOVERY_PROMOTE_FAILED", error_string(candidate_promote_error)) + var promoted_status := _package_status(target_path) + if not promoted_status[&"current_loadable"]: + _remove_tree(target_path) + DirAccess.rename_absolute(original_path, target_path) + return _failure(&"MIGRATION_RECOVERY_VERIFY_FAILED", target_path) + _remove_tree(original_path) + if DirAccess.dir_exists_absolute(backup_old_path): + _remove_tree(backup_old_path) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"completed_promotion", + } + + # The candidate cannot be safely committed. Restore the verified original. + if target_status[&"exists"]: + if not _looks_like_save_package(target_path): + return _failure(&"MIGRATION_RECOVERY_TARGET_CONFLICT", target_path) + _remove_tree(target_path) + var rollback_error := DirAccess.rename_absolute(original_path, target_path) + if rollback_error != OK: + return _failure(&"MIGRATION_RECOVERY_ROLLBACK_FAILED", error_string(rollback_error)) + if candidate_status[&"package_valid"]: + _remove_tree(candidate_path) + if backup_old_status[&"package_valid"]: + _restore_previous_migration_backup(backup_path, backup_old_path) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"rolled_back_to_original", + } + + # A current formal package makes a pre-hold candidate disposable. If the + # formal package vanished, a verified candidate plus backup can still commit. + if target_status[&"current_loadable"]: + if candidate_status[&"package_valid"]: + _remove_tree(candidate_path) + return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"} + if not target_status[&"exists"] and candidate_status[&"current_loadable"] \ + and backup_status[&"package_valid"]: + var orphan_promote_error := DirAccess.rename_absolute(candidate_path, target_path) + if orphan_promote_error != OK: + return _failure(&"MIGRATION_RECOVERY_PROMOTE_FAILED", error_string(orphan_promote_error)) + return { + &"ok": true, + &"code": &"OK", + &"reconciled": true, + &"action": &"promoted_verified_candidate", + } + return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"} + + +static func _create_verified_migration_backup( + source_path: String, + backup_path: String, + backup_temp_path: String, + backup_old_path: String +) -> Dictionary: + _remove_tree(backup_temp_path) + var copy_error := _copy_tree(source_path, backup_temp_path) + if copy_error != OK: + return _failure(&"MIGRATION_BACKUP_COPY_FAILED", error_string(copy_error)) + var verification := _load_snapshot_absolute(backup_temp_path, -1, false) + if not verification[&"ok"]: + _remove_tree(backup_temp_path) + return _failure(&"MIGRATION_BACKUP_VERIFY_FAILED", str(verification[&"code"])) + if DirAccess.dir_exists_absolute(backup_path): + if DirAccess.dir_exists_absolute(backup_old_path): + return _failure(&"MIGRATION_BACKUP_RECOVERY_AMBIGUOUS", backup_old_path) + var rotate_error := DirAccess.rename_absolute(backup_path, backup_old_path) + if rotate_error != OK: + return _failure(&"MIGRATION_BACKUP_ROTATE_FAILED", error_string(rotate_error)) + var promote_error := DirAccess.rename_absolute(backup_temp_path, backup_path) + if promote_error != OK: + if DirAccess.dir_exists_absolute(backup_old_path): + DirAccess.rename_absolute(backup_old_path, backup_path) + return _failure(&"MIGRATION_BACKUP_PROMOTE_FAILED", error_string(promote_error)) + return {&"ok": true, &"code": &"OK"} + + +static func _package_status(path: String) -> Dictionary: + var exists := DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path) + if not exists: + return { + &"exists": false, + &"package_valid": false, + &"current_loadable": false, + &"code": &"SAVE_NOT_FOUND", + } + var loaded := _load_snapshot_absolute(path, -1, false) + if not loaded[&"ok"]: + return { + &"exists": true, + &"package_valid": false, + &"current_loadable": false, + &"code": loaded[&"code"], + } + var current_format := int(loaded[&"manifest"].get("format_version", -1)) == FORMAT_VERSION + var schema_valid := bool(SimulationSnapshot.validate(loaded[&"snapshot"])[&"ok"]) + return { + &"exists": true, + &"package_valid": true, + &"current_loadable": current_format and schema_valid, + &"code": &"OK" if current_format and schema_valid else &"REQUIRES_MIGRATION", + &"format_version": int(loaded[&"manifest"].get("format_version", -1)), + } + + +static func _restore_previous_migration_backup(backup_path: String, old_backup_path: String) -> void: + _remove_tree(backup_path) + if DirAccess.dir_exists_absolute(old_backup_path): + DirAccess.rename_absolute(old_backup_path, backup_path) + + +static func _copy_tree(source_path: String, destination_path: String) -> Error: + if not DirAccess.dir_exists_absolute(source_path): + return ERR_DOES_NOT_EXIST + if not _operation_paths_are_safe([source_path, destination_path]): + return ERR_UNAUTHORIZED + _remove_tree(destination_path) + var make_error := DirAccess.make_dir_recursive_absolute(destination_path) + if make_error != OK: + return make_error + var source_directory := DirAccess.open(source_path) + if source_directory == null: + return DirAccess.get_open_error() + source_directory.list_dir_begin() + var name := source_directory.get_next() + while not name.is_empty(): + if name != "." and name != "..": + var source_child := source_path.path_join(name) + var destination_child := destination_path.path_join(name) + var copy_error := OK + if source_directory.current_is_dir(): + copy_error = _copy_tree(source_child, destination_child) + else: + copy_error = DirAccess.copy_absolute(source_child, destination_child) + if copy_error != OK: + source_directory.list_dir_end() + _remove_tree(destination_path) + return copy_error + name = source_directory.get_next() + source_directory.list_dir_end() + return OK + + +static func _resolve_safe_save_path(save_path: String) -> Dictionary: + if save_path.is_empty(): + return _failure(&"INVALID_PATH", save_path) + var user_root := ProjectSettings.globalize_path("user://").simplify_path() + var target_path := "" + if save_path.begins_with("user://"): + var relative_path := save_path.trim_prefix("user://") + if relative_path.is_empty() or relative_path.is_absolute_path(): + return _failure(&"INVALID_PATH", save_path) + var segments := relative_path.replace("\\", "/").split("/", false) + for segment in segments: + if segment == "." or segment == ".." or segment.is_empty(): + return _failure(&"UNSAFE_PATH", save_path) + target_path = ProjectSettings.globalize_path(save_path).simplify_path() + elif save_path.is_absolute_path(): + target_path = save_path.simplify_path() + else: + return _failure(&"INVALID_PATH", save_path) + if target_path == user_root or not target_path.begins_with(user_root + "/"): + return _failure(&"UNSAFE_PATH", save_path) + if _path_has_symbolic_link_component(target_path, user_root): + return _failure(&"UNSAFE_PATH", "symbolic link component: %s" % save_path) + return {&"ok": true, &"code": &"OK", &"path": target_path} + + +static func _write_world( + path: String, + uncompressed_size: int, + compressed_world: PackedByteArray, + format_version: int +) -> Error: + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_buffer(MAGIC.to_ascii_buffer()) + file.store_32(format_version) + file.store_64(uncompressed_size) + file.store_buffer(compressed_world) + return OK + + +static func _write_text(path: String, content: String) -> Error: + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_string(content) + return OK + + +static func _write_formal_transaction_marker(path: String) -> Error: + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_string(SAVE_TRANSACTION_MARKER) + file.flush() + return OK + + +static func _sha256(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + var start_error := context.start(HashingContext.HASH_SHA256) + if start_error != OK: + return "" + context.update(bytes) + return context.finish().hex_encode() + + +static func _complete_timed_operation( + result: Dictionary, + metric_id: StringName, + started_at_us: int +) -> Dictionary: + var duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0) + _performance_budget.record_sample(metric_id, duration_us) + var timed := result.duplicate(true) + timed[&"duration_us"] = duration_us + return timed + + +static func _read_manifest_identity(package_path: String) -> Dictionary: + var manifest_path := package_path.path_join(MANIFEST_FILE) + if not FileAccess.file_exists(manifest_path): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path)) + if not parsed is Dictionary: + return {} + var manifest: Dictionary = parsed + var identity := {} + for key: StringName in [&"save_id", &"display_name", &"created_at_real"]: + if manifest.has(key) or manifest.has(String(key)): + identity[key] = manifest.get(key, manifest.get(String(key))) + return identity + + +static func _merge_save_metadata( + metadata: Dictionary, + prior_identity: Dictionary, + logical_target_path: String +) -> Dictionary: + var merged := prior_identity.duplicate(true) + for key: Variant in metadata: + merged[key] = metadata[key] + var logical_name := logical_target_path.get_file() + if str(merged.get(&"save_id", "")).is_empty(): + merged[&"save_id"] = logical_name + if str(merged.get(&"display_name", "")).is_empty(): + merged[&"display_name"] = logical_name + return merged + + +static func _metadata_array(value: Variant) -> Array: + if value is Array: + return value.duplicate(true) + if value is PackedStringArray: + var unpacked: Array = [] + for item: String in value: + unpacked.append(item) + return unpacked + return [] + + +static func _snapshot_seed(snapshot: Dictionary) -> int: + var systems_value: Variant = snapshot.get(&"systems", {}) + if not systems_value is Dictionary: + return 0 + var systems: Dictionary = systems_value + var runtime_value: Variant = systems.get(&"runtime", {}) + if runtime_value is Dictionary: + return int(runtime_value.get(&"world_seed", 0)) + return int(systems.get(&"world_seed", 0)) + + +static func _ensure_thumbnail(package_path: String, metadata: Dictionary) -> Error: + var thumbnail_path := package_path.path_join(THUMBNAIL_FILE) + var supplied_bytes := PackedByteArray() + for key: StringName in [&"thumbnail_png", &"_thumbnail_png"]: + var value: Variant = metadata.get(key, null) + if value is PackedByteArray and not value.is_empty(): + supplied_bytes = value.duplicate() + break + if supplied_bytes.is_empty(): + var supplied_path := str(metadata.get(&"thumbnail_path", "")) + if not supplied_path.is_empty() and FileAccess.file_exists(supplied_path): + supplied_bytes = FileAccess.get_file_as_bytes(supplied_path) + if not supplied_bytes.is_empty(): + var supplied_image := Image.new() + var decode_error := supplied_image.load_png_from_buffer(supplied_bytes) + if decode_error != OK: + return decode_error + var file := FileAccess.open(thumbnail_path, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_buffer(supplied_bytes) + return OK + var placeholder := Image.create_empty(1, 1, false, Image.FORMAT_RGBA8) + placeholder.fill(Color(0.08, 0.12, 0.10, 1.0)) + return placeholder.save_png(thumbnail_path) + + +static func _looks_like_save_package(path: String) -> bool: + if not DirAccess.dir_exists_absolute(path): + return false + if FileAccess.file_exists(path.path_join(WORLD_FILE)): + return true + var has_manifest := FileAccess.file_exists(path.path_join(MANIFEST_FILE)) + var has_companion := ( + FileAccess.file_exists(path.path_join(THUMBNAIL_FILE)) + or FileAccess.file_exists(path.path_join(JOURNAL_FILE)) + ) + return has_manifest and has_companion + + +static func _path_is_missing_or_package(path: String) -> bool: + if not FileAccess.file_exists(path) and not DirAccess.dir_exists_absolute(path): + return true + return _looks_like_save_package(path) + + +static func _validate_package_read_boundary(package_path: String) -> Dictionary: + if not _operation_paths_are_safe([package_path]): + return _failure(&"UNSAFE_PATH", "save package contains a symbolic link: %s" % package_path) + if not DirAccess.dir_exists_absolute(package_path): + return _failure(&"SAVE_NOT_FOUND", package_path) + for file_name: String in [WORLD_FILE, MANIFEST_FILE, THUMBNAIL_FILE, JOURNAL_FILE]: + var known_path := package_path.path_join(file_name) + if _is_symbolic_link(known_path): + return _failure(&"UNSAFE_PATH", "symbolic link in save package: %s" % known_path) + if DirAccess.dir_exists_absolute(known_path): + return _failure(&"INVALID_PACKAGE_ENTRY", known_path) + return {&"ok": true, &"code": &"OK"} + + +static func _package_file_is_safe_for_read(path: String) -> bool: + var user_root := ProjectSettings.globalize_path("user://").simplify_path() + var normalized := path.simplify_path() + if normalized == user_root or not normalized.begins_with(user_root + "/"): + return false + if _path_has_symbolic_link_component(normalized, user_root): + return false + return not _is_symbolic_link(normalized) and not DirAccess.dir_exists_absolute(normalized) + + +static func _formal_transaction_marker_is_valid(marker_path: String) -> bool: + if _is_symbolic_link(marker_path) or DirAccess.dir_exists_absolute(marker_path): + return false + if not FileAccess.file_exists(marker_path): + return false + return FileAccess.get_file_as_string(marker_path) == SAVE_TRANSACTION_MARKER + + +static func _remove_formal_transaction_marker(marker_path: String) -> void: + if _formal_transaction_marker_is_valid(marker_path): + DirAccess.remove_absolute(marker_path) + + +static func _operation_paths_are_safe(paths: Array) -> bool: + var user_root := ProjectSettings.globalize_path("user://").simplify_path() + for raw_path: Variant in paths: + var path := str(raw_path).simplify_path() + if path == user_root or not path.begins_with(user_root + "/"): + return false + if _path_has_symbolic_link_component(path, user_root): + return false + if DirAccess.dir_exists_absolute(path) and _tree_contains_symbolic_link(path): + return false + return true + + +static func _path_has_symbolic_link_component(path: String, root_path: String) -> bool: + if path == root_path: + return false + var relative := path.trim_prefix(root_path + "/") + var current := root_path + for segment: String in relative.split("/", false): + var parent := DirAccess.open(current) + if parent != null and parent.is_link(segment): + return true + current = current.path_join(segment) + return false + + +static func _tree_contains_symbolic_link(path: String) -> bool: + var directory := DirAccess.open(path) + if directory == null: + return false + directory.list_dir_begin() + var name := directory.get_next() + while not name.is_empty(): + if name != "." and name != "..": + if directory.is_link(name): + directory.list_dir_end() + return true + if directory.current_is_dir() and _tree_contains_symbolic_link(path.path_join(name)): + directory.list_dir_end() + return true + name = directory.get_next() + directory.list_dir_end() + return false + + +static func _is_symbolic_link(path: String) -> bool: + var parent := DirAccess.open(path.get_base_dir()) + return parent != null and parent.is_link(path.get_file()) + + +static func _with_context(result: Dictionary, context: Dictionary) -> Dictionary: + var enriched := result.duplicate(true) + for key: Variant in context: + enriched[key] = context[key] + return enriched + + +static func _failure(code: StringName, message: String) -> Dictionary: + return { + &"ok": false, + &"code": code, + &"message": message, + } + + +static func _remove_tree(path: String) -> void: + if _is_symbolic_link(path): + DirAccess.remove_absolute(path) + return + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + return + if not DirAccess.dir_exists_absolute(path): + return + var directory := DirAccess.open(path) + if directory == null: + return + directory.list_dir_begin() + var name := directory.get_next() + while not name.is_empty(): + if name != "." and name != "..": + var child_path := path.path_join(name) + if directory.current_is_dir(): + _remove_tree(child_path) + else: + DirAccess.remove_absolute(child_path) + name = directory.get_next() + directory.list_dir_end() + DirAccess.remove_absolute(path) diff --git a/scripts/core/save_service.gd.uid b/scripts/core/save_service.gd.uid new file mode 100644 index 0000000..8c7f516 --- /dev/null +++ b/scripts/core/save_service.gd.uid @@ -0,0 +1 @@ +uid://chwd86m6gactn diff --git a/scripts/core/simulation_digest.gd b/scripts/core/simulation_digest.gd new file mode 100644 index 0000000..ac3fbef --- /dev/null +++ b/scripts/core/simulation_digest.gd @@ -0,0 +1,240 @@ +class_name SimulationDigest +extends RefCounted + +## Produces a stable SHA-256 digest for snapshot-shaped Variant data. +## +## Dictionary insertion order never affects the result. Type tags and byte +## lengths make the canonical form unambiguous, and Array order remains +## significant. Runtime Objects and other process-local values are rejected; +## callers must first export them to Dictionaries, Arrays and value types. + +const FORMAT_VERSION := 1 +const MAX_NESTING_DEPTH := 128 + + +static func compute(value: Variant) -> String: + var canonical := canonical_bytes(value) + if canonical.is_empty(): + return "" + var context := HashingContext.new() + if context.start(HashingContext.HASH_SHA256) != OK: + push_error("Could not initialize the simulation digest hash") + return "" + context.update(canonical) + return context.finish().hex_encode() + + +static func canonical_bytes(value: Variant) -> PackedByteArray: + var output := PackedByteArray() + _append_ascii(output, "the-tree:simulation-digest:v%d;" % FORMAT_VERSION) + if not _encode_value(value, output, 0): + return PackedByteArray() + return output + + +static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -> bool: + if depth > MAX_NESTING_DEPTH: + push_error("SimulationDigest data exceeds the maximum nesting depth") + return false + + match typeof(value): + TYPE_NIL: + _append_ascii(output, "n") + TYPE_BOOL: + _append_ascii(output, "b1" if bool(value) else "b0") + TYPE_INT: + _append_ascii(output, "i%d;" % int(value)) + TYPE_FLOAT: + _append_ascii(output, "f") + return _append_float64(output, float(value)) + TYPE_STRING: + _append_sized_bytes(output, "s", String(value).to_utf8_buffer()) + TYPE_STRING_NAME: + _append_sized_bytes(output, "q", String(value).to_utf8_buffer()) + TYPE_VECTOR2: + var vector: Vector2 = value + _append_ascii(output, "v2") + return _append_float64(output, vector.x) and _append_float64(output, vector.y) + TYPE_VECTOR2I: + var vector: Vector2i = value + _append_ascii(output, "w2") + _append_ascii(output, "i%d;i%d;" % [vector.x, vector.y]) + TYPE_RECT2: + var rect: Rect2 = value + _append_ascii(output, "r2") + return ( + _append_float64(output, rect.position.x) + and _append_float64(output, rect.position.y) + and _append_float64(output, rect.size.x) + and _append_float64(output, rect.size.y) + ) + TYPE_RECT2I: + var rect: Rect2i = value + _append_ascii(output, "x2") + _append_ascii(output, "i%d;i%d;i%d;i%d;" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y]) + TYPE_VECTOR3: + var vector: Vector3 = value + _append_ascii(output, "v3") + return ( + _append_float64(output, vector.x) + and _append_float64(output, vector.y) + and _append_float64(output, vector.z) + ) + TYPE_VECTOR3I: + var vector: Vector3i = value + _append_ascii(output, "w3") + _append_ascii(output, "i%d;i%d;i%d;" % [vector.x, vector.y, vector.z]) + TYPE_VECTOR4: + var vector: Vector4 = value + _append_ascii(output, "v4") + return ( + _append_float64(output, vector.x) + and _append_float64(output, vector.y) + and _append_float64(output, vector.z) + and _append_float64(output, vector.w) + ) + TYPE_VECTOR4I: + var vector: Vector4i = value + _append_ascii(output, "w4") + _append_ascii(output, "i%d;i%d;i%d;i%d;" % [vector.x, vector.y, vector.z, vector.w]) + TYPE_COLOR: + var color: Color = value + _append_ascii(output, "c") + return ( + _append_float64(output, color.r) + and _append_float64(output, color.g) + and _append_float64(output, color.b) + and _append_float64(output, color.a) + ) + TYPE_ARRAY: + return _encode_array(value, output, depth) + TYPE_DICTIONARY: + return _encode_dictionary(value, output, depth) + TYPE_PACKED_BYTE_ARRAY: + _append_sized_bytes(output, "pb", value) + TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: + return _encode_packed_int_array(value, output) + TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: + return _encode_packed_float_array(value, output) + TYPE_PACKED_STRING_ARRAY: + return _encode_packed_string_array(value, output) + TYPE_PACKED_VECTOR2_ARRAY: + return _encode_packed_vector2_array(value, output) + TYPE_PACKED_VECTOR3_ARRAY: + return _encode_packed_vector3_array(value, output) + TYPE_PACKED_COLOR_ARRAY: + return _encode_packed_color_array(value, output) + _: + push_error("SimulationDigest does not support Variant type %s" % type_string(typeof(value))) + return false + return true + + +static func _encode_array(value: Array, output: PackedByteArray, depth: int) -> bool: + _append_ascii(output, "a%d:" % value.size()) + for item in value: + if not _encode_value(item, output, depth + 1): + return false + return true + + +static func _encode_dictionary(value: Dictionary, output: PackedByteArray, depth: int) -> bool: + var entries: Array[Dictionary] = [] + for key in value: + var encoded_key := PackedByteArray() + if not _encode_value(key, encoded_key, depth + 1): + return false + entries.append({ + "sort_key": encoded_key.hex_encode(), + "encoded_key": encoded_key, + "value": value[key], + }) + entries.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + return String(left["sort_key"]) < String(right["sort_key"]) + ) + + _append_ascii(output, "d%d:" % entries.size()) + for entry in entries: + var encoded_key: PackedByteArray = entry["encoded_key"] + output.append_array(encoded_key) + if not _encode_value(entry["value"], output, depth + 1): + return false + return true + + +static func _encode_packed_int_array(value: Variant, output: PackedByteArray) -> bool: + var tag := "pi32" if typeof(value) == TYPE_PACKED_INT32_ARRAY else "pi64" + _append_ascii(output, "%s%d:" % [tag, value.size()]) + for item in value: + _append_ascii(output, "i%d;" % int(item)) + return true + + +static func _encode_packed_float_array(value: Variant, output: PackedByteArray) -> bool: + var tag := "pf32" if typeof(value) == TYPE_PACKED_FLOAT32_ARRAY else "pf64" + _append_ascii(output, "%s%d:" % [tag, value.size()]) + for item in value: + if not _append_float64(output, float(item)): + return false + return true + + +static func _encode_packed_string_array(value: PackedStringArray, output: PackedByteArray) -> bool: + _append_ascii(output, "ps%d:" % value.size()) + for item in value: + _append_sized_bytes(output, "s", item.to_utf8_buffer()) + return true + + +static func _encode_packed_vector2_array(value: PackedVector2Array, output: PackedByteArray) -> bool: + _append_ascii(output, "pv2%d:" % value.size()) + for item in value: + if not _append_float64(output, item.x) or not _append_float64(output, item.y): + return false + return true + + +static func _encode_packed_vector3_array(value: PackedVector3Array, output: PackedByteArray) -> bool: + _append_ascii(output, "pv3%d:" % value.size()) + for item in value: + if ( + not _append_float64(output, item.x) + or not _append_float64(output, item.y) + or not _append_float64(output, item.z) + ): + return false + return true + + +static func _encode_packed_color_array(value: PackedColorArray, output: PackedByteArray) -> bool: + _append_ascii(output, "pc%d:" % value.size()) + for item in value: + if ( + not _append_float64(output, item.r) + or not _append_float64(output, item.g) + or not _append_float64(output, item.b) + or not _append_float64(output, item.a) + ): + return false + return true + + +static func _append_float64(output: PackedByteArray, value: float) -> bool: + if is_nan(value) or is_inf(value): + push_error("SimulationDigest rejects NaN and infinite float values") + return false + var normalized := 0.0 if value == 0.0 else value + var encoded := PackedByteArray() + encoded.resize(8) + encoded.encode_double(0, normalized) + output.append_array(encoded) + return true + + +static func _append_sized_bytes(output: PackedByteArray, tag: String, value: PackedByteArray) -> void: + _append_ascii(output, "%s%d:" % [tag, value.size()]) + output.append_array(value) + + +static func _append_ascii(output: PackedByteArray, value: String) -> void: + output.append_array(value.to_utf8_buffer()) diff --git a/scripts/core/simulation_digest.gd.uid b/scripts/core/simulation_digest.gd.uid new file mode 100644 index 0000000..c22a055 --- /dev/null +++ b/scripts/core/simulation_digest.gd.uid @@ -0,0 +1 @@ +uid://dxqv2isl2o0le diff --git a/scripts/core/simulation_kernel.gd b/scripts/core/simulation_kernel.gd new file mode 100644 index 0000000..0daef0b --- /dev/null +++ b/scripts/core/simulation_kernel.gd @@ -0,0 +1,258 @@ +class_name SimulationKernel +extends Node + +signal time_changed(day: int, hour: int, minute: int) +signal day_started(day: int) +signal settlement_requested(day: int) +signal tick_started(absolute_minute: int) +signal tick_phase_started(phase: TickPhase, absolute_minute: int) +signal tick_completed(absolute_minute: int) +signal simulation_overloaded(backlog_minutes: int) + +enum TickPhase { + APPLY_COMMANDS, + TIME_BOUNDARIES, + MANA, + CONSTRUCTION, + LOGISTICS, + FACILITY_COMPLETION, + VITALS_AND_ENVIRONMENT, + INCIDENTS_AND_EVENTS, + FACILITY_QUEUES, + AI_DECISIONS, + PUBLISH_SNAPSHOT, +} + +const MINUTES_PER_DAY := 24 * 60 +const START_ABSOLUTE_MINUTE := 6 * 60 +const REAL_SECONDS_PER_GAME_HOUR := 30.0 +const REAL_SECONDS_PER_GAME_MINUTE := REAL_SECONDS_PER_GAME_HOUR / 60.0 +const MAX_TICKS_PER_FRAME := 8 +const MAX_BACKLOG_MINUTES := 120 + +var absolute_minute := START_ABSOLUTE_MINUTE +var day := 1 +var minute_of_day := START_ABSOLUTE_MINUTE +var speed := 1 +var paused := false +var snapshot_version := 0 +var performance_budget := SimulationPerformanceBudget.new() + +var _accumulator := 0.0 +var _skip_next_process := false +var _phase_handlers: Dictionary = {} +var _pause_request_serial := 0 +var _abort_current_tick_requested := false +var _last_tick_advanced := false + +func _ready() -> void: + _emit_current_time() + +func _process(delta: float) -> void: + process_real_delta(delta) + +func process_real_delta(delta: float) -> int: + if _skip_next_process: + _skip_next_process = false + return 0 + if paused or speed <= 0 or delta <= 0.0: + return 0 + + var frame_started_at_us := Time.get_ticks_usec() + _accumulator += delta * speed + var due_ticks := int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE) + var tick_budget := mini(due_ticks, MAX_TICKS_PER_FRAME) + var ticks_run := 0 + for _index in range(tick_budget): + if paused: + break + advance_one_minute() + if not _last_tick_advanced: + break + _accumulator -= REAL_SECONDS_PER_GAME_MINUTE + ticks_run += 1 + # A boundary handler may pause in the middle of a catch-up frame. Never + # consume another queued minute after that decision. + if paused: + break + if ticks_run > 0: + performance_budget.record_simulation_frame( + int(Time.get_ticks_usec() - frame_started_at_us), + speed, + ticks_run + ) + + var backlog_minutes := get_backlog_minutes() + if backlog_minutes > MAX_BACKLOG_MINUTES: + paused = true + simulation_overloaded.emit(backlog_minutes) + return ticks_run + +func reset(start_day := 1, start_hour := 6, start_minute := 0) -> void: + var safe_day := maxi(start_day, 1) + var safe_minute := posmod(start_hour * 60 + start_minute, MINUTES_PER_DAY) + absolute_minute = (safe_day - 1) * MINUTES_PER_DAY + safe_minute + _sync_calendar_fields() + _accumulator = 0.0 + paused = false + snapshot_version = 0 + performance_budget.clear() + _abort_current_tick_requested = false + _last_tick_advanced = false + _emit_current_time() + +func advance_minutes(amount: int) -> void: + if amount <= 0: + return + # Tests and deterministic tooling historically use an already-paused clock as + # an explicit manual stepping mode. Preserve that contract, while a pause + # raised during an initially-running batch is a hard boundary stop. + var manual_paused_stepping := paused + var pause_serial_before := _pause_request_serial + for _index in range(amount): + if not manual_paused_stepping and paused: + break + advance_one_minute() + if not _last_tick_advanced: + break + if _pause_request_serial != pause_serial_before \ + or (not manual_paused_stepping and paused): + break + +func advance_one_minute() -> void: + _abort_current_tick_requested = false + _last_tick_advanced = false + tick_started.emit(absolute_minute) + _run_phase(TickPhase.APPLY_COMMANDS) + # A Runtime gate can reject the tick before the Clock changes. This is + # distinct from an ordinary pause raised by a command that already committed + # for the upcoming minute. + if _abort_current_tick_requested: + _abort_current_tick_requested = false + return + + var previous_absolute_minute := absolute_minute + absolute_minute += 1 + _sync_calendar_fields() + _handle_time_boundaries(previous_absolute_minute) + _run_phase(TickPhase.TIME_BOUNDARIES) + + for phase in [ + TickPhase.MANA, + TickPhase.CONSTRUCTION, + TickPhase.LOGISTICS, + TickPhase.FACILITY_COMPLETION, + TickPhase.VITALS_AND_ENVIRONMENT, + TickPhase.INCIDENTS_AND_EVENTS, + TickPhase.FACILITY_QUEUES, + TickPhase.AI_DECISIONS, + TickPhase.PUBLISH_SNAPSHOT, + ]: + _run_phase(phase) + + snapshot_version += 1 + tick_completed.emit(absolute_minute) + _emit_current_time() + _abort_current_tick_requested = false + _last_tick_advanced = true + +func request_current_tick_abort() -> void: + _abort_current_tick_requested = true + +func set_speed(value: int) -> bool: + if value not in [1, 2, 4]: + return false + speed = value + paused = false + return true + +func toggle_pause() -> bool: + paused = not paused + if paused: + _pause_request_serial += 1 + return paused + +func set_paused(value: bool) -> void: + paused = value + if value: + _pause_request_serial += 1 + +func get_hour() -> int: + return minute_of_day / 60 + +func get_minute() -> int: + return minute_of_day % 60 + +func get_backlog_minutes() -> int: + return int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE) + +func get_performance_reports() -> Array[Dictionary]: + return performance_budget.get_all_reports() + +func register_tick_handler(phase: TickPhase, handler_id: StringName, callback: Callable) -> bool: + if handler_id.is_empty() or not callback.is_valid(): + return false + if not _phase_handlers.has(phase): + _phase_handlers[phase] = {} + var handlers: Dictionary = _phase_handlers[phase] + if handlers.has(handler_id): + return false + handlers[handler_id] = callback + return true + +func unregister_tick_handler(phase: TickPhase, handler_id: StringName) -> void: + if not _phase_handlers.has(phase): + return + var handlers: Dictionary = _phase_handlers[phase] + handlers.erase(handler_id) + +func clock_snapshot() -> Dictionary: + return { + &"absolute_minute": absolute_minute, + &"speed": speed, + &"paused": paused, + &"snapshot_version": snapshot_version, + } + +func restore_clock(snapshot: Dictionary) -> bool: + if not snapshot.has(&"absolute_minute"): + return false + var restored_speed := int(snapshot.get(&"speed", 1)) + if restored_speed not in [1, 2, 4]: + return false + absolute_minute = maxi(int(snapshot[&"absolute_minute"]), 0) + speed = restored_speed + paused = bool(snapshot.get(&"paused", true)) + snapshot_version = maxi(int(snapshot.get(&"snapshot_version", 0)), 0) + _accumulator = 0.0 + _skip_next_process = true + _sync_calendar_fields() + _emit_current_time() + return true + +func _run_phase(phase: TickPhase) -> void: + tick_phase_started.emit(phase, absolute_minute) + if not _phase_handlers.has(phase): + return + var handlers: Dictionary = _phase_handlers[phase] + var handler_ids: Array = handlers.keys() + handler_ids.sort_custom(func(left: Variant, right: Variant) -> bool: + return String(left) < String(right) + ) + for handler_id: Variant in handler_ids: + var callback: Callable = handlers[handler_id] + if callback.is_valid(): + callback.call(absolute_minute) + +func _sync_calendar_fields() -> void: + day = absolute_minute / MINUTES_PER_DAY + 1 + minute_of_day = absolute_minute % MINUTES_PER_DAY + +func _handle_time_boundaries(previous_absolute_minute: int) -> void: + if minute_of_day == 0: + var completed_day := previous_absolute_minute / MINUTES_PER_DAY + 1 + settlement_requested.emit(completed_day) + day_started.emit(day) + +func _emit_current_time() -> void: + time_changed.emit(day, get_hour(), get_minute()) diff --git a/scripts/core/simulation_kernel.gd.uid b/scripts/core/simulation_kernel.gd.uid new file mode 100644 index 0000000..0d9d4ec --- /dev/null +++ b/scripts/core/simulation_kernel.gd.uid @@ -0,0 +1 @@ +uid://vj3mrw0wb5bx diff --git a/scripts/core/simulation_snapshot.gd b/scripts/core/simulation_snapshot.gd new file mode 100644 index 0000000..0c68bb8 --- /dev/null +++ b/scripts/core/simulation_snapshot.gd @@ -0,0 +1,47 @@ +class_name SimulationSnapshot +extends RefCounted + +const SCHEMA_VERSION := 1 + +static func capture(clock: SimulationKernel, state: Node, extra := {}) -> Dictionary: + var snapshot := { + &"schema_version": SCHEMA_VERSION, + &"clock": clock.clock_snapshot(), + &"game_state": state.create_snapshot(), + } + if extra is Dictionary and not extra.is_empty(): + snapshot[&"systems"] = extra.duplicate(true) + return snapshot + +static func validate(snapshot: Dictionary) -> Dictionary: + var errors: Array[String] = [] + if int(snapshot.get(&"schema_version", -1)) != SCHEMA_VERSION: + errors.append("unsupported_schema_version") + if not snapshot.get(&"clock", null) is Dictionary: + errors.append("missing_clock") + else: + var clock_data: Dictionary = snapshot[&"clock"] + if int(clock_data.get(&"absolute_minute", -1)) < 0: + errors.append("invalid_absolute_minute") + if int(clock_data.get(&"speed", 0)) not in [1, 2, 4]: + errors.append("invalid_speed") + if not snapshot.get(&"game_state", null) is Dictionary: + errors.append("missing_game_state") + return { + &"ok": errors.is_empty(), + &"errors": errors, + } + +static func restore(snapshot: Dictionary, clock: SimulationKernel, state: Node) -> bool: + var validation := validate(snapshot) + if not validation[&"ok"]: + return false + var previous_clock := clock.clock_snapshot() + var previous_state: Dictionary = state.create_snapshot() + if not clock.restore_clock(snapshot[&"clock"]): + return false + if not state.restore_snapshot(snapshot[&"game_state"]): + clock.restore_clock(previous_clock) + state.restore_snapshot(previous_state) + return false + return true diff --git a/scripts/core/simulation_snapshot.gd.uid b/scripts/core/simulation_snapshot.gd.uid new file mode 100644 index 0000000..fe2f89f --- /dev/null +++ b/scripts/core/simulation_snapshot.gd.uid @@ -0,0 +1 @@ +uid://c0vxraq44s1wu diff --git a/scripts/core/stable_id_generator.gd b/scripts/core/stable_id_generator.gd new file mode 100644 index 0000000..242835f --- /dev/null +++ b/scripts/core/stable_id_generator.gd @@ -0,0 +1,60 @@ +class_name StableIdGenerator +extends RefCounted + +const SNAPSHOT_VERSION := 1 +const MAX_COUNTER := 999999999999 + +var _counters: Dictionary = {} + +func next_id(prefix: StringName) -> StringName: + if not _is_valid_prefix(String(prefix)): + push_error("Stable ID prefix must be lowercase ASCII snake_case") + return &"" + var next_value := int(_counters.get(prefix, 0)) + 1 + if next_value > MAX_COUNTER: + push_error("Stable ID counter exhausted for '%s'" % prefix) + return &"" + _counters[prefix] = next_value + return StringName("%s.%012d" % [prefix, next_value]) + +func get_counter(prefix: StringName) -> int: + return int(_counters.get(prefix, 0)) + +func create_snapshot() -> Dictionary: + var counters := {} + var prefixes: Array = _counters.keys() + prefixes.sort() + for prefix: Variant in prefixes: + counters[String(prefix)] = int(_counters[prefix]) + return { + "version": SNAPSHOT_VERSION, + "counters": counters, + } + +func restore_snapshot(snapshot: Dictionary) -> bool: + if int(snapshot.get("version", -1)) != SNAPSHOT_VERSION: + return false + var saved_counters: Variant = snapshot.get("counters", null) + if not saved_counters is Dictionary: + return false + var restored := {} + for prefix: Variant in saved_counters: + var normalized_prefix := String(prefix) + var counter := int(saved_counters[prefix]) + if not _is_valid_prefix(normalized_prefix) or counter < 0 or counter > MAX_COUNTER: + return false + restored[StringName(normalized_prefix)] = counter + _counters = restored + return true + +func reset() -> void: + _counters.clear() + +static func _is_valid_prefix(prefix: String) -> bool: + if prefix.is_empty() or prefix[0] < "a" or prefix[0] > "z": + return false + for character in prefix: + if (character >= "a" and character <= "z") or (character >= "0" and character <= "9") or character == "_": + continue + return false + return true diff --git a/scripts/core/stable_id_generator.gd.uid b/scripts/core/stable_id_generator.gd.uid new file mode 100644 index 0000000..04543fc --- /dev/null +++ b/scripts/core/stable_id_generator.gd.uid @@ -0,0 +1 @@ +uid://6dn6weviv7a8 diff --git a/scripts/core/state_transaction.gd b/scripts/core/state_transaction.gd new file mode 100644 index 0000000..ca17670 --- /dev/null +++ b/scripts/core/state_transaction.gd @@ -0,0 +1,266 @@ +class_name StateTransaction +extends RefCounted + +enum Status { + OPEN, + COMMITTED, + ROLLED_BACK, + FAILED, +} + +class StagedChange: + extends RefCounted + + var label := "" + var validate_action := Callable() + var apply_action := Callable() + var rollback_action := Callable() + + func _init( + p_label: String, + p_validate_action: Callable, + p_apply_action: Callable, + p_rollback_action: Callable + ) -> void: + label = p_label + validate_action = p_validate_action + apply_action = p_apply_action + rollback_action = p_rollback_action + + +var status: Status = Status.OPEN +var _changes: Array[StagedChange] = [] +var _validators: Array[Callable] = [] +var _configuration_errors: Array[String] = [] + + +func stage_dictionary_set( + target: Dictionary, + key: Variant, + value: Variant, + label: String = "" +) -> bool: + if not _require_open("stage_dictionary_set"): + return false + + var had_key := target.has(key) + var original_value: Variant = target.get(key) + var original_snapshot: Variant = _copy_value(original_value) + var staged_value: Variant = _copy_value(value) + var change_label := label if not label.is_empty() else "set dictionary key %s" % str(key) + + return stage_custom( + change_label, + func() -> Variant: + if target.has(key) != had_key: + return "Dictionary key presence changed before commit." + if had_key and target[key] != original_snapshot: + return "Dictionary value changed before commit." + return true, + func() -> Variant: + target[key] = _copy_value(staged_value) + return true, + func() -> void: + if had_key: + target[key] = original_value + else: + target.erase(key) + ) + + +func stage_dictionary_erase( + target: Dictionary, + key: Variant, + label: String = "" +) -> bool: + if not _require_open("stage_dictionary_erase"): + return false + + var had_key := target.has(key) + var original_value: Variant = target.get(key) + var original_snapshot: Variant = _copy_value(original_value) + var change_label := label if not label.is_empty() else "erase dictionary key %s" % str(key) + + return stage_custom( + change_label, + func() -> Variant: + if target.has(key) != had_key: + return "Dictionary key presence changed before commit." + if had_key and target[key] != original_snapshot: + return "Dictionary value changed before commit." + return true, + func() -> Variant: + target.erase(key) + return true, + func() -> void: + if had_key: + target[key] = original_value + ) + + +func stage_dictionary_increment( + target: Dictionary, + key: Variant, + delta: Variant, + minimum: Variant = null, + maximum: Variant = null, + label: String = "" +) -> bool: + if not _require_open("stage_dictionary_increment"): + return false + if not target.has(key) or not _is_number(target[key]) or not _is_number(delta): + _configuration_errors.append("Increment requires an existing numeric value and numeric delta.") + return false + + var next_value: Variant = target[key] + delta + if minimum != null and (not _is_number(minimum) or next_value < minimum): + _configuration_errors.append("Increment result is below its minimum.") + return false + if maximum != null and (not _is_number(maximum) or next_value > maximum): + _configuration_errors.append("Increment result is above its maximum.") + return false + return stage_dictionary_set(target, key, next_value, label) + + +func add_validator(validator: Callable) -> bool: + if not _require_open("add_validator"): + return false + if not validator.is_valid(): + _configuration_errors.append("Validator is not callable.") + return false + _validators.append(validator) + return true + + +func stage_custom( + label: String, + validate_action: Callable, + apply_action: Callable, + rollback_action: Callable +) -> bool: + if not _require_open("stage_custom"): + return false + if not apply_action.is_valid() or not rollback_action.is_valid(): + _configuration_errors.append("Custom change '%s' requires apply and rollback actions." % label) + return false + _changes.append(StagedChange.new(label, validate_action, apply_action, rollback_action)) + return true + + +func validate() -> CommandResult: + if status != Status.OPEN: + return CommandResult.failed( + CommandResult.Code.TRANSACTION_CLOSED, + 0, + "Transaction is not open." + ) + if not _configuration_errors.is_empty(): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + 0, + _configuration_errors[0], + {"errors": _configuration_errors.duplicate()} + ) + + for index in _validators.size(): + var validator_result := _validation_result(_validators[index].call(), "validator %d" % index) + if validator_result != null: + return validator_result + + for change in _changes: + if not change.validate_action.is_valid(): + continue + var change_result := _validation_result(change.validate_action.call(), change.label) + if change_result != null: + return change_result + return CommandResult.succeeded() + + +func commit() -> CommandResult: + var validation_result := validate() + if not validation_result.is_success(): + if status == Status.OPEN: + status = Status.FAILED + return validation_result + + var applied: Array[StagedChange] = [] + for change in _changes: + var returned: Variant = change.apply_action.call() + var apply_failure := _apply_result(returned, change.label) + if apply_failure != null: + change.rollback_action.call() + for index in range(applied.size() - 1, -1, -1): + applied[index].rollback_action.call() + status = Status.ROLLED_BACK + apply_failure.details["rolled_back"] = true + return apply_failure + applied.append(change) + + status = Status.COMMITTED + return CommandResult.succeeded(0, {"change_count": _changes.size()}) + + +func rollback() -> CommandResult: + if status != Status.OPEN: + return CommandResult.failed( + CommandResult.Code.TRANSACTION_CLOSED, + 0, + "Only an open transaction can be cancelled." + ) + status = Status.ROLLED_BACK + return CommandResult.succeeded() + + +func change_count() -> int: + return _changes.size() + + +func _require_open(operation: String) -> bool: + if status == Status.OPEN: + return true + _configuration_errors.append("%s cannot run on a closed transaction." % operation) + return false + + +func _validation_result(returned: Variant, label: String) -> CommandResult: + if returned == null or (returned is bool and returned): + return null + if returned is CommandResult: + var command_result: CommandResult = returned + if command_result.is_success(): + return null + return command_result.copy() + var message := str(returned) if returned is String and not returned.is_empty() else "Validation failed." + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, + 0, + "%s: %s" % [label, message], + {"change": label} + ) + + +func _apply_result(returned: Variant, label: String) -> CommandResult: + if returned == null or (returned is bool and returned): + return null + if returned is CommandResult: + var command_result: CommandResult = returned + if command_result.is_success(): + return null + return command_result.copy() + var message := str(returned) if returned is String and not returned.is_empty() else "Apply action failed." + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + 0, + "%s: %s" % [label, message], + {"change": label} + ) + + +static func _copy_value(value: Variant) -> Variant: + if value is Dictionary or value is Array: + return value.duplicate(true) + return value + + +static func _is_number(value: Variant) -> bool: + return value is int or value is float diff --git a/scripts/core/state_transaction.gd.uid b/scripts/core/state_transaction.gd.uid new file mode 100644 index 0000000..4865843 --- /dev/null +++ b/scripts/core/state_transaction.gd.uid @@ -0,0 +1 @@ +uid://drilrnwk4am7v diff --git a/scripts/data/facility_catalog.gd b/scripts/data/facility_catalog.gd new file mode 100644 index 0000000..f5b2291 --- /dev/null +++ b/scripts/data/facility_catalog.gd @@ -0,0 +1,114 @@ +class_name FacilityCatalog +extends RefCounted + +## Canonical MVP order from GAME_SPECIFICATION section 7.6. +## +## DefinitionRegistry deliberately sorts its query results by stable ID. This +## list instead preserves the design-table order for tools that present the +## baseline catalog to designers or players. +const BASELINE_FACILITY_IDS: Array[StringName] = [ + &"reception", + &"standard_room", + &"cafeteria", + &"kitchen", + &"public_bath", + &"warehouse", + &"staff_waiting_room", + &"security_station", + &"tavern", + &"massage_room", + &"guest_elevator", + &"service_elevator", + &"staff_door", + &"guest_corridor", + &"service_corridor", + &"adult_private_room", +] + +const FACILITY_PATHS: Array[String] = [ + "res://data/facilities/reception.tres", + "res://data/facilities/standard_room.tres", + "res://data/facilities/cafeteria.tres", + "res://data/facilities/kitchen.tres", + "res://data/facilities/public_bath.tres", + "res://data/facilities/warehouse.tres", + "res://data/facilities/staff_waiting_room.tres", + "res://data/facilities/security_station.tres", + "res://data/facilities/tavern.tres", + "res://data/facilities/massage_room.tres", + "res://data/facilities/guest_elevator.tres", + "res://data/facilities/service_elevator.tres", + "res://data/facilities/staff_door.tres", + "res://data/facilities/guest_corridor.tres", + "res://data/facilities/service_corridor.tres", + "res://data/facilities/adult_private_room.tres", +] + +## Optional post-MVP expansion definitions. They live below a subdirectory so +## DefinitionRegistry's baseline directory scan remains exactly compatible with +## existing saves and the sixteen-facility acceptance fixture. +const EXPANSION_FACILITY_IDS: Array[StringName] = [ + &"premium_room", + &"tea_lounge", + &"general_store", + &"clinic", + &"guild_counter", + &"underground_guest_room", + &"private_bath", + &"secret_tavern", + &"black_market", + &"vip_private_salon", + &"laundry", + &"cold_storage", + &"mana_control_room", + &"maintenance_workshop", + &"staff_dormitory", + &"waste_processing", +] + +const EXPANSION_FACILITY_PATHS: Array[String] = [ + "res://data/facilities/expansion/premium_room.tres", + "res://data/facilities/expansion/tea_lounge.tres", + "res://data/facilities/expansion/general_store.tres", + "res://data/facilities/expansion/clinic.tres", + "res://data/facilities/expansion/guild_counter.tres", + "res://data/facilities/expansion/underground_guest_room.tres", + "res://data/facilities/expansion/private_bath.tres", + "res://data/facilities/expansion/secret_tavern.tres", + "res://data/facilities/expansion/black_market.tres", + "res://data/facilities/expansion/vip_private_salon.tres", + "res://data/facilities/expansion/laundry.tres", + "res://data/facilities/expansion/cold_storage.tres", + "res://data/facilities/expansion/mana_control_room.tres", + "res://data/facilities/expansion/maintenance_workshop.tres", + "res://data/facilities/expansion/staff_dormitory.tres", + "res://data/facilities/expansion/waste_processing.tres", +] + +static func load_all() -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for path in FACILITY_PATHS: + var definition := load(path) as FacilityDefinition + if definition != null: + result.append(definition) + return result + +static func load_expansion() -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for path in EXPANSION_FACILITY_PATHS: + var definition := load(path) as FacilityDefinition + if definition != null: + result.append(definition) + return result + +static func load_available(include_expansion := false) -> Array[FacilityDefinition]: + var result := load_all() + if include_expansion: + result.append_array(load_expansion()) + return result + +static func by_id(facility_id: StringName) -> FacilityDefinition: + for definition in load_available(true): + if definition.id == facility_id: + return definition + return null diff --git a/scripts/data/facility_catalog.gd.uid b/scripts/data/facility_catalog.gd.uid new file mode 100644 index 0000000..e64cce1 --- /dev/null +++ b/scripts/data/facility_catalog.gd.uid @@ -0,0 +1 @@ +uid://c61tnpw8yjyx7 diff --git a/scripts/data/facility_definition.gd b/scripts/data/facility_definition.gd new file mode 100644 index 0000000..765b2f9 --- /dev/null +++ b/scripts/data/facility_definition.gd @@ -0,0 +1,143 @@ +class_name FacilityDefinition +extends Resource + +const LEGACY_SCHEMA_VERSION := 1 +const DETAILED_SCHEMA_VERSION := 2 + +enum MapZone { + ANY, + ABOVE_GROUND, + BOUNDARY, + UNDERGROUND, +} + +enum ViewLayer { + FRONT, + BACK, +} + +@export_group("Identity") +@export_range(LEGACY_SCHEMA_VERSION, DETAILED_SCHEMA_VERSION, 1) var definition_schema_version := LEGACY_SCHEMA_VERSION +@export var id: StringName +@export var display_name := "" +@export_multiline var description := "" +@export var category: StringName = &"uncategorized" +@export var service_types: Array[StringName] = [] +@export var default_service_type: StringName = &"" +@export var service_profiles: Dictionary = {} + +@export_group("Placement") +@export var zone := MapZone.ANY +@export var layer := ViewLayer.FRONT +@export var footprint := Vector2i.ONE +@export var allowed_rotations: Array[int] = [0, 90, 180, 270] +@export var guest_access_offsets: Array[Vector2i] = [] +@export var staff_access_offsets: Array[Vector2i] = [] +@export var required_connection_tags: Array[StringName] = [] +@export var preferred_adjacency_tags: Array[StringName] = [] +@export var forbidden_adjacency_tags: Array[StringName] = [] + +@export_group("Construction") +@export var construction_cost := {&"gold": 100, &"materials": 1} +@export var build_minutes := 60 +@export_range(1, 3, 1) var weight_per_cell := 1 +@export var pass_through := false +@export_range(0, 100, 1) var demolition_refund_percent := 25 +@export_range(1, 5, 1) var max_level := 3 +@export var upgrade_costs: Array[Dictionary] = [] + +@export_group("Upkeep and Power") +@export var maintenance_gold := 0 +@export var maintenance_mana := 0 +@export_range(0, 100, 1) var power_priority := 50 + +@export_group("Staffing") +@export var staff_min := 0 +@export var staff_max := 0 +@export var required_roles: Array[StringName] = [] +@export var primary_skill: StringName = &"service" + +@export_group("Service") +@export var capacity := 0 +@export var queue_capacity := 0 +@export var base_service_minutes := 1 +@export var base_quality := 50 +@export var base_price := 0 +@export var inventory_inputs: Dictionary = {} +@export var inventory_outputs: Dictionary = {} +@export var opening_schedule := {&"morning": true, &"day": true, &"night": true} +@export var policy_schema: Dictionary = {} + +@export_group("Environment and Risk") +@export_range(0, 100, 1) var cleanliness_decay_per_service := 0 +@export_range(0, 100, 1) var noise_per_service := 0 +@export_range(-100, 100, 1) var beauty_effect := 0 +@export_range(-100, 100, 1) var security_effect := 0 +@export_range(-100, 100, 1) var concealment_effect := 0 +@export_range(0, 100, 1) var heat_per_service := 0 +@export var incident_weights: Dictionary = {} + +@export_group("Audience and Content") +@export var allowed_guest_tags: Array[StringName] = [] +@export var forbidden_guest_tags: Array[StringName] = [] +@export var adult_content := false +@export var content_replacement_id: StringName = &"" + +@export_group("Progression") +@export_range(1, 7, 1) var unlock_rank := 1 +@export var research_requirements: Array[StringName] = [] + +@export_group("Analytics") +@export var statistics_keys: Array[StringName] = [] + +@export_group("Classification") +@export var tags: Array[StringName] = [] + +func is_valid() -> bool: + return not id.is_empty() \ + and not display_name.is_empty() \ + and footprint.x > 0 \ + and footprint.y > 0 \ + and build_minutes > 0 \ + and weight_per_cell in [1, 2, 3] \ + and not allowed_rotations.is_empty() \ + and maintenance_gold >= 0 \ + and maintenance_mana >= 0 \ + and staff_min >= 0 \ + and staff_max >= staff_min \ + and capacity >= 0 \ + and queue_capacity >= 0 \ + and base_service_minutes > 0 \ + and base_price >= 0 \ + and detailed_profile_is_valid() + + +func has_detailed_profile() -> bool: + return definition_schema_version == DETAILED_SCHEMA_VERSION + + +func detailed_profile_is_valid() -> bool: + if definition_schema_version == LEGACY_SCHEMA_VERSION: + return true + if definition_schema_version != DETAILED_SCHEMA_VERSION: + return false + if category.is_empty() or service_types.is_empty() or default_service_type.is_empty(): + return false + if not service_types.has(default_service_type) or not service_profiles.has(default_service_type): + return false + if guest_access_offsets.is_empty() and staff_access_offsets.is_empty() and not pass_through: + return false + if demolition_refund_percent < 0 or demolition_refund_percent > 100: + return false + if max_level < 1 or max_level > 5 or upgrade_costs.size() != max_level - 1: + return false + if unlock_rank < 1 or unlock_rank > 7: + return false + for time_band: StringName in [&"morning", &"day", &"night"]: + if not opening_schedule.has(time_band) or not opening_schedule[time_band] is bool: + return false + if opening_schedule.size() != 3: + return false + if adult_content and (not tags.has(&"adult_only") or content_replacement_id.is_empty()): + return false + return true diff --git a/scripts/data/facility_definition.gd.uid b/scripts/data/facility_definition.gd.uid new file mode 100644 index 0000000..a4488d6 --- /dev/null +++ b/scripts/data/facility_definition.gd.uid @@ -0,0 +1 @@ +uid://cpmiimqx2vb58 diff --git a/scripts/data/mvp_content_catalog.gd b/scripts/data/mvp_content_catalog.gd new file mode 100644 index 0000000..4394cf7 --- /dev/null +++ b/scripts/data/mvp_content_catalog.gd @@ -0,0 +1,1000 @@ +class_name MvpContentCatalog +extends RefCounted + +## Validated, immutable content boundary for the 14-day MVP cast. +## +## Runtime snapshots keep stable IDs; balancing values remain in JSON and are +## rebuilt through this catalog. Invalid documents are never partially exposed. + +const GuestDefinitionType := preload("res://scripts/guest/guest_definition.gd") + +const SCHEMA_VERSION := 1 +const MAX_ENTRIES_PER_DOCUMENT := 128 +const MAX_DOCUMENT_BYTES := 1_048_576 +const MAX_CONTAINER_FIELDS := 64 +const MAX_ERRORS := 256 +const MAX_ID_LENGTH := 64 +const MAX_DISPLAY_NAME_LENGTH := 80 +const MAX_PATH_LENGTH := 512 +const MAX_JSON_DEPTH := 64 +const MAX_JSON_KEY_LENGTH := 128 +const MAX_JSON_TOKENS := 100_000 +const MAX_AGE := 10_000 +const MAX_GOLD_VALUE := 1_000_000_000_000 +const MAX_MECHANICAL_EFFECT_VALUE := 1_000_000 + +const DEFAULT_PATHS := { + &"guest_classes": "res://data/characters/mvp_guest_classes.json", + &"species": "res://data/characters/mvp_species.json", + &"staff_candidates": "res://data/characters/mvp_staff_candidates.json", + &"unique_characters": "res://data/characters/mvp_unique_characters.json", +} + +const REQUIRED_COUNTS := { + &"guest_classes": 8, + &"species": 4, + &"staff_candidates": 20, + &"unique_characters": 2, +} +const REQUIRED_IDS := { + &"guest_classes": [ + &"cleric", &"diplomat", &"mage", &"merchant", &"noble", &"outsider", &"rogue", &"warrior", + ], + &"species": [&"beastfolk", &"demonfolk", &"elf", &"human"], + &"staff_candidates": [ + &"staff_akane", &"staff_bardo", &"staff_chika", &"staff_doran", &"staff_eri", + &"staff_fugo", &"staff_gina", &"staff_haru", &"staff_iris", &"staff_juno", + &"staff_klaus", &"staff_lina", &"staff_marc", &"staff_nadia", &"staff_otto", + &"staff_petra", &"staff_quinn", &"staff_rika", &"staff_sven", &"staff_tessa", + ], + &"unique_characters": [&"heroine_anri", &"rival_rai"], +} + +const NEED_IDS: Array[StringName] = [ + &"hunger", &"hygiene", &"intimacy", &"leisure", &"medical", &"sleep", +] +const SKILL_IDS: Array[StringName] = [&"combat", &"operation", &"service", &"underworld"] +const SHIFT_IDS: Array[StringName] = [&"any", &"day", &"night"] +const SCHEDULE_PROFILE_IDS: Array[StringName] = [&"day", &"evening", &"night"] +const UNIQUE_KINDS: Array[StringName] = [&"heroine", &"rival"] +const MECHANICAL_EFFECT_IDS: Array[StringName] = [ + &"assigned_floor_patience_loss_multiplier_bp", + &"base_shield_attack", + &"night_shield_attack_bonus", +] +const HEROINE_EFFECT_IDS: Array[StringName] = [ + &"assigned_floor_patience_loss_multiplier_bp", +] +const RIVAL_EFFECT_IDS: Array[StringName] = [ + &"base_shield_attack", + &"night_shield_attack_bonus", +] + +const GUEST_TAGS: Array[StringName] = [ + &"combatant", &"diplomatic", &"faith_aligned", &"mana_sensitive", + &"nocturnal", &"noise_sensitive", &"stealthy", &"trade_focused", + &"underworld", &"vip", +] +const SPECIES_TAGS: Array[StringName] = [ + &"beastfolk", &"demonfolk", &"elf", &"human", &"mana_affinity", + &"night_affinity", &"noise_sensitive", +] +const PREFERENCE_IDS: Array[StringName] = [ + &"adult", &"faith", &"food", &"luxury", &"mana", &"nightlife", + &"quiet", &"security", &"service", &"tavern", &"trade", + &"underground", &"vip", +] +const ROLE_IDS: Array[StringName] = [ + &"attendant", &"bartender", &"clerk", &"cleaner", &"cook", &"guard", + &"influencer", &"innkeeper", &"maintenance", &"masseur", &"medic", + &"porter", &"receptionist", +] +const TRAIT_IDS: Array[StringName] = [ + &"calm", &"careful", &"cleanly", &"diplomatic", &"friendly", + &"merchant_minded", &"mentor", &"night_owl", &"short_tempered", + &"underworld_savvy", &"unique", &"unsellable", +] +const TASK_TAG_IDS: Array[StringName] = [ + &"accounting", &"adult_service", &"bar", &"bath", &"cleaning", + &"construction", &"cooking", &"defense", &"escort", &"logistics", + &"maintenance", &"massage", &"medical", &"reception", &"repair", + &"rescue", &"room_service", &"security", &"service", &"supply", &"trade", +] + +const DOCUMENT_FIELDS: Array[StringName] = [&"content_type", &"entries", &"schema_version"] +const GUEST_CLASS_FIELDS: Array[StringName] = [ + &"base_patience", &"credit_limit_gold", &"display_name", + &"group_size_weights", &"id", &"legal_sensitivity", &"minimum_quality", + &"need_rates", &"preferences", &"quality_sensitivity", &"risk_tolerance", + &"schedule_profile", &"tags", &"wealth_max", &"wealth_min", +] +const SPECIES_FIELDS: Array[StringName] = [ + &"adult_age", &"display_name", &"id", &"need_rate_modifiers", + &"patience_bonus", &"preference_modifiers", &"risk_bonus", &"tags", + &"wealth_multiplier_bp", +] +const STAFF_FIELDS: Array[StringName] = [ + &"adult_verified", &"age", &"daily_wage", &"display_name", &"id", + &"permitted_task_tags", &"preferred_shift", &"role_id", &"signing_cost", + &"skills", &"traits", +] +const UNIQUE_FIELDS: Array[StringName] = [ + &"adult_verified", &"age", &"character_kind", &"counterpart_id", + &"display_name", &"id", &"mechanical_effects", &"preferred_shift", + &"role_id", &"skills", &"traits", +] + +var _entries_by_type: Dictionary = {} +var _ordered_ids_by_type: Dictionary = {} +var _errors: Array[Dictionary] = [] +var _errors_truncated := false +var _json_scan_text := "" +var _json_scan_index := 0 +var _json_scan_depth := 0 +var _json_scan_tokens := 0 +var _json_scan_failure: StringName = &"" +var _json_last_string := "" + + +func rebuild(paths: Dictionary = DEFAULT_PATHS) -> Dictionary: + _reset() + if not _validate_path_set(paths): + _errors.sort_custom(_issue_less) + return get_report() + var documents: Dictionary = {} + for content_type: StringName in _ordered_content_types(): + var path := String(_value(paths, content_type, "")) + if path.is_empty(): + documents[content_type] = null + continue + documents[content_type] = _read_json_document(path, content_type) + var read_errors := _errors.duplicate(true) + var report := rebuild_from_documents(documents) + if not read_errors.is_empty(): + _errors.append_array(read_errors) + _errors.sort_custom(_issue_less) + _entries_by_type.clear() + _ordered_ids_by_type.clear() + report = get_report() + return report + + +func rebuild_from_documents(documents: Dictionary) -> Dictionary: + _reset() + for content_type: StringName in _ordered_content_types(): + var document: Variant = _value(documents, content_type, null) + _validate_and_index_document(content_type, document) + _validate_cross_references() + _errors.sort_custom(_issue_less) + if not _errors.is_empty(): + _entries_by_type.clear() + _ordered_ids_by_type.clear() + return get_report() + + +func is_valid() -> bool: + return _errors.is_empty() and _entries_by_type.size() == DEFAULT_PATHS.size() + + +func get_report() -> Dictionary: + var counts: Dictionary = {} + for content_type: StringName in _ordered_content_types(): + counts[String(content_type)] = get_ids(content_type).size() + return { + &"valid": is_valid(), + &"schema_version": SCHEMA_VERSION, + &"counts": counts, + &"error_count": _errors.size(), + &"errors_truncated": _errors_truncated, + &"errors": _errors.duplicate(true), + } + + +func get_ids(content_type: StringName) -> Array[StringName]: + var source: Variant = _ordered_ids_by_type.get(content_type, []) + var result: Array[StringName] = [] + if source is Array: + for raw_id: Variant in source: + result.append(StringName(raw_id)) + return result + + +func get_entry(content_type: StringName, entry_id: StringName) -> Dictionary: + var index: Dictionary = _entries_by_type.get(content_type, {}) + var entry: Variant = index.get(entry_id, {}) + return entry.duplicate(true) if entry is Dictionary else {} + + +func get_guest_class_ids() -> Array[StringName]: + return get_ids(&"guest_classes") + + +func get_species_ids() -> Array[StringName]: + return get_ids(&"species") + + +func get_staff_candidate_ids() -> Array[StringName]: + return get_ids(&"staff_candidates") + + +func get_unique_character_ids() -> Array[StringName]: + return get_ids(&"unique_characters") + + +func build_guest_definition(class_id: StringName, species_id: StringName) -> GuestDefinition: + if not is_valid(): + return null + var guest_class := get_entry(&"guest_classes", class_id) + var species := get_entry(&"species", species_id) + if guest_class.is_empty() or species.is_empty(): + return null + + var preference_weights := _merged_integer_maps( + guest_class[&"preferences"], species[&"preference_modifiers"] + ) + var need_rates := _merged_integer_maps( + guest_class[&"need_rates"], species[&"need_rate_modifiers"] + ) + for need_id: StringName in NEED_IDS: + need_rates[need_id] = maxi(int(need_rates.get(need_id, 0)), 0) + var tags := _merged_name_arrays(guest_class[&"tags"], species[&"tags"]) + var wealth_multiplier_bp := int(species[&"wealth_multiplier_bp"]) + var combined_id := StringName("%s_%s" % [class_id, species_id]) + return GuestDefinitionType.new().configure(combined_id, { + &"adult_verified": true, + &"base_patience": clampi( + int(guest_class[&"base_patience"]) + int(species[&"patience_bonus"]), 0, 100 + ), + &"credit_limit_gold": int(guest_class[&"credit_limit_gold"]), + &"group_size_weights": guest_class[&"group_size_weights"], + &"legal_sensitivity": int(guest_class[&"legal_sensitivity"]), + &"minimum_quality": int(guest_class[&"minimum_quality"]), + &"need_rates": need_rates, + &"preference_weights": preference_weights, + &"quality_sensitivity": int(guest_class[&"quality_sensitivity"]), + &"risk_tolerance": clampi( + int(guest_class[&"risk_tolerance"]) + int(species[&"risk_bonus"]), 0, 100 + ), + &"schedule_profile": StringName(guest_class[&"schedule_profile"]), + &"tags": tags, + &"wealth_min": int(guest_class[&"wealth_min"]) * wealth_multiplier_bp / 10000, + &"wealth_max": int(guest_class[&"wealth_max"]) * wealth_multiplier_bp / 10000, + }) + + +func build_all_guest_definitions() -> Array[GuestDefinition]: + var result: Array[GuestDefinition] = [] + if not is_valid(): + return result + for class_id: StringName in get_guest_class_ids(): + for species_id: StringName in get_species_ids(): + var definition := build_guest_definition(class_id, species_id) + if definition != null: + result.append(definition) + return result + + +func staff_hire_payload(candidate_id: StringName) -> Dictionary: + var candidate := get_entry(&"staff_candidates", candidate_id) + if candidate.is_empty(): + return {} + return { + &"staff_id": candidate_id, + &"role_id": StringName(candidate[&"role_id"]), + &"daily_wage": int(candidate[&"daily_wage"]), + &"signing_cost": int(candidate[&"signing_cost"]), + &"preferred_shift": StringName(candidate[&"preferred_shift"]), + &"adult_verified": bool(candidate[&"adult_verified"]), + &"age": int(candidate[&"age"]), + &"options": { + &"daily_wage": int(candidate[&"daily_wage"]), + &"employment_kind": &"FREE_HIRE", + &"permitted_task_tags": _to_name_array(candidate[&"permitted_task_tags"]), + &"role_tags": [StringName(candidate[&"role_id"])], + &"skills": _name_keyed_integer_map(candidate[&"skills"]), + }, + } + + +func _reset() -> void: + _entries_by_type.clear() + _ordered_ids_by_type.clear() + _errors.clear() + _errors_truncated = false + + +func _validate_path_set(paths: Dictionary) -> bool: + if paths.size() > MAX_CONTAINER_FIELDS: + _add_error(&"catalog", &"", &"paths", &"field_limit_exceeded", "") + return false + var seen: Dictionary = {} + for raw_key: Variant in paths: + if not _is_string_variant(raw_key): + _add_error(&"catalog", &"", &"paths", &"invalid_key_type", "") + continue + var content_type := StringName(raw_key) + if seen.has(content_type): + _add_error(&"catalog", &"", &"paths", &"canonical_key_collision", String(content_type)) + continue + seen[content_type] = true + if not DEFAULT_PATHS.has(content_type): + _add_error(&"catalog", &"", &"paths", &"unknown_content_type", String(content_type)) + for content_type: StringName in _ordered_content_types(): + if not seen.has(content_type): + _add_error(&"catalog", &"", &"paths", &"missing_content_type", String(content_type)) + return _errors.is_empty() + + +func _read_json_document(path: String, content_type: StringName) -> Variant: + if path.length() > MAX_PATH_LENGTH \ + or not path.begins_with("res://") \ + or path.get_extension().to_lower() != "json" \ + or _contains_control_character(path) \ + or _path_has_traversal(path): + _add_error(content_type, &"", &"resource_path", &"invalid_resource_path", path) + return null + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + _add_error(content_type, &"", &"resource_path", &"resource_unreadable", path) + return null + if file.get_length() > MAX_DOCUMENT_BYTES: + _add_error(content_type, &"", &"document", &"document_too_large", path) + return null + var source_text := file.get_as_text() + var structure_issue := _validate_json_key_structure(source_text) + if not structure_issue.is_empty(): + _add_error(content_type, &"", &"document", structure_issue, path) + return null + var parsed: Variant = JSON.parse_string(source_text) + if not parsed is Dictionary: + _add_error(content_type, &"", &"document", &"invalid_json_document", path) + return null + return parsed + + +func _validate_json_key_structure(source_text: String) -> StringName: + _json_scan_text = source_text + _json_scan_index = 0 + _json_scan_depth = 0 + _json_scan_tokens = 0 + _json_scan_failure = &"" + _json_last_string = "" + _json_skip_whitespace() + if not _json_scan_value(): + if _json_scan_failure.is_empty(): + _json_scan_failure = &"invalid_json_document" + else: + _json_skip_whitespace() + if _json_scan_index != _json_scan_text.length(): + _json_scan_failure = &"invalid_json_document" + var result := _json_scan_failure + _json_scan_text = "" + _json_last_string = "" + return result + + +func _json_scan_value() -> bool: + _json_skip_whitespace() + _json_scan_tokens += 1 + if _json_scan_tokens > MAX_JSON_TOKENS or _json_scan_index >= _json_scan_text.length(): + _json_scan_failure = &"json_structure_limit_exceeded" + return false + var character := _json_scan_text[_json_scan_index] + if character == "{": + return _json_scan_object() + if character == "[": + return _json_scan_array() + if character == "\"": + return _json_scan_string_token() + while _json_scan_index < _json_scan_text.length(): + character = _json_scan_text[_json_scan_index] + if character in [",", "]", "}"] or character in [" ", "\t", "\r", "\n"]: + break + _json_scan_index += 1 + return true + + +func _json_scan_object() -> bool: + _json_scan_depth += 1 + if _json_scan_depth > MAX_JSON_DEPTH: + _json_scan_failure = &"json_structure_limit_exceeded" + return false + _json_scan_index += 1 + _json_skip_whitespace() + if _json_try_consume("}"): + _json_scan_depth -= 1 + return true + var seen_keys: Dictionary = {} + while _json_scan_index < _json_scan_text.length(): + if not _json_scan_string_token(): + return false + var key := _json_last_string + if key.length() > MAX_JSON_KEY_LENGTH: + _json_scan_failure = &"json_structure_limit_exceeded" + return false + if seen_keys.has(key): + _json_scan_failure = &"duplicate_json_key" + return false + seen_keys[key] = true + _json_skip_whitespace() + if not _json_consume(":"): + return false + if not _json_scan_value(): + return false + _json_skip_whitespace() + if _json_try_consume("}"): + _json_scan_depth -= 1 + return true + if not _json_consume(","): + return false + _json_skip_whitespace() + _json_scan_failure = &"invalid_json_document" + return false + + +func _json_scan_array() -> bool: + _json_scan_depth += 1 + if _json_scan_depth > MAX_JSON_DEPTH: + _json_scan_failure = &"json_structure_limit_exceeded" + return false + _json_scan_index += 1 + _json_skip_whitespace() + if _json_try_consume("]"): + _json_scan_depth -= 1 + return true + while _json_scan_index < _json_scan_text.length(): + if not _json_scan_value(): + return false + _json_skip_whitespace() + if _json_try_consume("]"): + _json_scan_depth -= 1 + return true + if not _json_consume(","): + return false + _json_skip_whitespace() + _json_scan_failure = &"invalid_json_document" + return false + + +func _json_scan_string_token() -> bool: + if not _json_consume("\""): + return false + var token_start := _json_scan_index - 1 + while _json_scan_index < _json_scan_text.length(): + var character := _json_scan_text[_json_scan_index] + if character == "\\": + _json_scan_index += 2 + continue + _json_scan_index += 1 + if character == "\"": + var raw_token := _json_scan_text.substr(token_start, _json_scan_index - token_start) + var decoded: Variant = JSON.parse_string(raw_token) + if not decoded is String: + _json_scan_failure = &"invalid_json_document" + return false + _json_last_string = decoded + return true + _json_scan_failure = &"invalid_json_document" + return false + + +func _json_skip_whitespace() -> void: + while _json_scan_index < _json_scan_text.length() \ + and _json_scan_text[_json_scan_index] in [" ", "\t", "\r", "\n"]: + _json_scan_index += 1 + + +func _json_consume(character: String) -> bool: + if _json_scan_index >= _json_scan_text.length() \ + or _json_scan_text[_json_scan_index] != character: + _json_scan_failure = &"invalid_json_document" + return false + _json_scan_index += 1 + return true + + +func _json_try_consume(character: String) -> bool: + if _json_scan_index >= _json_scan_text.length() \ + or _json_scan_text[_json_scan_index] != character: + return false + _json_scan_index += 1 + return true + + +func _validate_and_index_document(content_type: StringName, raw_document: Variant) -> void: + if not raw_document is Dictionary: + _add_error(content_type, &"", &"document", &"missing_document", "") + return + var document := raw_document as Dictionary + if document.size() > MAX_CONTAINER_FIELDS: + _add_error(content_type, &"", &"document", &"field_limit_exceeded", "") + return + _reject_unknown_fields(content_type, &"", document, DOCUMENT_FIELDS) + var schema_value: Variant = _value(document, &"schema_version", null) + if not _is_integer_variant(schema_value) or int(schema_value) != SCHEMA_VERSION: + _add_error(content_type, &"", &"schema_version", &"unsupported_schema", "") + var declared_type: Variant = _value(document, &"content_type", null) + if not _is_string_variant(declared_type) or StringName(declared_type) != content_type: + _add_error(content_type, &"", &"content_type", &"content_type_mismatch", "") + var raw_entries: Variant = _value(document, &"entries", null) + if not raw_entries is Array: + _add_error(content_type, &"", &"entries", &"entries_not_array", "") + return + var entries := raw_entries as Array + if entries.size() > MAX_ENTRIES_PER_DOCUMENT: + _add_error(content_type, &"", &"entries", &"entry_limit_exceeded", "") + return + if entries.size() != int(REQUIRED_COUNTS[content_type]): + _add_error(content_type, &"", &"entries", &"mvp_count_mismatch", "") + + var index: Dictionary = {} + var ordered_ids: Array[StringName] = [] + for raw_entry: Variant in entries: + if not raw_entry is Dictionary: + _add_error(content_type, &"", &"entry", &"entry_not_object", "") + continue + var entry := raw_entry as Dictionary + if entry.size() > MAX_CONTAINER_FIELDS: + _add_error(content_type, &"", &"entry", &"field_limit_exceeded", "") + continue + var raw_entry_id: Variant = _value(entry, &"id", null) + var entry_id := StringName(raw_entry_id) if _is_string_variant(raw_entry_id) else &"" + if entry_id.is_empty() or not _is_snake_case_id(String(entry_id)): + _add_error(content_type, entry_id, &"id", &"invalid_id", "") + continue + if index.has(entry_id): + _add_error(content_type, entry_id, &"id", &"duplicate_id", "") + continue + var valid_before := _errors.size() + _validate_entry(content_type, entry_id, entry) + if _errors.size() == valid_before: + index[entry_id] = _normalized_entry(entry) + ordered_ids.append(entry_id) + ordered_ids.sort_custom(_name_less) + if ordered_ids != REQUIRED_IDS[content_type]: + _add_error(content_type, &"", &"id", &"mvp_id_set_mismatch", "") + _entries_by_type[content_type] = index + _ordered_ids_by_type[content_type] = ordered_ids + + +func _validate_entry(content_type: StringName, entry_id: StringName, entry: Dictionary) -> void: + var display_name: Variant = _value(entry, &"display_name", null) + if not _is_string_variant(display_name) or not _is_safe_display_name(String(display_name)): + _add_error(content_type, entry_id, &"display_name", &"missing_display_name", "") + match content_type: + &"guest_classes": + _validate_guest_class(entry_id, entry) + &"species": + _validate_species(entry_id, entry) + &"staff_candidates": + _validate_staff(entry_id, entry) + &"unique_characters": + _validate_unique(entry_id, entry) + + +func _validate_guest_class(entry_id: StringName, entry: Dictionary) -> void: + var content_type := &"guest_classes" + _reject_unknown_fields(content_type, entry_id, entry, GUEST_CLASS_FIELDS) + _validate_bounded_int_fields(content_type, entry_id, entry, [ + &"base_patience", &"legal_sensitivity", &"minimum_quality", + &"quality_sensitivity", &"risk_tolerance", + ], 0, 100) + var raw_wealth_min: Variant = _value(entry, &"wealth_min", null) + var raw_wealth_max: Variant = _value(entry, &"wealth_max", null) + var wealth_min := int(raw_wealth_min) if _is_integer_variant(raw_wealth_min) else -1 + var wealth_max := int(raw_wealth_max) if _is_integer_variant(raw_wealth_max) else -1 + if wealth_min < 0 or wealth_max < wealth_min or wealth_max > MAX_GOLD_VALUE: + _add_error(content_type, entry_id, &"wealth_min", &"invalid_wealth_range", "") + for field_id: StringName in [&"credit_limit_gold"]: + var field_value: Variant = _value(entry, field_id, null) + if not _is_integer_variant(field_value) or int(field_value) < 0 or int(field_value) > MAX_GOLD_VALUE: + _add_error(content_type, entry_id, field_id, &"negative_value", "") + var raw_schedule: Variant = _value(entry, &"schedule_profile", null) + var schedule := StringName(raw_schedule) if _is_string_variant(raw_schedule) else &"" + if schedule.is_empty() or not SCHEDULE_PROFILE_IDS.has(schedule): + _add_error(content_type, entry_id, &"schedule_profile", &"unknown_shift", "") + _validate_name_array(content_type, entry_id, &"tags", _value(entry, &"tags", null), GUEST_TAGS) + _validate_integer_map(content_type, entry_id, &"preferences", _value(entry, &"preferences", null), PREFERENCE_IDS, -100, 100, false) + _validate_integer_map(content_type, entry_id, &"need_rates", _value(entry, &"need_rates", null), NEED_IDS, 0, 100, true) + _validate_group_weights(content_type, entry_id, _value(entry, &"group_size_weights", null)) + + +func _validate_species(entry_id: StringName, entry: Dictionary) -> void: + var content_type := &"species" + _reject_unknown_fields(content_type, entry_id, entry, SPECIES_FIELDS) + var adult_age: Variant = _value(entry, &"adult_age", null) + if not _is_integer_variant(adult_age) or int(adult_age) < 18 or int(adult_age) > MAX_AGE: + _add_error(content_type, entry_id, &"adult_age", &"adult_age_required", "") + var raw_wealth_multiplier: Variant = _value(entry, &"wealth_multiplier_bp", null) + var wealth_multiplier := int(raw_wealth_multiplier) if _is_integer_variant(raw_wealth_multiplier) else 0 + if wealth_multiplier < 2500 or wealth_multiplier > 40000: + _add_error(content_type, entry_id, &"wealth_multiplier_bp", &"multiplier_out_of_range", "") + _validate_bounded_int_fields(content_type, entry_id, entry, [&"patience_bonus", &"risk_bonus"], -100, 100) + _validate_name_array(content_type, entry_id, &"tags", _value(entry, &"tags", null), SPECIES_TAGS) + var species_tags := _to_name_array(_value(entry, &"tags", [])) + var identity_tag_count := 0 + for identity_tag: StringName in [&"beastfolk", &"demonfolk", &"elf", &"human"]: + identity_tag_count += int(species_tags.has(identity_tag)) + if not species_tags.has(entry_id) or identity_tag_count != 1: + _add_error(content_type, entry_id, &"tags", &"species_identity_mismatch", "") + _validate_integer_map(content_type, entry_id, &"preference_modifiers", _value(entry, &"preference_modifiers", null), PREFERENCE_IDS, -100, 100, false) + _validate_integer_map(content_type, entry_id, &"need_rate_modifiers", _value(entry, &"need_rate_modifiers", null), NEED_IDS, -100, 100, false) + + +func _validate_staff(entry_id: StringName, entry: Dictionary) -> void: + var content_type := &"staff_candidates" + _reject_unknown_fields(content_type, entry_id, entry, STAFF_FIELDS) + _validate_adult(content_type, entry_id, entry) + _validate_employment_fields(content_type, entry_id, entry) + _validate_name_array(content_type, entry_id, &"permitted_task_tags", _value(entry, &"permitted_task_tags", null), TASK_TAG_IDS) + if not String(entry_id).begins_with("staff_"): + _add_error(content_type, entry_id, &"id", &"invalid_namespace", "staff_") + var traits := _to_name_array(_value(entry, &"traits", [])) + if traits.has(&"unique") or traits.has(&"unsellable"): + _add_error(content_type, entry_id, &"traits", &"reserved_trait", "") + + +func _validate_unique(entry_id: StringName, entry: Dictionary) -> void: + var content_type := &"unique_characters" + _reject_unknown_fields(content_type, entry_id, entry, UNIQUE_FIELDS) + _validate_adult(content_type, entry_id, entry) + _validate_employment_fields(content_type, entry_id, entry, false) + var raw_kind: Variant = _value(entry, &"character_kind", null) + var kind := StringName(raw_kind) if _is_string_variant(raw_kind) else &"" + if kind.is_empty() or not UNIQUE_KINDS.has(kind): + _add_error(content_type, entry_id, &"character_kind", &"unknown_character_kind", "") + elif not String(entry_id).begins_with("%s_" % String(kind)): + _add_error(content_type, entry_id, &"id", &"invalid_namespace", "%s_" % String(kind)) + var raw_counterpart_id: Variant = _value(entry, &"counterpart_id", null) + var counterpart_id := StringName(raw_counterpart_id) if _is_string_variant(raw_counterpart_id) else &"" + if counterpart_id.is_empty() or counterpart_id == entry_id: + _add_error(content_type, entry_id, &"counterpart_id", &"invalid_counterpart", "") + var traits := _to_name_array(_value(entry, &"traits", [])) + if not traits.has(&"unique") or not traits.has(&"unsellable"): + _add_error(content_type, entry_id, &"traits", &"unique_safety_traits_required", "") + var effects: Variant = _value(entry, &"mechanical_effects", null) + if not effects is Dictionary or effects.is_empty(): + _add_error(content_type, entry_id, &"mechanical_effects", &"missing_mechanical_effect", "") + elif effects.size() > 16: + _add_error(content_type, entry_id, &"mechanical_effects", &"effect_limit_exceeded", "") + else: + var seen_effects: Dictionary = {} + for raw_key: Variant in effects: + var effect_id := StringName(raw_key) if _is_string_variant(raw_key) else &"" + if effect_id.is_empty() or seen_effects.has(effect_id): + _add_error(content_type, entry_id, &"mechanical_effects", &"canonical_key_collision", String(effect_id)) + continue + seen_effects[effect_id] = true + var effect_value: Variant = effects[raw_key] + if not MECHANICAL_EFFECT_IDS.has(effect_id) \ + or not _mechanical_effect_value_is_valid(effect_id, effect_value): + _add_error(content_type, entry_id, &"mechanical_effects", &"invalid_mechanical_effect", String(raw_key)) + var expected_effects: Array[StringName] = HEROINE_EFFECT_IDS if kind == &"heroine" else RIVAL_EFFECT_IDS + for expected_effect_id: StringName in expected_effects: + if not seen_effects.has(expected_effect_id): + _add_error(content_type, entry_id, &"mechanical_effects", &"missing_mechanical_effect", String(expected_effect_id)) + for effect_id: StringName in seen_effects: + if not expected_effects.has(effect_id): + _add_error(content_type, entry_id, &"mechanical_effects", &"effect_kind_mismatch", String(effect_id)) + + +func _validate_adult(content_type: StringName, entry_id: StringName, entry: Dictionary) -> void: + var verified: Variant = _value(entry, &"adult_verified", null) + if not verified is bool or not bool(verified): + _add_error(content_type, entry_id, &"adult_verified", &"adult_verification_required", "") + var age: Variant = _value(entry, &"age", null) + if not _is_integer_variant(age) or int(age) < 18 or int(age) > MAX_AGE: + _add_error(content_type, entry_id, &"age", &"adult_age_required", "") + + +func _validate_employment_fields( + content_type: StringName, + entry_id: StringName, + entry: Dictionary, + require_costs: bool = true +) -> void: + var raw_role_id: Variant = _value(entry, &"role_id", null) + var role_id := StringName(raw_role_id) if _is_string_variant(raw_role_id) else &"" + if role_id.is_empty() or not ROLE_IDS.has(role_id): + _add_error(content_type, entry_id, &"role_id", &"unknown_role", "") + var raw_shift_id: Variant = _value(entry, &"preferred_shift", null) + var shift_id := StringName(raw_shift_id) if _is_string_variant(raw_shift_id) else &"" + if shift_id.is_empty() or not SHIFT_IDS.has(shift_id): + _add_error(content_type, entry_id, &"preferred_shift", &"unknown_shift", "") + if require_costs: + for field_id: StringName in [&"daily_wage", &"signing_cost"]: + var cost: Variant = _value(entry, field_id, null) + if not _is_integer_variant(cost) or int(cost) < 0 or int(cost) > MAX_GOLD_VALUE: + _add_error(content_type, entry_id, field_id, &"negative_value", "") + _validate_name_array(content_type, entry_id, &"traits", _value(entry, &"traits", null), TRAIT_IDS) + _validate_integer_map(content_type, entry_id, &"skills", _value(entry, &"skills", null), SKILL_IDS, 0, 100, true) + + +func _validate_cross_references() -> void: + var unique_index: Dictionary = _entries_by_type.get(&"unique_characters", {}) + for entry_id: StringName in get_unique_character_ids(): + var entry: Dictionary = unique_index[entry_id] + var counterpart_id := StringName(entry[&"counterpart_id"]) + if not unique_index.has(counterpart_id): + _add_error(&"unique_characters", entry_id, &"counterpart_id", &"missing_counterpart", String(counterpart_id)) + continue + var counterpart: Dictionary = unique_index[counterpart_id] + if StringName(counterpart[&"counterpart_id"]) != entry_id: + _add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_not_reciprocal", String(counterpart_id)) + if StringName(counterpart[&"character_kind"]) == StringName(entry[&"character_kind"]): + _add_error(&"unique_characters", entry_id, &"character_kind", &"counterpart_kind_conflict", String(counterpart_id)) + var staff_index: Dictionary = _entries_by_type.get(&"staff_candidates", {}) + for unique_id: StringName in unique_index: + if staff_index.has(unique_id): + _add_error(&"unique_characters", unique_id, &"id", &"runtime_actor_id_collision", "") + + +func _validate_bounded_int_fields( + content_type: StringName, + entry_id: StringName, + entry: Dictionary, + fields: Array, + minimum: int, + maximum: int +) -> void: + for raw_field: Variant in fields: + var field_id := StringName(raw_field) + var value: Variant = _value(entry, field_id, null) + if not _is_integer_variant(value) or int(value) < minimum or int(value) > maximum: + _add_error(content_type, entry_id, field_id, &"integer_out_of_range", "") + + +func _validate_name_array( + content_type: StringName, + entry_id: StringName, + field_id: StringName, + raw_values: Variant, + allowed_values: Array[StringName] +) -> void: + if not raw_values is Array or raw_values.is_empty() or raw_values.size() > 32: + _add_error(content_type, entry_id, field_id, &"invalid_name_array", "") + return + var seen: Dictionary = {} + for raw_value: Variant in raw_values: + var value := StringName(raw_value) if _is_string_variant(raw_value) else &"" + if value.is_empty() or not allowed_values.has(value): + _add_error(content_type, entry_id, field_id, &"unknown_tag", String(value)) + elif seen.has(value): + _add_error(content_type, entry_id, field_id, &"duplicate_tag", String(value)) + seen[value] = true + + +func _validate_integer_map( + content_type: StringName, + entry_id: StringName, + field_id: StringName, + raw_map: Variant, + allowed_keys: Array[StringName], + minimum: int, + maximum: int, + require_all: bool +) -> void: + if not raw_map is Dictionary or raw_map.size() > allowed_keys.size(): + _add_error(content_type, entry_id, field_id, &"invalid_integer_map", "") + return + var seen: Dictionary = {} + for raw_key: Variant in raw_map: + var key := StringName(raw_key) if _is_string_variant(raw_key) else &"" + if not allowed_keys.has(key): + _add_error(content_type, entry_id, field_id, &"unknown_map_key", String(key)) + continue + if seen.has(key): + _add_error(content_type, entry_id, field_id, &"canonical_key_collision", String(key)) + seen[key] = true + var value: Variant = raw_map[raw_key] + if not _is_integer_variant(value) or int(value) < minimum or int(value) > maximum: + _add_error(content_type, entry_id, field_id, &"map_value_out_of_range", String(key)) + if require_all: + for required_key: StringName in allowed_keys: + if not seen.has(required_key): + _add_error(content_type, entry_id, field_id, &"missing_map_key", String(required_key)) + + +func _validate_group_weights(content_type: StringName, entry_id: StringName, raw_map: Variant) -> void: + if not raw_map is Dictionary or raw_map.is_empty() or raw_map.size() > 4: + _add_error(content_type, entry_id, &"group_size_weights", &"invalid_group_weights", "") + return + var total := 0 + var seen: Dictionary = {} + for raw_key: Variant in raw_map: + var key_text := String(raw_key) if _is_string_variant(raw_key) else "" + if key_text not in ["1", "2", "3", "4"] or seen.has(key_text): + _add_error(content_type, entry_id, &"group_size_weights", &"invalid_group_size", key_text) + continue + seen[key_text] = true + var value: Variant = raw_map[raw_key] + if not _is_integer_variant(value) or int(value) <= 0: + _add_error(content_type, entry_id, &"group_size_weights", &"invalid_group_weight", key_text) + else: + total += int(value) + if total != 100: + _add_error(content_type, entry_id, &"group_size_weights", &"group_weights_not_100", str(total)) + + +func _reject_unknown_fields( + content_type: StringName, + entry_id: StringName, + value: Dictionary, + allowed_fields: Array[StringName] +) -> void: + if value.size() > MAX_CONTAINER_FIELDS: + _add_error(content_type, entry_id, &"fields", &"field_limit_exceeded", "") + return + var canonical: Dictionary = {} + for raw_key: Variant in value: + if not _is_string_variant(raw_key): + _add_error(content_type, entry_id, &"fields", &"invalid_key_type", "") + continue + var key := StringName(raw_key) + if canonical.has(key): + _add_error(content_type, entry_id, key, &"canonical_key_collision", String(key)) + canonical[key] = true + if not allowed_fields.has(key): + _add_error(content_type, entry_id, key, &"unknown_field", String(key)) + + +func _normalized_entry(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array[String] = [] + for raw_key: Variant in source: + keys.append(String(raw_key)) + keys.sort() + for key: String in keys: + var value: Variant = source.get(key, source.get(StringName(key))) + result[StringName(key)] = _deep_normalize(value) + return result + + +func _deep_normalize(value: Variant) -> Variant: + if value is Dictionary: + var result: Dictionary = {} + var keys: Array[String] = [] + for raw_key: Variant in value: + keys.append(String(raw_key)) + keys.sort() + for key: String in keys: + result[StringName(key)] = _deep_normalize(value.get(key, value.get(StringName(key)))) + return result + if value is Array: + var result: Array = [] + for item: Variant in value: + result.append(_deep_normalize(item)) + return result + if value is float and value == floor(value): + return int(value) + return value + + +func _merged_integer_maps(first: Variant, second: Variant) -> Dictionary: + var result: Dictionary = {} + for source: Variant in [first, second]: + if not source is Dictionary: + continue + for raw_key: Variant in source: + var key := StringName(raw_key) + result[key] = int(result.get(key, 0)) + int(source[raw_key]) + return result + + +func _merged_name_arrays(first: Variant, second: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + for source: Variant in [first, second]: + if not source is Array: + continue + for raw_value: Variant in source: + var value := StringName(raw_value) + if not result.has(value): + result.append(value) + result.sort_custom(_name_less) + return result + + +func _to_name_array(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if source is Array: + for raw_value: Variant in source: + result.append(StringName(raw_value)) + result.sort_custom(_name_less) + return result + + +func _name_keyed_integer_map(source: Variant) -> Dictionary: + var result: Dictionary = {} + if source is Dictionary: + for raw_key: Variant in source: + result[StringName(raw_key)] = int(source[raw_key]) + return result + + +func _add_error( + content_type: StringName, + entry_id: StringName, + field_id: StringName, + code: StringName, + detail: String +) -> void: + if _errors.size() >= MAX_ERRORS: + _errors_truncated = true + return + _errors.append({ + &"code": code, + &"content_type": content_type, + &"detail": detail.left(256), + &"entry_id": entry_id, + &"field": field_id, + }) + + +func _ordered_content_types() -> Array[StringName]: + return [&"guest_classes", &"species", &"staff_candidates", &"unique_characters"] + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _is_integer_variant(value: Variant) -> bool: + return value is int or (value is float and is_finite(value) and value == floor(value)) + + +static func _is_string_variant(value: Variant) -> bool: + return value is String or value is StringName + + +static func _is_snake_case_id(value: String) -> bool: + if value.is_empty() or value.length() > MAX_ID_LENGTH or value[0] < "a" or value[0] > "z": + return false + for index in value.length(): + var character := value[index] + if not ((character >= "a" and character <= "z") or character == "_" or (character >= "0" and character <= "9")): + return false + return not value.ends_with("_") and not "__" in value + + +static func _is_safe_display_name(value: String) -> bool: + if value.is_empty() or value.length() > MAX_DISPLAY_NAME_LENGTH or value != value.strip_edges(): + return false + return not _contains_control_character(value) + + +static func _contains_control_character(value: String) -> bool: + for index in value.length(): + var code := value.unicode_at(index) + if code < 32 or code == 127: + return true + return false + + +static func _path_has_traversal(path: String) -> bool: + for segment: String in path.trim_prefix("res://").split("/", false): + if segment == "." or segment == "..": + return true + return false + + +static func _mechanical_effect_value_is_valid(effect_id: StringName, raw_value: Variant) -> bool: + if not _is_integer_variant(raw_value): + return false + var value := int(raw_value) + if value < 0 or value > MAX_MECHANICAL_EFFECT_VALUE: + return false + if effect_id == &"assigned_floor_patience_loss_multiplier_bp": + return value >= 2500 and value <= 30_000 + if effect_id in [&"base_shield_attack", &"night_shield_attack_bonus"]: + return value <= 100 + return false + + +static func _name_less(a: StringName, b: StringName) -> bool: + return String(a) < String(b) + + +static func _issue_less(a: Dictionary, b: Dictionary) -> bool: + var left := "%s|%s|%s|%s|%s" % [a[&"content_type"], a[&"entry_id"], a[&"field"], a[&"code"], a[&"detail"]] + var right := "%s|%s|%s|%s|%s" % [b[&"content_type"], b[&"entry_id"], b[&"field"], b[&"code"], b[&"detail"]] + return left < right diff --git a/scripts/data/mvp_content_catalog.gd.uid b/scripts/data/mvp_content_catalog.gd.uid new file mode 100644 index 0000000..f683c13 --- /dev/null +++ b/scripts/data/mvp_content_catalog.gd.uid @@ -0,0 +1 @@ +uid://nw4dttch8rt4 diff --git a/scripts/defense/defense_service.gd b/scripts/defense/defense_service.gd new file mode 100644 index 0000000..ed36951 --- /dev/null +++ b/scripts/defense/defense_service.gd @@ -0,0 +1,2129 @@ +class_name DefenseService +extends RefCounted + +## Deterministic, round-based Raid and defense aggregate. +## +## The service owns combat state only. Resource costs and final consequences are +## explicit callback boundaries, which lets SimulationRuntime include them in a +## larger transaction without giving this service access to another domain. + +signal raid_created(raid_id: StringName) +signal raid_state_changed(raid_id: StringName, old_state: int, new_state: int, reason: StringName) +signal active_skill_applied(raid_id: StringName, action_id: StringName, round_number: int) +signal round_resolved(raid_id: StringName, resolution: Dictionary) +signal raid_resolved(raid_id: StringName, outcome: Dictionary) + +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 1 +const DEFAULT_ROUND_MINUTES := 5 +const OPERATION_ROUND := &"ROUND" +const OPERATION_RETREAT := &"RETREAT" +const OPERATION_CANCEL := &"CANCEL" +const VALID_OPERATION_KINDS := [OPERATION_ROUND, OPERATION_RETREAT, OPERATION_CANCEL] +const MAX_METADATA_DEPTH := 32 + +enum RaidState { + PREPARING, + ACTIVE, + BETWEEN_WAVES, + DEFENDED, + BREACHED, + DEFENDERS_RETREATED, + CANCELLED, +} + +enum CombatantState { + ACTIVE, + INCAPACITATED, + RETREATED, +} + +enum AssetKind { + DOOR, + DEFENSE_DEVICE, + OBJECTIVE, +} + +const TERMINAL_STATES := [ + RaidState.DEFENDED, + RaidState.BREACHED, + RaidState.DEFENDERS_RETREATED, + RaidState.CANCELLED, +] + +const VALID_RETREAT_POLICIES := [ + &"HOLD", + &"MANUAL", + &"THRESHOLD", + &"PROTECT_AND_RETREAT", +] + +const VALID_SKILL_KINDS := [ + &"RALLY", + &"POWER_STRIKE", + &"HEAL", + &"FORTIFY", +] + +var current_minute := 0 +var last_error := "" + +var _raids: Dictionary = {} +var _round_roll := Callable() +var _skill_boundary := Callable() +var _outcome_boundary := Callable() +var _rng_boundary := Callable() + + +func configure_callbacks( + round_roll: Callable = Callable(), + skill_boundary: Callable = Callable(), + outcome_boundary: Callable = Callable(), + rng_boundary: Callable = Callable() +) -> DefenseService: + # `round_roll(key, context)` is a pure keyed function by default: the same + # key must always return the same value. Stateful RNG providers must also + # supply `rng_boundary(context, phase, token)` so failed resolutions restore + # their cursor. Boundary phases are snapshot, commit and rollback. + _round_roll = round_roll + _skill_boundary = skill_boundary + _outcome_boundary = outcome_boundary + _rng_boundary = rng_boundary + return self + + +func get_raid(raid_id: StringName) -> Dictionary: + if not _raids.has(raid_id): + return {} + return (_raids[raid_id] as Dictionary).duplicate(true) + + +func get_raid_ids() -> Array[StringName]: + return _sorted_keys(_raids) + + +func get_active_raid_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for raid_id: StringName in _sorted_keys(_raids): + var raid: Dictionary = _raids[raid_id] + if int(raid[&"state"]) in [RaidState.ACTIVE, RaidState.BETWEEN_WAVES]: + result.append(raid_id) + return result + + +func create_raid( + raid_id: StringName, + absolute_minute: int, + definition: Dictionary +) -> CommandResult: + if raid_id.is_empty() or absolute_minute < 0: + return _invalid("Raid requires an ID and non-negative creation minute.") + if absolute_minute < current_minute: + return _invalid("Raid creation time cannot precede the Defense clock.") + if _raids.has(raid_id): + return _duplicate("Raid", raid_id) + + var entry_points := _sorted_unique_names(_value(definition, &"entry_points", [])) + var objective_ids := _sorted_unique_names(_value(definition, &"objective_ids", [])) + if entry_points.is_empty() or objective_ids.is_empty(): + return _invalid("Raid requires at least one entry point and objective.") + var waves_result := _normalize_waves(raid_id, definition, entry_points, objective_ids) + if not bool(waves_result.get(&"ok", false)): + return _invalid(String(waves_result.get(&"error", "Raid waves are invalid."))) + + var retreat_policy := StringName(_value(definition, &"retreat_policy", &"HOLD")) + if not VALID_RETREAT_POLICIES.has(retreat_policy): + return _invalid("Raid retreat policy is invalid.") + var max_duration_minutes := int(_value(definition, &"max_duration_minutes", 120)) + var round_minutes := int(_value(definition, &"round_minutes", DEFAULT_ROUND_MINUTES)) + var defender_retreat_threshold := int(_value(definition, &"defender_retreat_threshold", 20)) + if max_duration_minutes <= 0 or round_minutes <= 0: + return _invalid("Raid duration and round duration must be positive.") + if defender_retreat_threshold < 0 or defender_retreat_threshold > 100: + return _invalid("Defender retreat threshold must be within 0..100.") + + var raid := { + &"raid_id": raid_id, + &"source_id": StringName(_value(definition, &"source_id", &"")), + &"state": RaidState.PREPARING, + &"created_at": absolute_minute, + &"started_at": -1, + &"finished_at": -1, + &"state_changed_at": absolute_minute, + &"current_wave_index": 0, + &"current_round": 0, + &"elapsed_minutes": 0, + &"max_duration_minutes": max_duration_minutes, + &"round_minutes": round_minutes, + &"retreat_policy": retreat_policy, + &"defender_retreat_threshold": defender_retreat_threshold, + &"terrain_bonus": int(_value(definition, &"terrain_bonus", 0)), + &"command_bonus": int(_value(definition, &"command_bonus", 0)), + &"surprise_penalty": maxi(int(_value(definition, &"surprise_penalty", 0)), 0), + &"multiple_entry_penalty": maxi(int(_value(definition, &"multiple_entry_penalty", 5)), 0), + &"entry_points": entry_points, + &"objective_ids": objective_ids, + &"waves": waves_result[&"waves"], + &"combatants": {}, + &"assets": {}, + &"queued_skills": {}, + &"applied_skill_ids": {}, + &"resolved_rounds": {}, + &"result": &"PENDING", + &"result_reason": &"", + &"revision": 0, + } + var report := _validate_raid(raid) + if not bool(report[&"ok"]): + return _invalid("Raid definition violates invariants.", report) + _raids[raid_id] = raid + current_minute = maxi(current_minute, absolute_minute) + last_error = "" + raid_created.emit(raid_id) + return _success({&"raid_id": raid_id, &"wave_count": (raid[&"waves"] as Array).size()}) + + +func register_combatant( + raid_id: StringName, + actor_id: StringName, + side: StringName, + combat: int, + max_health: int, + options: Dictionary = {} +) -> CommandResult: + var raid := _raid_ref(raid_id) + if raid.is_empty(): + return _not_found("Raid", raid_id) + if int(raid[&"state"]) != RaidState.PREPARING: + return _invalid("Combatants can only be registered while a Raid is PREPARING.") + if actor_id.is_empty() or (raid[&"combatants"] as Dictionary).has(actor_id): + return _duplicate("Defense combatant", actor_id) + if side not in [&"DEFENDER", &"ATTACKER"] or combat < 0 or max_health <= 0: + return _invalid("Defense combatant schema is invalid.") + var health := int(_value(options, &"health", max_health)) + var armor := int(_value(options, &"armor", 0)) + var fatigue := int(_value(options, &"fatigue", 0)) + if health <= 0 or health > max_health or armor < 0 or fatigue < 0 or fatigue > 100: + return _invalid("Defense combatant health, armor or fatigue is invalid.") + var wave_id := StringName(_value(options, &"wave_id", &"")) + if side == &"ATTACKER" and not _wave_exists(raid, wave_id): + return _invalid("Attacker must reference an existing Raid wave.") + if side == &"DEFENDER" and not wave_id.is_empty(): + return _invalid("Defenders cannot be bound to a single attacker wave.") + var entry_point_id := StringName(_value(options, &"entry_point_id", &"")) + if not entry_point_id.is_empty() and not (raid[&"entry_points"] as Array).has(entry_point_id): + return _invalid("Combatant entry point is outside the Raid definition.") + if side == &"ATTACKER" and not entry_point_id.is_empty(): + var attacker_wave := _wave_by_id(raid, wave_id) + if attacker_wave.is_empty() or not (attacker_wave[&"entry_points"] as Array).has(entry_point_id): + return _invalid("Attacker entry point is outside its assigned wave.") + (raid[&"combatants"] as Dictionary)[actor_id] = { + &"actor_id": actor_id, + &"side": side, + &"wave_id": wave_id, + &"entry_point_id": entry_point_id, + &"combat": combat, + &"armor": armor, + &"max_health": max_health, + &"health": health, + # Baseline percentages (retreat policy and reports) are relative to the + # combatant's full capacity, even when a wounded actor enters the Raid. + &"initial_health": max_health, + &"fatigue": fatigue, + &"target_order": int(_value(options, &"target_order", 75)), + &"state": CombatantState.ACTIVE, + &"damage_dealt": 0, + &"damage_taken": 0, + &"revision": 0, + } + raid[&"revision"] = int(raid[&"revision"]) + 1 + last_error = "" + return _success({&"raid_id": raid_id, &"actor_id": actor_id, &"side": side}) + + +func register_asset( + raid_id: StringName, + asset_id: StringName, + kind: StringName, + max_integrity: int, + defense: int, + options: Dictionary = {} +) -> CommandResult: + var raid := _raid_ref(raid_id) + if raid.is_empty(): + return _not_found("Raid", raid_id) + if int(raid[&"state"]) != RaidState.PREPARING: + return _invalid("Defense assets can only be registered while a Raid is PREPARING.") + if asset_id.is_empty() or (raid[&"assets"] as Dictionary).has(asset_id): + return _duplicate("Defense asset", asset_id) + var kind_index := AssetKind.keys().find(String(kind)) + if kind_index < 0 or max_integrity <= 0 or defense < 0: + return _invalid("Defense asset schema is invalid.") + var is_objective := (raid[&"objective_ids"] as Array).has(asset_id) + if is_objective != (kind_index == AssetKind.OBJECTIVE): + return _invalid("Raid objective IDs must be registered as OBJECTIVE assets.") + var integrity := int(_value(options, &"integrity", max_integrity)) + if integrity <= 0 or integrity > max_integrity: + return _invalid("Defense asset integrity is invalid.") + var covered_entries := _sorted_unique_names(_value(options, &"entry_points", [])) + for entry_id: StringName in covered_entries: + if not (raid[&"entry_points"] as Array).has(entry_id): + return _invalid("Defense asset references an unknown entry point.") + var default_order := 100 + if kind_index == AssetKind.DOOR: + default_order = 10 + elif kind_index == AssetKind.DEFENSE_DEVICE: + default_order = 50 + (raid[&"assets"] as Dictionary)[asset_id] = { + &"asset_id": asset_id, + &"kind": kind_index, + &"max_integrity": max_integrity, + &"integrity": integrity, + # Keep the full-capacity baseline so pre-damaged objectives can correctly + # trigger PROTECT_AND_RETREAT. + &"initial_integrity": max_integrity, + &"defense": defense, + &"armor": maxi(int(_value(options, &"armor", 0)), 0), + &"terrain_bonus": int(_value(options, &"terrain_bonus", 0)), + &"target_order": int(_value(options, &"target_order", default_order)), + &"entry_points": covered_entries, + &"revision": 0, + } + raid[&"revision"] = int(raid[&"revision"]) + 1 + last_error = "" + return _success({&"raid_id": raid_id, &"asset_id": asset_id, &"kind": AssetKind.keys()[kind_index]}) + + +func start_raid(raid_id: StringName, absolute_minute: int) -> CommandResult: + var raid := _raid_ref(raid_id) + if raid.is_empty(): + return _not_found("Raid", raid_id) + if int(raid[&"state"]) != RaidState.PREPARING: + return _invalid("Only a PREPARING Raid can start.") + if absolute_minute < int(raid[&"created_at"]): + return _invalid("Raid start time cannot precede creation.") + if absolute_minute < current_minute: + return _invalid("Raid start time cannot precede the Defense clock.") + for objective_id: StringName in raid[&"objective_ids"]: + if not (raid[&"assets"] as Dictionary).has(objective_id): + return _invalid("Every Raid objective needs a registered OBJECTIVE asset.", { + &"objective_id": objective_id, + }) + var old_state := int(raid[&"state"]) + raid[&"state"] = RaidState.ACTIVE + var first_wave := _active_wave(raid) + first_wave[&"state"] = &"ACTIVE" + raid[&"started_at"] = absolute_minute + raid[&"state_changed_at"] = absolute_minute + raid[&"revision"] = int(raid[&"revision"]) + 1 + current_minute = maxi(current_minute, absolute_minute) + last_error = "" + raid_state_changed.emit(raid_id, old_state, RaidState.ACTIVE, &"RAID_STARTED") + return _success({&"raid_id": raid_id, &"state": &"ACTIVE", &"wave_id": _active_wave(raid)[&"wave_id"]}) + + +func set_retreat_policy( + raid_id: StringName, + policy: StringName, + threshold: int = -1 +) -> CommandResult: + var raid := _raid_ref(raid_id) + if raid.is_empty(): + return _not_found("Raid", raid_id) + if _is_terminal(int(raid[&"state"])): + return _invalid("A completed Raid cannot change retreat policy.") + if not VALID_RETREAT_POLICIES.has(policy): + return _invalid("Retreat policy is invalid.") + if threshold >= 0 and threshold > 100: + return _invalid("Retreat threshold must be within 0..100.") + raid[&"retreat_policy"] = policy + if threshold >= 0: + raid[&"defender_retreat_threshold"] = threshold + raid[&"revision"] = int(raid[&"revision"]) + 1 + last_error = "" + return _success({ + &"raid_id": raid_id, + &"retreat_policy": policy, + &"defender_retreat_threshold": raid[&"defender_retreat_threshold"], + }) + + +func queue_active_skill( + raid_id: StringName, + action_id: StringName, + actor_id: StringName, + kind: StringName, + options: Dictionary = {} +) -> CommandResult: + var raid := _raid_ref(raid_id) + if raid.is_empty(): + return _not_found("Raid", raid_id) + if int(raid[&"state"]) != RaidState.ACTIVE: + return _invalid("Active skills can only be queued during an active wave.") + if action_id.is_empty() or actor_id.is_empty() or not VALID_SKILL_KINDS.has(kind): + return _invalid("Active skill schema is invalid.") + if (raid[&"applied_skill_ids"] as Dictionary).has(action_id): + var applied_replay := _success({&"raid_id": raid_id, &"action_id": action_id}) + applied_replay.is_replay = true + return applied_replay + if (raid[&"queued_skills"] as Dictionary).has(action_id): + var queued_replay := _success({&"raid_id": raid_id, &"action_id": action_id}) + queued_replay.is_replay = true + return queued_replay + var combatant: Dictionary = (raid[&"combatants"] as Dictionary).get(actor_id, {}) + if combatant.is_empty() or combatant[&"side"] != &"DEFENDER" \ + or int(combatant[&"state"]) != CombatantState.ACTIVE: + return _invalid("Active skill actor must be an active defender.") + var potency := int(_value(options, &"potency", 0)) + if potency < 0 or potency > 100: + return _invalid("Active skill potency must be within 0..100.") + var target_id := StringName(_value(options, &"target_id", &"")) + if kind == &"HEAL": + var target_combatant: Dictionary = (raid[&"combatants"] as Dictionary).get(target_id, {}) + if target_combatant.is_empty() or target_combatant[&"side"] != &"DEFENDER": + return _invalid("HEAL requires a defender target.") + elif kind == &"FORTIFY": + var target_asset: Dictionary = (raid[&"assets"] as Dictionary).get(target_id, {}) + if target_asset.is_empty() or int(target_asset[&"integrity"]) <= 0: + return _invalid("FORTIFY requires an intact defense asset target.") + var raw_metadata: Variant = _value(options, &"metadata", {}) + if not raw_metadata is Dictionary: + return _invalid("Active skill metadata must be a Dictionary.") + var metadata_result := _canonicalize_metadata(raw_metadata) + if not bool(metadata_result[&"ok"]): + return _invalid("Active skill metadata is not deterministic or saveable.", metadata_result) + (raid[&"queued_skills"] as Dictionary)[action_id] = { + &"action_id": action_id, + &"actor_id": actor_id, + &"kind": kind, + &"potency": potency, + &"target_id": target_id, + &"round_number": int(raid[&"current_round"]) + 1, + &"metadata": metadata_result[&"value"], + } + raid[&"revision"] = int(raid[&"revision"]) + 1 + last_error = "" + return _success({&"raid_id": raid_id, &"action_id": action_id, &"round_number": int(raid[&"current_round"]) + 1}) + + +func resolve_next_round( + raid_id: StringName, + resolution_id: StringName, + absolute_minute: int +) -> CommandResult: + var live := _raid_ref(raid_id) + if live.is_empty(): + return _not_found("Raid", raid_id) + if resolution_id.is_empty(): + return _invalid("Defense round requires a resolution_id.") + if (live[&"resolved_rounds"] as Dictionary).has(resolution_id): + return _replay_resolution(live, resolution_id, OPERATION_ROUND) + if int(live[&"state"]) != RaidState.ACTIVE: + return _invalid("Defense round can only resolve during an active wave.") + if absolute_minute < current_minute: + return _invalid("Defense round time cannot precede the Defense clock.") + var earliest_round_minute := int(live[&"state_changed_at"]) + int(live[&"round_minutes"]) + if absolute_minute < earliest_round_minute: + return _invalid("Defense round must observe its configured round interval.", { + &"earliest_minute": earliest_round_minute, + }) + + var candidate := live.duplicate(true) + var wave := _active_wave(candidate) + if wave.is_empty(): + return _invalid("Raid has no active wave.") + var round_number := int(candidate[&"current_round"]) + 1 + var skills := _skills_for_round(candidate, round_number) + var external_skills := _validate_and_apply_skill_boundaries(raid_id, skills, round_number) + if not bool(external_skills[&"ok"]): + return _invalid("Active skill boundary rejected the round.", external_skills) + var rng_context := { + &"raid_id": raid_id, + &"resolution_id": resolution_id, + &"wave_id": wave[&"wave_id"], + &"round_number": round_number, + } + var rng_transaction := _begin_rng_transaction(rng_context) + if not bool(rng_transaction[&"ok"]): + _rollback_skill_boundaries(external_skills.get(&"applied", [])) + return _invalid("Defense RNG snapshot boundary rejected the round.", rng_transaction) + + var skill_effects := _apply_skills(candidate, skills) + var score := _calculate_scores(candidate, wave, skill_effects) + var defense_roll := _deterministic_roll(raid_id, wave[&"wave_id"], round_number, &"DEFENSE") + var attack_roll := _deterministic_roll(raid_id, wave[&"wave_id"], round_number, &"ATTACK") + var defense_margin := int(score[&"defense_score"]) - int(score[&"attack_score"]) \ + + int(floor(float(defense_roll - 50) / 5.0)) + var attack_margin := int(score[&"attack_score"]) - int(score[&"defense_score"]) \ + + int(floor(float(attack_roll - 50) / 5.0)) + var damage_to_threat := clampi( + 5 + int(floor(float(defense_margin) / 8.0)) + int(skill_effects[&"damage_bonus"]), + 1, + maxi(_remaining_attacker_durability(candidate, wave), 1) + ) + var damage_to_defense := clampi( + 5 + int(floor(float(attack_margin) / 8.0)), + 0, + 50 + ) + # Explicit attackers and the off-screen aggregate are a single damage pool. + # Damage is allocated to stable-ID ordered explicit actors first and only the + # remainder reduces the aggregate. Applying the full amount to both would + # double-count casualties and make on-screen/off-screen simulation diverge. + var attacker_damage := _apply_damage_to_attackers(candidate, wave, damage_to_threat) + var defense_damage := _apply_damage_to_defense(candidate, wave, damage_to_defense) + _apply_round_fatigue(candidate) + wave[&"rounds_completed"] = int(wave[&"rounds_completed"]) + 1 + candidate[&"current_round"] = round_number + candidate[&"elapsed_minutes"] = int(candidate[&"elapsed_minutes"]) + int(candidate[&"round_minutes"]) + candidate[&"state_changed_at"] = absolute_minute + _mark_skills_applied(candidate, skills, round_number) + + var old_state := int(candidate[&"state"]) + var resolution_reason := _evaluate_round_outcome(candidate, wave, absolute_minute) + var new_state := int(candidate[&"state"]) + var round_payload := { + &"operation_kind": OPERATION_ROUND, + &"resolution_id": resolution_id, + &"raid_id": raid_id, + &"wave_id": wave[&"wave_id"], + &"round_number": round_number, + &"absolute_minute": absolute_minute, + &"defense_score": score[&"defense_score"], + &"attack_score": score[&"attack_score"], + &"score_components": score[&"components"], + &"defense_roll": defense_roll, + &"attack_roll": attack_roll, + &"damage_to_threat": damage_to_threat, + &"remaining_threat": wave[&"remaining_threat"], + &"damage_applied_to_threat": attacker_damage[&"applied_total"], + &"damage_mitigated_by_attackers": attacker_damage[&"mitigated_total"], + &"unapplied_damage_to_threat": attacker_damage[&"unapplied_damage"], + &"attacker_damage": attacker_damage[&"explicit_damage"], + &"aggregate_damage": attacker_damage[&"aggregate_damage"], + &"damage_to_defense": damage_to_defense, + &"defense_damage": defense_damage, + &"skill_ids": _skill_ids(skills), + &"skill_effects": (skill_effects[&"changes"] as Array).duplicate(true), + &"state": StringName(RaidState.keys()[new_state]), + &"reason": resolution_reason, + } + + var outcome_boundary := {&"ok": true} + if _is_terminal(new_state): + var outcome_payload := _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_ROUND) + outcome_boundary = _apply_outcome_boundary(outcome_payload) + if not bool(outcome_boundary[&"ok"]): + _rollback_rng_transaction(rng_transaction) + _rollback_skill_boundaries(external_skills.get(&"applied", [])) + return _invalid("Raid outcome boundary rejected the round.", outcome_boundary) + + (candidate[&"resolved_rounds"] as Dictionary)[resolution_id] = round_payload.duplicate(true) + candidate[&"revision"] = int(candidate[&"revision"]) + 1 + var invariant_report := _validate_raid(candidate) + if not bool(invariant_report[&"ok"]): + _rollback_outcome_boundary(outcome_boundary) + _rollback_rng_transaction(rng_transaction) + _rollback_skill_boundaries(external_skills.get(&"applied", [])) + return _invalid("Resolved defense round violates invariants.", invariant_report) + var rng_commit := _commit_rng_transaction(rng_transaction) + if not bool(rng_commit[&"ok"]): + _rollback_outcome_boundary(outcome_boundary) + _rollback_rng_transaction(rng_transaction) + _rollback_skill_boundaries(external_skills.get(&"applied", [])) + return _invalid("Defense RNG commit boundary rejected the round.", rng_commit) + + _raids[raid_id] = candidate + current_minute = maxi(current_minute, absolute_minute) + last_error = "" + for skill: Dictionary in skills: + active_skill_applied.emit(raid_id, skill[&"action_id"], round_number) + round_resolved.emit(raid_id, round_payload.duplicate(true)) + if new_state != old_state: + raid_state_changed.emit(raid_id, old_state, new_state, resolution_reason) + if _is_terminal(new_state): + raid_resolved.emit(raid_id, _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_ROUND)) + return _success(round_payload) + + +func begin_next_wave(raid_id: StringName, absolute_minute: int) -> CommandResult: + var raid := _raid_ref(raid_id) + if raid.is_empty(): + return _not_found("Raid", raid_id) + if int(raid[&"state"]) != RaidState.BETWEEN_WAVES: + return _invalid("Raid is not waiting between waves.") + if absolute_minute < int(raid[&"state_changed_at"]): + return _invalid("Wave start time cannot move backwards.") + if absolute_minute < current_minute: + return _invalid("Wave start time cannot precede the Defense clock.") + var next_index := int(raid[&"current_wave_index"]) + 1 + if next_index >= (raid[&"waves"] as Array).size(): + return _invalid("Raid has no remaining wave.") + var old_state := int(raid[&"state"]) + raid[&"current_wave_index"] = next_index + raid[&"state"] = RaidState.ACTIVE + var next_wave := _active_wave(raid) + next_wave[&"state"] = &"ACTIVE" + raid[&"state_changed_at"] = absolute_minute + raid[&"revision"] = int(raid[&"revision"]) + 1 + current_minute = maxi(current_minute, absolute_minute) + last_error = "" + raid_state_changed.emit(raid_id, old_state, RaidState.ACTIVE, &"NEXT_WAVE_STARTED") + return _success({ + &"raid_id": raid_id, + &"wave_id": _active_wave(raid)[&"wave_id"], + &"wave_index": next_index, + }) + + +func retreat_defenders( + raid_id: StringName, + resolution_id: StringName, + absolute_minute: int, + reason: StringName = &"PLAYER_RETREAT" +) -> CommandResult: + var live := _raid_ref(raid_id) + if live.is_empty(): + return _not_found("Raid", raid_id) + if resolution_id.is_empty(): + return _invalid("Defender retreat requires a resolution_id.") + if (live[&"resolved_rounds"] as Dictionary).has(resolution_id): + return _replay_resolution(live, resolution_id, OPERATION_RETREAT) + if int(live[&"state"]) not in [RaidState.ACTIVE, RaidState.BETWEEN_WAVES]: + return _invalid("Defenders can only retreat from an active Raid.") + if absolute_minute < int(live[&"state_changed_at"]): + return _invalid("Retreat time cannot move backwards.") + if absolute_minute < current_minute: + return _invalid("Retreat time cannot precede the Defense clock.") + var candidate := live.duplicate(true) + for actor_id: StringName in _sorted_keys(candidate[&"combatants"]): + var combatant: Dictionary = candidate[&"combatants"][actor_id] + if combatant[&"side"] == &"DEFENDER" and int(combatant[&"state"]) == CombatantState.ACTIVE: + combatant[&"state"] = CombatantState.RETREATED + combatant[&"revision"] = int(combatant[&"revision"]) + 1 + var wave := _active_wave(candidate) + if not wave.is_empty(): + wave[&"state"] = &"RETREATED" + wave[&"result_reason"] = reason + _set_terminal_state(candidate, RaidState.DEFENDERS_RETREATED, absolute_minute, reason) + var outcome := _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_RETREAT) + var boundary := _apply_outcome_boundary(outcome) + if not bool(boundary[&"ok"]): + return _invalid("Raid retreat outcome boundary rejected the command.", boundary) + var payload := outcome.duplicate(true) + payload[&"resolution_id"] = resolution_id + (candidate[&"resolved_rounds"] as Dictionary)[resolution_id] = payload + candidate[&"revision"] = int(candidate[&"revision"]) + 1 + var report := _validate_raid(candidate) + if not bool(report[&"ok"]): + _rollback_outcome_boundary(boundary) + return _invalid("Defender retreat violates invariants.", report) + _raids[raid_id] = candidate + current_minute = maxi(current_minute, absolute_minute) + last_error = "" + raid_state_changed.emit(raid_id, int(live[&"state"]), RaidState.DEFENDERS_RETREATED, reason) + raid_resolved.emit(raid_id, outcome) + return _success(payload) + + +func cancel_raid( + raid_id: StringName, + absolute_minute: int, + reason: StringName = &"CANCELLED", + resolution_id: StringName = &"" +) -> CommandResult: + var live := _raid_ref(raid_id) + if live.is_empty(): + return _not_found("Raid", raid_id) + if resolution_id.is_empty(): + resolution_id = StringName("%s:cancel" % raid_id) + if (live[&"resolved_rounds"] as Dictionary).has(resolution_id): + return _replay_resolution(live, resolution_id, OPERATION_CANCEL) + if _is_terminal(int(live[&"state"])): + return _invalid("Raid is already terminal.") + if absolute_minute < int(live[&"state_changed_at"]): + return _invalid("Raid cancellation time cannot move backwards.") + if absolute_minute < current_minute: + return _invalid("Raid cancellation time cannot precede the Defense clock.") + var candidate := live.duplicate(true) + var old_state := int(candidate[&"state"]) + var wave := _active_wave(candidate) + if not wave.is_empty(): + wave[&"state"] = &"CANCELLED" + wave[&"result_reason"] = reason + _set_terminal_state(candidate, RaidState.CANCELLED, absolute_minute, reason) + var outcome := _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_CANCEL) + var boundary := _apply_outcome_boundary(outcome) + if not bool(boundary[&"ok"]): + return _invalid("Raid cancellation outcome boundary rejected the command.", boundary) + (candidate[&"resolved_rounds"] as Dictionary)[resolution_id] = outcome.duplicate(true) + candidate[&"revision"] = int(candidate[&"revision"]) + 1 + var report := _validate_raid(candidate) + if not bool(report[&"ok"]): + _rollback_outcome_boundary(boundary) + return _invalid("Raid cancellation violates invariants.", report) + _raids[raid_id] = candidate + current_minute = maxi(current_minute, absolute_minute) + last_error = "" + raid_state_changed.emit(raid_id, old_state, RaidState.CANCELLED, reason) + raid_resolved.emit(raid_id, outcome.duplicate(true)) + return _success(outcome) + + +func advance_time(absolute_minute: int) -> CommandResult: + if absolute_minute < current_minute: + return _invalid("Defense time cannot move backwards.") + current_minute = absolute_minute + last_error = "" + return _success({&"current_minute": current_minute}) + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + if current_minute < 0: + errors.append("Defense current minute is negative.") + for raid_id: StringName in _sorted_keys(_raids): + var raid: Dictionary = _raids[raid_id] + if raid.get(&"raid_id", &"") != raid_id: + errors.append("Raid dictionary key mismatch: %s" % raid_id) + var report := _validate_raid(raid) + if not bool(report[&"ok"]): + for error: String in report[&"errors"]: + errors.append("%s: %s" % [raid_id, error]) + var clock_report := _validate_raid_clock(raid, current_minute) + if not bool(clock_report[&"ok"]): + for error: String in clock_report[&"errors"]: + errors.append("%s: %s" % [raid_id, error]) + return {&"ok": errors.is_empty(), &"errors": errors} + + +func create_snapshot() -> Dictionary: + var raids: Array[Dictionary] = [] + for raid_id: StringName in _sorted_keys(_raids): + raids.append(_raid_to_snapshot(_raids[raid_id])) + return { + &"version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"raids": raids, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION: + last_error = "Unsupported Defense snapshot version." + return false + var next_minute := int(_value(snapshot, &"current_minute", -1)) + var raw_raids: Variant = _value(snapshot, &"raids", null) + if next_minute < 0 or not raw_raids is Array: + last_error = "Malformed Defense snapshot root." + return false + var parsed_raids: Dictionary = {} + for raw_raid: Variant in raw_raids: + if not raw_raid is Dictionary: + last_error = "Malformed Raid snapshot entry." + return false + var parsed := _raid_from_snapshot(raw_raid) + if not bool(parsed.get(&"ok", false)): + last_error = String(parsed.get(&"error", "Invalid Raid snapshot.")) + return false + var raid: Dictionary = parsed[&"raid"] + var raid_id: StringName = raid[&"raid_id"] + if parsed_raids.has(raid_id): + last_error = "Duplicate Raid snapshot ID." + return false + var report := _validate_raid(raid) + if not bool(report[&"ok"]): + last_error = "Raid snapshot violates invariants: %s" % str(report[&"errors"]) + return false + var clock_report := _validate_raid_clock(raid, next_minute) + if not bool(clock_report[&"ok"]): + last_error = "Raid snapshot exceeds Defense current minute: %s" % str(clock_report[&"errors"]) + return false + parsed_raids[raid_id] = raid + _raids = parsed_raids + current_minute = next_minute + last_error = "" + return true + + +func _normalize_waves( + raid_id: StringName, + definition: Dictionary, + entry_points: Array[StringName], + objective_ids: Array[StringName] +) -> Dictionary: + var raw_waves: Variant = _value(definition, &"waves", []) + if not raw_waves is Array: + return {&"ok": false, &"error": "Raid waves must be an Array."} + if raw_waves.is_empty() and int(_value(definition, &"threat_strength", 0)) > 0: + raw_waves = [{ + &"wave_id": StringName("%s_wave_01" % raid_id), + &"threat_strength": int(_value(definition, &"threat_strength", 0)), + }] + if raw_waves.is_empty(): + return {&"ok": false, &"error": "Raid requires at least one wave."} + var result: Array[Dictionary] = [] + var seen: Dictionary = {} + for index: int in range(raw_waves.size()): + var raw: Variant = raw_waves[index] + if not raw is Dictionary: + return {&"ok": false, &"error": "Raid wave entry must be a Dictionary."} + var wave_id := StringName(_value(raw, &"wave_id", StringName("%s_wave_%02d" % [raid_id, index + 1]))) + var threat := int(_value(raw, &"threat_strength", 0)) + var retreat_threshold := int(_value(raw, &"retreat_threshold", 0)) + var max_rounds := int(_value(raw, &"max_rounds", 12)) + if wave_id.is_empty() or seen.has(wave_id) or threat <= 0 or max_rounds <= 0 \ + or retreat_threshold < 0 or retreat_threshold > 100: + return {&"ok": false, &"error": "Raid wave ID, strength, retreat threshold or duration is invalid."} + var wave_entries := _sorted_unique_names(_value(raw, &"entry_points", entry_points)) + var wave_objectives := _sorted_unique_names(_value(raw, &"objective_ids", objective_ids)) + if wave_entries.is_empty() or wave_objectives.is_empty(): + return {&"ok": false, &"error": "Every Raid wave requires entry points and objectives."} + for entry_id: StringName in wave_entries: + if not entry_points.has(entry_id): + return {&"ok": false, &"error": "Raid wave references an unknown entry point."} + for objective_id: StringName in wave_objectives: + if not objective_ids.has(objective_id): + return {&"ok": false, &"error": "Raid wave references an unknown objective."} + seen[wave_id] = true + result.append({ + &"wave_id": wave_id, + &"threat_strength": threat, + &"remaining_threat": threat, + &"retreat_threshold": retreat_threshold, + &"max_rounds": max_rounds, + &"rounds_completed": 0, + &"entry_points": wave_entries, + &"objective_ids": wave_objectives, + &"state": &"PENDING", + &"result_reason": &"", + }) + return {&"ok": true, &"waves": result} + + +func _calculate_scores(raid: Dictionary, wave: Dictionary, skill_effects: Dictionary) -> Dictionary: + var defender_combat := 0 + var fatigue_penalty := 0 + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var combatant: Dictionary = raid[&"combatants"][actor_id] + if combatant[&"side"] != &"DEFENDER" or int(combatant[&"state"]) != CombatantState.ACTIVE: + continue + var effective_combat := _effective_combat_power( + int(combatant[&"combat"]), + int(combatant[&"health"]), + int(combatant[&"max_health"]) + ) + defender_combat += effective_combat + fatigue_penalty += int(floor(float(effective_combat * int(combatant[&"fatigue"])) / 100.0)) + var asset_defense := 0 + var terrain_bonus := int(raid[&"terrain_bonus"]) + for asset_id: StringName in _sorted_keys(raid[&"assets"]): + var asset: Dictionary = raid[&"assets"][asset_id] + if int(asset[&"integrity"]) <= 0 or not _asset_relevant_to_wave(asset, wave): + continue + asset_defense += int(floor(float(int(asset[&"defense"]) * int(asset[&"integrity"])) / float(int(asset[&"max_integrity"])))) + terrain_bonus += int(asset[&"terrain_bonus"]) + var attacker_combat := 0 + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var attacker: Dictionary = raid[&"combatants"][actor_id] + if attacker[&"side"] != &"ATTACKER" or attacker[&"wave_id"] != wave[&"wave_id"] \ + or int(attacker[&"state"]) != CombatantState.ACTIVE: + continue + attacker_combat += _effective_combat_power( + int(attacker[&"combat"]), + int(attacker[&"health"]), + int(attacker[&"max_health"]) + ) + # Off-screen attackers use the exact same effective-power formula as explicit + # actors. Their max health and combat are both the wave's initial threat, so + # the result is the remaining aggregate threat without a separate rule path. + var aggregate_attacker_combat := _effective_combat_power( + int(wave[&"threat_strength"]), + int(wave[&"remaining_threat"]), + int(wave[&"threat_strength"]) + ) + var multi_entry_penalty := maxi((wave[&"entry_points"] as Array).size() - 1, 0) * int(raid[&"multiple_entry_penalty"]) + var surprise_penalty := int(raid[&"surprise_penalty"]) if int(wave[&"rounds_completed"]) == 0 else 0 + var defense_score := defender_combat + asset_defense + terrain_bonus + int(raid[&"command_bonus"]) \ + + int(skill_effects[&"score_bonus"]) - fatigue_penalty - multi_entry_penalty - surprise_penalty + var attack_score := aggregate_attacker_combat + attacker_combat + return { + &"defense_score": maxi(defense_score, 0), + &"attack_score": maxi(attack_score, 0), + &"components": { + &"defender_combat": defender_combat, + &"asset_defense": asset_defense, + &"terrain_bonus": terrain_bonus, + &"command_bonus": int(raid[&"command_bonus"]), + &"skill_bonus": int(skill_effects[&"score_bonus"]), + &"fatigue_penalty": fatigue_penalty, + &"multiple_entry_penalty": multi_entry_penalty, + &"surprise_penalty": surprise_penalty, + &"attacker_combat": attacker_combat, + &"aggregate_attacker_combat": aggregate_attacker_combat, + &"remaining_threat": int(wave[&"remaining_threat"]), + }, + } + + +func _effective_combat_power(combat: int, health: int, max_health: int) -> int: + if combat <= 0 or health <= 0 or max_health <= 0: + return 0 + return int(floor(float(combat * health) / float(max_health))) + + +func _remaining_attacker_durability(raid: Dictionary, wave: Dictionary) -> int: + var result := maxi(int(wave[&"remaining_threat"]), 0) + for actor_id: StringName in _wave_attacker_ids(raid, wave[&"wave_id"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if int(actor[&"state"]) == CombatantState.ACTIVE: + result += maxi(int(actor[&"health"]), 0) + return result + + +func _apply_damage_to_attackers(raid: Dictionary, wave: Dictionary, damage: int) -> Dictionary: + var remaining := maxi(damage, 0) + var applied_total := 0 + var mitigated_total := 0 + var changes: Array[Dictionary] = [] + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + if remaining <= 0: + break + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] != &"ATTACKER" or actor[&"wave_id"] != wave[&"wave_id"] \ + or int(actor[&"state"]) != CombatantState.ACTIVE: + continue + var mitigated := mini(remaining, int(actor[&"armor"])) + var penetrating := maxi(remaining - mitigated, 0) + var actual := mini(penetrating, int(actor[&"health"])) + remaining -= mitigated + actual + mitigated_total += mitigated + applied_total += actual + if actual > 0: + actor[&"health"] = int(actor[&"health"]) - actual + actor[&"damage_taken"] = int(actor[&"damage_taken"]) + actual + actor[&"revision"] = int(actor[&"revision"]) + 1 + if int(actor[&"health"]) <= 0: + actor[&"state"] = CombatantState.INCAPACITATED + changes.append({ + &"target_id": actor_id, + &"damage": actual, + &"mitigated": mitigated, + &"state": StringName(CombatantState.keys()[actor[&"state"]]), + }) + var aggregate_damage := mini(remaining, int(wave[&"remaining_threat"])) + wave[&"remaining_threat"] = int(wave[&"remaining_threat"]) - aggregate_damage + remaining -= aggregate_damage + applied_total += aggregate_damage + return { + &"requested": maxi(damage, 0), + &"applied_total": applied_total, + &"mitigated_total": mitigated_total, + &"explicit_damage": changes, + &"aggregate_damage": aggregate_damage, + &"unapplied_damage": remaining, + } + + +func _apply_damage_to_defense(raid: Dictionary, wave: Dictionary, damage: int) -> Dictionary: + if damage <= 0: + return {&"target_type": &"NONE", &"target_id": &"", &"damage": 0} + var targets: Array[Dictionary] = [] + for asset_id: StringName in _sorted_keys(raid[&"assets"]): + var asset: Dictionary = raid[&"assets"][asset_id] + if int(asset[&"integrity"]) > 0 and _asset_relevant_to_wave(asset, wave): + targets.append({ + &"target_type": &"ASSET", + &"target_id": asset_id, + &"target_order": int(asset[&"target_order"]), + }) + # Guards are a real defense layer rather than an unreachable fallback after + # objective destruction. Their default order (75) places them after doors + # and devices but before objectives; callers can override it per actor. + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] == &"DEFENDER" and int(actor[&"state"]) == CombatantState.ACTIVE: + targets.append({ + &"target_type": &"COMBATANT", + &"target_id": actor_id, + &"target_order": int(actor[&"target_order"]), + }) + targets.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if int(a[&"target_order"]) != int(b[&"target_order"]): + return int(a[&"target_order"]) < int(b[&"target_order"]) + if a[&"target_type"] != b[&"target_type"]: + return String(a[&"target_type"]) < String(b[&"target_type"]) + return String(a[&"target_id"]) < String(b[&"target_id"]) + ) + if not targets.is_empty(): + var target_ref: Dictionary = targets[0] + if target_ref[&"target_type"] == &"ASSET": + var target_asset: Dictionary = raid[&"assets"][target_ref[&"target_id"]] + var asset_mitigated := mini(damage, int(target_asset[&"armor"])) + var asset_damage := mini(maxi(damage - asset_mitigated, 0), int(target_asset[&"integrity"])) + if asset_damage > 0: + target_asset[&"integrity"] = int(target_asset[&"integrity"]) - asset_damage + target_asset[&"revision"] = int(target_asset[&"revision"]) + 1 + return { + &"target_type": &"ASSET", + &"target_id": target_asset[&"asset_id"], + &"damage": asset_damage, + &"mitigated": asset_mitigated, + &"destroyed": int(target_asset[&"integrity"]) <= 0, + &"remaining_integrity": target_asset[&"integrity"], + } + var target_actor: Dictionary = raid[&"combatants"][target_ref[&"target_id"]] + var actor_mitigated := mini(damage, int(target_actor[&"armor"])) + var actor_damage := mini(maxi(damage - actor_mitigated, 0), int(target_actor[&"health"])) + if actor_damage > 0: + target_actor[&"health"] = int(target_actor[&"health"]) - actor_damage + target_actor[&"damage_taken"] = int(target_actor[&"damage_taken"]) + actor_damage + target_actor[&"revision"] = int(target_actor[&"revision"]) + 1 + if int(target_actor[&"health"]) <= 0: + target_actor[&"state"] = CombatantState.INCAPACITATED + return { + &"target_type": &"COMBATANT", + &"target_id": target_actor[&"actor_id"], + &"damage": actor_damage, + &"mitigated": actor_mitigated, + &"state": StringName(CombatantState.keys()[target_actor[&"state"]]), + &"remaining_health": target_actor[&"health"], + } + return {&"target_type": &"NONE", &"target_id": &"", &"damage": 0} + + +func _apply_round_fatigue(raid: Dictionary) -> void: + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] == &"DEFENDER" and int(actor[&"state"]) == CombatantState.ACTIVE: + actor[&"fatigue"] = mini(int(actor[&"fatigue"]) + 5, 100) + actor[&"revision"] = int(actor[&"revision"]) + 1 + + +func _evaluate_round_outcome(raid: Dictionary, wave: Dictionary, absolute_minute: int) -> StringName: + for objective_id: StringName in wave[&"objective_ids"]: + var objective: Dictionary = (raid[&"assets"] as Dictionary).get(objective_id, {}) + if objective.is_empty() or int(objective[&"integrity"]) <= 0: + _set_terminal_state(raid, RaidState.BREACHED, absolute_minute, &"OBJECTIVE_DESTROYED") + wave[&"state"] = &"BREACHED" + wave[&"result_reason"] = &"OBJECTIVE_DESTROYED" + return &"OBJECTIVE_DESTROYED" + + if _should_defenders_retreat(raid): + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var defender: Dictionary = raid[&"combatants"][actor_id] + if defender[&"side"] == &"DEFENDER" and int(defender[&"state"]) == CombatantState.ACTIVE: + defender[&"state"] = CombatantState.RETREATED + defender[&"revision"] = int(defender[&"revision"]) + 1 + _set_terminal_state(raid, RaidState.DEFENDERS_RETREATED, absolute_minute, &"RETREAT_POLICY_TRIGGERED") + wave[&"state"] = &"RETREATED" + wave[&"result_reason"] = &"RETREAT_POLICY_TRIGGERED" + return &"RETREAT_POLICY_TRIGGERED" + + var attacker_ids := _wave_attacker_ids(raid, wave[&"wave_id"]) + var initial_total_threat := int(wave[&"threat_strength"]) + var remaining_total_threat := int(wave[&"remaining_threat"]) + var all_attackers_down := true + for actor_id: StringName in attacker_ids: + var attacker: Dictionary = raid[&"combatants"][actor_id] + initial_total_threat += int(attacker[&"combat"]) + remaining_total_threat += _effective_combat_power( + int(attacker[&"combat"]), + int(attacker[&"health"]), + int(attacker[&"max_health"]) + ) + if int(attacker[&"state"]) == CombatantState.ACTIVE: + all_attackers_down = false + var threat_percent := 0 + if initial_total_threat > 0: + threat_percent = int(floor(float(remaining_total_threat * 100) / float(initial_total_threat))) + all_attackers_down = all_attackers_down and int(wave[&"remaining_threat"]) <= 0 + # The Raid-wide duration is a hard terminal boundary, including in a + # multi-wave Raid. It takes precedence over opening another wave. + if int(raid[&"elapsed_minutes"]) >= int(raid[&"max_duration_minutes"]): + _retreat_active_attackers(raid, wave[&"wave_id"]) + wave[&"state"] = &"RESOLVED" + wave[&"result_reason"] = &"RAID_DURATION_EXHAUSTED" + _set_terminal_state(raid, RaidState.DEFENDED, absolute_minute, &"RAID_DURATION_EXHAUSTED") + return &"RAID_DURATION_EXHAUSTED" + var wave_timed_out := int(wave[&"rounds_completed"]) >= int(wave[&"max_rounds"]) + if threat_percent <= int(wave[&"retreat_threshold"]) or all_attackers_down or wave_timed_out: + _retreat_active_attackers(raid, wave[&"wave_id"]) + wave[&"state"] = &"RESOLVED" + var reason := &"THREAT_RETREATED" + if all_attackers_down: + reason = &"ATTACKERS_INCAPACITATED" + elif wave_timed_out: + reason = &"WAVE_DURATION_EXHAUSTED" + wave[&"result_reason"] = reason + if int(raid[&"current_wave_index"]) + 1 >= (raid[&"waves"] as Array).size(): + _set_terminal_state(raid, RaidState.DEFENDED, absolute_minute, reason) + else: + raid[&"state"] = RaidState.BETWEEN_WAVES + raid[&"state_changed_at"] = absolute_minute + return reason + + wave[&"state"] = &"ACTIVE" + return &"ROUND_COMPLETE" + + +func _should_defenders_retreat(raid: Dictionary) -> bool: + var policy: StringName = raid[&"retreat_policy"] + if policy in [&"HOLD", &"MANUAL"]: + return false + var total_initial := 0 + var total_health := 0 + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] != &"DEFENDER": + continue + total_initial += int(actor[&"initial_health"]) + total_health += int(actor[&"health"]) + var health_percent := 0 + if total_initial > 0: + health_percent = int(floor(float(total_health * 100) / float(total_initial))) + if health_percent <= int(raid[&"defender_retreat_threshold"]): + return true + if policy == &"PROTECT_AND_RETREAT": + for objective_id: StringName in raid[&"objective_ids"]: + var objective: Dictionary = raid[&"assets"][objective_id] + var integrity_percent := int(floor(float(int(objective[&"integrity"]) * 100) / float(int(objective[&"initial_integrity"])))) + if integrity_percent <= int(raid[&"defender_retreat_threshold"]): + return true + return false + + +func _set_terminal_state(raid: Dictionary, state: int, absolute_minute: int, reason: StringName) -> void: + raid[&"state"] = state + raid[&"state_changed_at"] = absolute_minute + raid[&"finished_at"] = absolute_minute + raid[&"result"] = StringName(RaidState.keys()[state]) + raid[&"result_reason"] = reason + (raid[&"queued_skills"] as Dictionary).clear() + + +func _retreat_active_attackers(raid: Dictionary, wave_id: StringName) -> void: + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] == &"ATTACKER" and actor[&"wave_id"] == wave_id \ + and int(actor[&"state"]) == CombatantState.ACTIVE: + actor[&"state"] = CombatantState.RETREATED + actor[&"revision"] = int(actor[&"revision"]) + 1 + + +func _skills_for_round(raid: Dictionary, round_number: int) -> Array[Dictionary]: + var skills: Array[Dictionary] = [] + for action_id: StringName in _sorted_keys(raid[&"queued_skills"]): + var skill: Dictionary = raid[&"queued_skills"][action_id] + if int(skill[&"round_number"]) == round_number: + skills.append(skill.duplicate(true)) + return skills + + +func _apply_skills(raid: Dictionary, skills: Array[Dictionary]) -> Dictionary: + var score_bonus := 0 + var damage_bonus := 0 + var changes: Array[Dictionary] = [] + for skill: Dictionary in skills: + var kind: StringName = skill[&"kind"] + var potency := int(skill[&"potency"]) + if kind == &"RALLY": + score_bonus += potency + changes.append({&"action_id": skill[&"action_id"], &"score_bonus": potency}) + elif kind == &"POWER_STRIKE": + damage_bonus += potency + changes.append({&"action_id": skill[&"action_id"], &"damage_bonus": potency}) + elif kind == &"HEAL": + var target: Dictionary = raid[&"combatants"][skill[&"target_id"]] + var old_health := int(target[&"health"]) + target[&"health"] = mini(old_health + potency, int(target[&"max_health"])) + if int(target[&"health"]) > 0 and int(target[&"state"]) == CombatantState.INCAPACITATED: + target[&"state"] = CombatantState.ACTIVE + target[&"revision"] = int(target[&"revision"]) + 1 + changes.append({&"action_id": skill[&"action_id"], &"healed": int(target[&"health"]) - old_health}) + elif kind == &"FORTIFY": + var asset: Dictionary = raid[&"assets"][skill[&"target_id"]] + var old_integrity := int(asset[&"integrity"]) + asset[&"integrity"] = mini(old_integrity + potency, int(asset[&"max_integrity"])) + asset[&"revision"] = int(asset[&"revision"]) + 1 + changes.append({&"action_id": skill[&"action_id"], &"repaired": int(asset[&"integrity"]) - old_integrity}) + return {&"score_bonus": score_bonus, &"damage_bonus": damage_bonus, &"changes": changes} + + +func _mark_skills_applied(raid: Dictionary, skills: Array[Dictionary], round_number: int) -> void: + for skill: Dictionary in skills: + var action_id: StringName = skill[&"action_id"] + (raid[&"queued_skills"] as Dictionary).erase(action_id) + (raid[&"applied_skill_ids"] as Dictionary)[action_id] = round_number + + +func _validate_and_apply_skill_boundaries( + raid_id: StringName, + skills: Array[Dictionary], + round_number: int +) -> Dictionary: + if not _skill_boundary.is_valid() or skills.is_empty(): + return {&"ok": true, &"applied": []} + var validated: Array[Dictionary] = [] + for skill: Dictionary in skills: + var payload := skill.duplicate(true) + payload[&"raid_id"] = raid_id + payload[&"round_number"] = round_number + var validation := _normalize_boundary_result(_skill_boundary.call(payload, &"validate", null)) + if not bool(validation[&"ok"]): + # Validation callbacks may reserve resources. Release every earlier + # validation token when a later skill rejects the batch. A rejecting + # validator may also supply a token for work it needs undone. + if validation.has(&"token") or validation.has("token"): + validated.append({&"payload": payload, &"token": validation.get(&"token", validation.get("token", null))}) + _rollback_skill_boundaries(validated) + return {&"ok": false, &"phase": &"validate", &"action_id": skill[&"action_id"], &"details": validation} + validated.append({&"payload": payload, &"token": validation.get(&"token", null)}) + for index: int in range(validated.size()): + var entry: Dictionary = validated[index] + var application := _normalize_boundary_result(_skill_boundary.call(entry[&"payload"], &"apply", entry[&"token"])) + if not bool(application[&"ok"]): + entry[&"token"] = application.get(&"token", entry[&"token"]) + validated[index] = entry + # All validations were completed before apply. Roll back applied, + # failed, and not-yet-applied reservation tokens exactly once. + _rollback_skill_boundaries(validated) + return {&"ok": false, &"phase": &"apply", &"action_id": entry[&"payload"][&"action_id"], &"details": application} + entry[&"token"] = application.get(&"token", entry[&"token"]) + validated[index] = entry + return {&"ok": true, &"applied": validated} + + +func _rollback_skill_boundaries(raw_applied: Variant) -> void: + if not _skill_boundary.is_valid() or not raw_applied is Array: + return + var applied: Array = raw_applied + for index: int in range(applied.size() - 1, -1, -1): + var entry: Dictionary = applied[index] + _skill_boundary.call(entry[&"payload"], &"rollback", entry.get(&"token", null)) + + +func _apply_outcome_boundary(payload: Dictionary) -> Dictionary: + if not _outcome_boundary.is_valid(): + return {&"ok": true, &"applied": false} + var validation := _normalize_boundary_result(_outcome_boundary.call(payload.duplicate(true), &"validate", null)) + if not bool(validation[&"ok"]): + if validation.has(&"token") or validation.has("token"): + _outcome_boundary.call(payload.duplicate(true), &"rollback", validation.get(&"token", validation.get("token", null))) + return {&"ok": false, &"phase": &"validate", &"details": validation} + var application := _normalize_boundary_result(_outcome_boundary.call(payload.duplicate(true), &"apply", validation.get(&"token", null))) + if not bool(application[&"ok"]): + _outcome_boundary.call(payload.duplicate(true), &"rollback", application.get(&"token", validation.get(&"token", null))) + return {&"ok": false, &"phase": &"apply", &"details": application} + return { + &"ok": true, + &"applied": true, + &"payload": payload.duplicate(true), + &"token": application.get(&"token", validation.get(&"token", null)), + } + + +func _rollback_outcome_boundary(boundary: Dictionary) -> void: + if _outcome_boundary.is_valid() and bool(boundary.get(&"applied", false)): + _outcome_boundary.call(boundary[&"payload"], &"rollback", boundary.get(&"token", null)) + + +func _begin_rng_transaction(context: Dictionary) -> Dictionary: + if not _rng_boundary.is_valid(): + return {&"ok": true, &"enabled": false, &"context": context.duplicate(true), &"token": null} + var snapshot := _normalize_boundary_result( + _rng_boundary.call(context.duplicate(true), &"snapshot", null) + ) + if not bool(snapshot[&"ok"]): + if snapshot.has(&"token") or snapshot.has("token"): + _rng_boundary.call( + context.duplicate(true), + &"rollback", + snapshot.get(&"token", snapshot.get("token", null)) + ) + return {&"ok": false, &"phase": &"snapshot", &"details": snapshot} + return { + &"ok": true, + &"enabled": true, + &"context": context.duplicate(true), + &"token": snapshot.get(&"token", snapshot.get("token", null)), + } + + +func _commit_rng_transaction(transaction: Dictionary) -> Dictionary: + if not bool(transaction.get(&"enabled", false)): + return {&"ok": true} + var result := _normalize_boundary_result(_rng_boundary.call( + (transaction[&"context"] as Dictionary).duplicate(true), + &"commit", + transaction.get(&"token", null) + )) + if not bool(result[&"ok"]): + return {&"ok": false, &"phase": &"commit", &"details": result} + return {&"ok": true} + + +func _rollback_rng_transaction(transaction: Dictionary) -> void: + if _rng_boundary.is_valid() and bool(transaction.get(&"enabled", false)): + _rng_boundary.call( + (transaction[&"context"] as Dictionary).duplicate(true), + &"rollback", + transaction.get(&"token", null) + ) + + +func _make_outcome_payload( + raid: Dictionary, + resolution_id: StringName, + absolute_minute: int, + operation_kind: StringName +) -> Dictionary: + var destroyed_assets: Array[StringName] = [] + var asset_results: Array[Dictionary] = [] + for asset_id: StringName in _sorted_keys(raid[&"assets"]): + var asset: Dictionary = raid[&"assets"][asset_id] + if int(asset[&"integrity"]) <= 0: + destroyed_assets.append(asset_id) + asset_results.append({ + &"asset_id": asset_id, + &"kind": StringName(AssetKind.keys()[int(asset[&"kind"])]), + &"integrity": int(asset[&"integrity"]), + &"max_integrity": int(asset[&"max_integrity"]), + &"destroyed": int(asset[&"integrity"]) <= 0, + }) + var incapacitated: Array[StringName] = [] + var retreated: Array[StringName] = [] + var incapacitated_defenders: Array[StringName] = [] + var incapacitated_attackers: Array[StringName] = [] + var retreated_defenders: Array[StringName] = [] + var retreated_attackers: Array[StringName] = [] + var combatant_results: Array[Dictionary] = [] + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var combatant: Dictionary = raid[&"combatants"][actor_id] + var state := int(combatant[&"state"]) + if state == CombatantState.INCAPACITATED: + incapacitated.append(actor_id) + if combatant[&"side"] == &"DEFENDER": + incapacitated_defenders.append(actor_id) + else: + incapacitated_attackers.append(actor_id) + elif state == CombatantState.RETREATED: + retreated.append(actor_id) + if combatant[&"side"] == &"DEFENDER": + retreated_defenders.append(actor_id) + else: + retreated_attackers.append(actor_id) + combatant_results.append({ + &"actor_id": actor_id, + &"side": combatant[&"side"], + &"state": StringName(CombatantState.keys()[state]), + &"health": int(combatant[&"health"]), + &"max_health": int(combatant[&"max_health"]), + &"fatigue": int(combatant[&"fatigue"]), + &"damage_taken": int(combatant[&"damage_taken"]), + }) + return { + &"operation_kind": operation_kind, + &"resolution_id": resolution_id, + &"raid_id": raid[&"raid_id"], + &"result": raid[&"result"], + &"reason": raid[&"result_reason"], + &"absolute_minute": absolute_minute, + &"rounds_completed": raid[&"current_round"], + &"destroyed_asset_ids": destroyed_assets, + &"incapacitated_actor_ids": incapacitated, + &"retreated_actor_ids": retreated, + &"incapacitated_defender_ids": incapacitated_defenders, + &"incapacitated_attacker_ids": incapacitated_attackers, + &"retreated_defender_ids": retreated_defenders, + &"retreated_attacker_ids": retreated_attackers, + &"combatant_results": combatant_results, + &"asset_results": asset_results, + } + + +func _deterministic_roll( + raid_id: StringName, + wave_id: StringName, + round_number: int, + side: StringName +) -> int: + var key := StringName("%s:%s:%d:%s" % [raid_id, wave_id, round_number, side]) + var context := { + &"raid_id": raid_id, + &"wave_id": wave_id, + &"round_number": round_number, + &"side": side, + } + if _round_roll.is_valid(): + var raw: Variant = _round_roll.call(key, context) + if raw is Dictionary: + raw = raw.get(&"value", raw.get("value", 50)) + if raw is int or raw is float: + return clampi(int(raw), 0, 100) + var accumulator := 17 + for character_index: int in range(String(key).length()): + accumulator = (accumulator * 131 + String(key).unicode_at(character_index)) % 1_000_003 + return accumulator % 101 + + +func _raid_to_snapshot(raid: Dictionary) -> Dictionary: + var snapshot := { + &"raid_id": String(raid[&"raid_id"]), + &"source_id": String(raid[&"source_id"]), + &"state": RaidState.keys()[int(raid[&"state"])], + &"created_at": raid[&"created_at"], + &"started_at": raid[&"started_at"], + &"finished_at": raid[&"finished_at"], + &"state_changed_at": raid[&"state_changed_at"], + &"current_wave_index": raid[&"current_wave_index"], + &"current_round": raid[&"current_round"], + &"elapsed_minutes": raid[&"elapsed_minutes"], + &"max_duration_minutes": raid[&"max_duration_minutes"], + &"round_minutes": raid[&"round_minutes"], + &"retreat_policy": String(raid[&"retreat_policy"]), + &"defender_retreat_threshold": raid[&"defender_retreat_threshold"], + &"terrain_bonus": raid[&"terrain_bonus"], + &"command_bonus": raid[&"command_bonus"], + &"surprise_penalty": raid[&"surprise_penalty"], + &"multiple_entry_penalty": raid[&"multiple_entry_penalty"], + &"entry_points": _names_to_strings(raid[&"entry_points"]), + &"objective_ids": _names_to_strings(raid[&"objective_ids"]), + &"waves": (raid[&"waves"] as Array).duplicate(true), + &"combatants": [], + &"assets": [], + &"queued_skills": [], + &"applied_skill_ids": [], + &"resolved_rounds": [], + &"result": String(raid[&"result"]), + &"result_reason": String(raid[&"result_reason"]), + &"revision": raid[&"revision"], + } + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var combatant: Dictionary = raid[&"combatants"][actor_id] + var saved_combatant := combatant.duplicate(true) + saved_combatant[&"actor_id"] = String(actor_id) + saved_combatant[&"side"] = String(combatant[&"side"]) + saved_combatant[&"wave_id"] = String(combatant[&"wave_id"]) + saved_combatant[&"entry_point_id"] = String(combatant[&"entry_point_id"]) + saved_combatant[&"state"] = CombatantState.keys()[int(combatant[&"state"])] + (snapshot[&"combatants"] as Array).append(saved_combatant) + for asset_id: StringName in _sorted_keys(raid[&"assets"]): + var asset: Dictionary = raid[&"assets"][asset_id] + var saved_asset := asset.duplicate(true) + saved_asset[&"asset_id"] = String(asset_id) + saved_asset[&"kind"] = AssetKind.keys()[int(asset[&"kind"])] + saved_asset[&"entry_points"] = _names_to_strings(asset[&"entry_points"]) + (snapshot[&"assets"] as Array).append(saved_asset) + for action_id: StringName in _sorted_keys(raid[&"queued_skills"]): + (snapshot[&"queued_skills"] as Array).append((raid[&"queued_skills"][action_id] as Dictionary).duplicate(true)) + for action_id: StringName in _sorted_keys(raid[&"applied_skill_ids"]): + (snapshot[&"applied_skill_ids"] as Array).append({ + &"action_id": String(action_id), + &"round_number": raid[&"applied_skill_ids"][action_id], + }) + for resolution_id: StringName in _sorted_keys(raid[&"resolved_rounds"]): + (snapshot[&"resolved_rounds"] as Array).append({ + &"resolution_id": String(resolution_id), + &"payload": _canonical_snapshot_value(raid[&"resolved_rounds"][resolution_id]), + }) + return snapshot + + +func _raid_from_snapshot(raw: Dictionary) -> Dictionary: + for collection_key: StringName in [&"waves", &"combatants", &"assets", &"queued_skills", &"applied_skill_ids", &"resolved_rounds"]: + if not _value(raw, collection_key, null) is Array: + return {&"ok": false, &"error": "Malformed Raid collection: %s" % collection_key} + var raid_id := StringName(_value(raw, &"raid_id", &"")) + var state_index := RaidState.keys().find(String(_value(raw, &"state", &""))) + if raid_id.is_empty() or state_index < 0: + return {&"ok": false, &"error": "Raid snapshot ID or state is invalid."} + var raid := { + &"raid_id": raid_id, + &"source_id": StringName(_value(raw, &"source_id", &"")), + &"state": state_index, + &"created_at": int(_value(raw, &"created_at", -1)), + &"started_at": int(_value(raw, &"started_at", -1)), + &"finished_at": int(_value(raw, &"finished_at", -1)), + &"state_changed_at": int(_value(raw, &"state_changed_at", -1)), + &"current_wave_index": int(_value(raw, &"current_wave_index", -1)), + &"current_round": int(_value(raw, &"current_round", -1)), + &"elapsed_minutes": int(_value(raw, &"elapsed_minutes", -1)), + &"max_duration_minutes": int(_value(raw, &"max_duration_minutes", 0)), + &"round_minutes": int(_value(raw, &"round_minutes", 0)), + &"retreat_policy": StringName(_value(raw, &"retreat_policy", &"")), + &"defender_retreat_threshold": int(_value(raw, &"defender_retreat_threshold", -1)), + &"terrain_bonus": int(_value(raw, &"terrain_bonus", 0)), + &"command_bonus": int(_value(raw, &"command_bonus", 0)), + &"surprise_penalty": int(_value(raw, &"surprise_penalty", -1)), + &"multiple_entry_penalty": int(_value(raw, &"multiple_entry_penalty", -1)), + &"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])), + &"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])), + &"waves": [], + &"combatants": {}, + &"assets": {}, + &"queued_skills": {}, + &"applied_skill_ids": {}, + &"resolved_rounds": {}, + &"result": StringName(_value(raw, &"result", &"")), + &"result_reason": StringName(_value(raw, &"result_reason", &"")), + &"revision": int(_value(raw, &"revision", -1)), + } + for raw_wave: Variant in _value(raw, &"waves", []): + if not raw_wave is Dictionary: + return {&"ok": false, &"error": "Malformed Raid wave snapshot."} + var wave: Dictionary = raw_wave.duplicate(true) + wave[&"wave_id"] = StringName(_value(wave, &"wave_id", &"")) + wave[&"threat_strength"] = int(_value(wave, &"threat_strength", 0)) + wave[&"remaining_threat"] = int(_value(wave, &"remaining_threat", -1)) + wave[&"retreat_threshold"] = int(_value(wave, &"retreat_threshold", -1)) + wave[&"max_rounds"] = int(_value(wave, &"max_rounds", 0)) + wave[&"rounds_completed"] = int(_value(wave, &"rounds_completed", -1)) + wave[&"entry_points"] = _sorted_unique_names(_value(wave, &"entry_points", [])) + wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", [])) + wave[&"state"] = StringName(_value(wave, &"state", &"")) + wave[&"result_reason"] = StringName(_value(wave, &"result_reason", &"")) + (raid[&"waves"] as Array).append(wave) + for raw_combatant: Variant in _value(raw, &"combatants", []): + if not raw_combatant is Dictionary: + return {&"ok": false, &"error": "Malformed defense combatant snapshot."} + var combatant: Dictionary = (raw_combatant as Dictionary).duplicate(true) + var actor_id := StringName(_value(combatant, &"actor_id", &"")) + var combatant_state := CombatantState.keys().find(String(_value(combatant, &"state", &""))) + if actor_id.is_empty() or combatant_state < 0 or (raid[&"combatants"] as Dictionary).has(actor_id): + return {&"ok": false, &"error": "Invalid or duplicate defense combatant snapshot."} + combatant[&"actor_id"] = actor_id + combatant[&"side"] = StringName(_value(combatant, &"side", &"")) + combatant[&"wave_id"] = StringName(_value(combatant, &"wave_id", &"")) + combatant[&"entry_point_id"] = StringName(_value(combatant, &"entry_point_id", &"")) + combatant[&"state"] = combatant_state + for numeric_key: StringName in [ + &"combat", &"armor", &"max_health", &"health", &"initial_health", + &"fatigue", &"target_order", &"damage_dealt", &"damage_taken", &"revision", + ]: + combatant[numeric_key] = int(_value(combatant, numeric_key, -1)) + (raid[&"combatants"] as Dictionary)[actor_id] = combatant + for raw_asset: Variant in _value(raw, &"assets", []): + if not raw_asset is Dictionary: + return {&"ok": false, &"error": "Malformed defense asset snapshot."} + var asset: Dictionary = (raw_asset as Dictionary).duplicate(true) + var asset_id := StringName(_value(asset, &"asset_id", &"")) + var kind_index := AssetKind.keys().find(String(_value(asset, &"kind", &""))) + if asset_id.is_empty() or kind_index < 0 or (raid[&"assets"] as Dictionary).has(asset_id): + return {&"ok": false, &"error": "Invalid or duplicate defense asset snapshot."} + asset[&"asset_id"] = asset_id + asset[&"kind"] = kind_index + asset[&"entry_points"] = _sorted_unique_names(_value(asset, &"entry_points", [])) + for numeric_key: StringName in [ + &"max_integrity", &"integrity", &"initial_integrity", &"defense", + &"armor", &"terrain_bonus", &"target_order", &"revision", + ]: + asset[numeric_key] = int(_value(asset, numeric_key, -1)) + (raid[&"assets"] as Dictionary)[asset_id] = asset + for raw_skill: Variant in _value(raw, &"queued_skills", []): + if not raw_skill is Dictionary: + return {&"ok": false, &"error": "Malformed queued defense skill snapshot."} + var skill: Dictionary = (raw_skill as Dictionary).duplicate(true) + var action_id := StringName(_value(skill, &"action_id", &"")) + if action_id.is_empty() or (raid[&"queued_skills"] as Dictionary).has(action_id): + return {&"ok": false, &"error": "Invalid or duplicate queued defense skill snapshot."} + skill[&"action_id"] = action_id + skill[&"actor_id"] = StringName(_value(skill, &"actor_id", &"")) + skill[&"kind"] = StringName(_value(skill, &"kind", &"")) + skill[&"target_id"] = StringName(_value(skill, &"target_id", &"")) + skill[&"potency"] = int(_value(skill, &"potency", -1)) + skill[&"round_number"] = int(_value(skill, &"round_number", -1)) + if not _value(skill, &"metadata", null) is Dictionary: + return {&"ok": false, &"error": "Malformed queued defense skill metadata."} + var metadata_result := _canonicalize_metadata(_value(skill, &"metadata", {})) + if not bool(metadata_result[&"ok"]): + return {&"ok": false, &"error": "Unsupported queued defense skill metadata: %s" % str(metadata_result)} + skill[&"metadata"] = metadata_result[&"value"] + (raid[&"queued_skills"] as Dictionary)[action_id] = skill + for raw_applied: Variant in _value(raw, &"applied_skill_ids", []): + if not raw_applied is Dictionary: + return {&"ok": false, &"error": "Malformed applied defense skill snapshot."} + var action_id := StringName(_value(raw_applied, &"action_id", &"")) + if action_id.is_empty() or (raid[&"applied_skill_ids"] as Dictionary).has(action_id): + return {&"ok": false, &"error": "Invalid or duplicate applied defense skill snapshot."} + (raid[&"applied_skill_ids"] as Dictionary)[action_id] = int(_value(raw_applied, &"round_number", -1)) + for raw_resolution: Variant in _value(raw, &"resolved_rounds", []): + if not raw_resolution is Dictionary or not _value(raw_resolution, &"payload", null) is Dictionary: + return {&"ok": false, &"error": "Malformed defense round resolution snapshot."} + var resolution_id := StringName(_value(raw_resolution, &"resolution_id", &"")) + if resolution_id.is_empty() or (raid[&"resolved_rounds"] as Dictionary).has(resolution_id): + return {&"ok": false, &"error": "Invalid or duplicate defense resolution snapshot."} + var canonical_payload: Variant = _canonical_snapshot_value(_value(raw_resolution, &"payload", {})) + if not canonical_payload is Dictionary: + return {&"ok": false, &"error": "Defense resolution payload is not saveable."} + (raid[&"resolved_rounds"] as Dictionary)[resolution_id] = canonical_payload + return {&"ok": true, &"raid": raid} + + +func _validate_raid(raid: Dictionary) -> Dictionary: + var errors: Array[String] = [] + var raid_id := StringName(raid.get(&"raid_id", &"")) + var state := int(raid.get(&"state", -1)) + var created_at := int(raid.get(&"created_at", -1)) + var started_at := int(raid.get(&"started_at", -1)) + var state_changed_at := int(raid.get(&"state_changed_at", -1)) + var finished_at := int(raid.get(&"finished_at", -1)) + if raid_id.is_empty() or state < 0 or state >= RaidState.size(): + errors.append("Raid ID or state is invalid.") + if created_at < 0 or state_changed_at < created_at: + errors.append("Raid timestamps are invalid.") + if started_at >= 0 and (started_at < created_at or state_changed_at < started_at): + errors.append("Raid start timestamp is outside created/state-changed bounds.") + if int(raid.get(&"max_duration_minutes", 0)) <= 0 or int(raid.get(&"round_minutes", 0)) <= 0: + errors.append("Raid durations are invalid.") + if int(raid.get(&"current_round", -1)) < 0 or int(raid.get(&"elapsed_minutes", -1)) < 0: + errors.append("Raid progress counters are invalid.") + elif int(raid.get(&"elapsed_minutes", -1)) \ + != int(raid.get(&"current_round", -1)) * int(raid.get(&"round_minutes", 0)): + errors.append("Raid elapsed time does not match its resolved round count.") + if int(raid.get(&"revision", -1)) < 0: + errors.append("Raid revision is invalid.") + if int(raid.get(&"surprise_penalty", -1)) < 0 \ + or int(raid.get(&"multiple_entry_penalty", -1)) < 0: + errors.append("Raid entry or surprise penalties are invalid.") + if not VALID_RETREAT_POLICIES.has(StringName(raid.get(&"retreat_policy", &""))): + errors.append("Raid retreat policy is invalid.") + var retreat_threshold := int(raid.get(&"defender_retreat_threshold", -1)) + if retreat_threshold < 0 or retreat_threshold > 100: + errors.append("Raid defender retreat threshold is invalid.") + var entry_points: Variant = raid.get(&"entry_points", null) + var objective_ids: Variant = raid.get(&"objective_ids", null) + if not entry_points is Array or not objective_ids is Array \ + or entry_points.is_empty() or objective_ids.is_empty(): + errors.append("Raid entry points or objectives are invalid.") + else: + if entry_points != _sorted_unique_names(entry_points): + errors.append("Raid entry points are not canonical.") + if objective_ids != _sorted_unique_names(objective_ids): + errors.append("Raid objective IDs are not canonical.") + + var raw_waves: Variant = raid.get(&"waves", null) + var wave_ids: Dictionary = {} + var resolved_round_total := 0 + const VALID_WAVE_STATES := [&"PENDING", &"ACTIVE", &"RESOLVED", &"BREACHED", &"RETREATED", &"CANCELLED"] + if not raw_waves is Array or raw_waves.is_empty(): + errors.append("Raid waves are missing.") + else: + for raw_wave: Variant in raw_waves: + if not raw_wave is Dictionary: + errors.append("Raid wave entry is malformed.") + continue + var wave: Dictionary = raw_wave + var wave_id := StringName(wave.get(&"wave_id", &"")) + var threat := int(wave.get(&"threat_strength", 0)) + var remaining := int(wave.get(&"remaining_threat", -1)) + var wave_retreat := int(wave.get(&"retreat_threshold", -1)) + var wave_state := StringName(wave.get(&"state", &"")) + if wave_id.is_empty() or wave_ids.has(wave_id): + errors.append("Raid wave ID is empty or duplicated.") + else: + wave_ids[wave_id] = true + if threat <= 0 or remaining < 0 or remaining > threat: + errors.append("Raid wave threat values are invalid: %s" % wave_id) + if wave_retreat < 0 or wave_retreat > 100 or int(wave.get(&"max_rounds", 0)) <= 0 \ + or int(wave.get(&"rounds_completed", -1)) < 0 \ + or int(wave.get(&"rounds_completed", 0)) > int(wave.get(&"max_rounds", 0)): + errors.append("Raid wave round limits are invalid: %s" % wave_id) + resolved_round_total += maxi(int(wave.get(&"rounds_completed", -1)), 0) + if not VALID_WAVE_STATES.has(wave_state): + errors.append("Raid wave state is invalid: %s" % wave_id) + var wave_entries: Variant = wave.get(&"entry_points", null) + var wave_objectives: Variant = wave.get(&"objective_ids", null) + if not wave_entries is Array or wave_entries.is_empty() \ + or wave_entries != _sorted_unique_names(wave_entries): + errors.append("Raid wave entry points are malformed: %s" % wave_id) + if not wave_objectives is Array or wave_objectives.is_empty() \ + or wave_objectives != _sorted_unique_names(wave_objectives): + errors.append("Raid wave objectives are malformed: %s" % wave_id) + for wave_entry: StringName in _sorted_unique_names(wave_entries): + if not entry_points is Array or not entry_points.has(wave_entry): + errors.append("Raid wave has unknown entry point: %s" % wave_entry) + for wave_objective: StringName in _sorted_unique_names(wave_objectives): + if not objective_ids is Array or not objective_ids.has(wave_objective): + errors.append("Raid wave has unknown objective: %s" % wave_objective) + var wave_index := int(raid.get(&"current_wave_index", -1)) + if raw_waves is Array and (wave_index < 0 or wave_index >= raw_waves.size()): + errors.append("Raid current wave index is invalid.") + if resolved_round_total != int(raid.get(&"current_round", -1)): + errors.append("Raid current round differs from the sum of wave rounds.") + if raw_waves is Array and wave_index >= 0 and wave_index < raw_waves.size() \ + and raw_waves[wave_index] is Dictionary: + var current_wave_state := StringName((raw_waves[wave_index] as Dictionary).get(&"state", &"")) + if state == RaidState.PREPARING and current_wave_state != &"PENDING": + errors.append("Preparing Raid current wave is not pending.") + elif state == RaidState.ACTIVE and current_wave_state != &"ACTIVE": + errors.append("Active Raid current wave is not active.") + elif state == RaidState.BETWEEN_WAVES and current_wave_state != &"RESOLVED": + errors.append("Between-wave Raid current wave is not resolved.") + elif state == RaidState.DEFENDED and current_wave_state != &"RESOLVED": + errors.append("Defended Raid current wave is not resolved.") + elif state == RaidState.BREACHED and current_wave_state != &"BREACHED": + errors.append("Breached Raid current wave is not breached.") + elif state == RaidState.DEFENDERS_RETREATED and current_wave_state != &"RETREATED": + errors.append("Retreated Raid current wave is not marked retreated.") + elif state == RaidState.CANCELLED and current_wave_state != &"CANCELLED": + errors.append("Cancelled Raid current wave is not cancelled.") + for index: int in range(raw_waves.size()): + if not raw_waves[index] is Dictionary: + continue + var indexed_state := StringName((raw_waves[index] as Dictionary).get(&"state", &"")) + if index < wave_index and indexed_state != &"RESOLVED": + errors.append("Completed earlier Raid wave is not resolved.") + elif index > wave_index and indexed_state != &"PENDING": + errors.append("Future Raid wave is not pending.") + + var combatants: Variant = raid.get(&"combatants", null) + if not combatants is Dictionary: + errors.append("Raid combatants collection is malformed.") + else: + for actor_id: StringName in _sorted_keys(combatants): + var actor: Variant = combatants[actor_id] + if not actor is Dictionary or actor.get(&"actor_id", &"") != actor_id: + errors.append("Defense combatant key mismatch: %s" % actor_id) + continue + if actor.get(&"side", &"") not in [&"DEFENDER", &"ATTACKER"]: + errors.append("Defense combatant side is invalid: %s" % actor_id) + if int(actor.get(&"state", -1)) < 0 or int(actor.get(&"state", -1)) >= CombatantState.size(): + errors.append("Defense combatant state is invalid: %s" % actor_id) + var max_health := int(actor.get(&"max_health", 0)) + var health := int(actor.get(&"health", -1)) + if max_health <= 0 or health < 0 or health > max_health \ + or int(actor.get(&"initial_health", 0)) <= 0 or int(actor.get(&"initial_health", 0)) > max_health: + errors.append("Defense combatant health is invalid: %s" % actor_id) + if health == 0 and int(actor.get(&"state", -1)) == CombatantState.ACTIVE: + errors.append("Zero-health combatant remains active: %s" % actor_id) + if int(actor.get(&"combat", -1)) < 0 or int(actor.get(&"armor", -1)) < 0 \ + or int(actor.get(&"fatigue", -1)) < 0 or int(actor.get(&"fatigue", -1)) > 100: + errors.append("Defense combatant stats are invalid: %s" % actor_id) + if int(actor.get(&"damage_dealt", -1)) < 0 \ + or int(actor.get(&"damage_taken", -1)) < 0 \ + or int(actor.get(&"revision", -1)) < 0 \ + or not actor.has(&"target_order"): + errors.append("Defense combatant counters are invalid: %s" % actor_id) + var actor_entry := StringName(actor.get(&"entry_point_id", &"")) + if not actor_entry.is_empty() and (not entry_points is Array or not entry_points.has(actor_entry)): + errors.append("Defense combatant entry point is invalid: %s" % actor_id) + if actor.get(&"side", &"") == &"ATTACKER": + if not wave_ids.has(actor.get(&"wave_id", &"")): + errors.append("Attacker references a missing wave: %s" % actor_id) + elif not actor_entry.is_empty(): + var referenced_wave := _wave_by_id(raid, StringName(actor.get(&"wave_id", &""))) + if referenced_wave.is_empty() or not (referenced_wave[&"entry_points"] as Array).has(actor_entry): + errors.append("Attacker entry point is outside its wave: %s" % actor_id) + elif not StringName(actor.get(&"wave_id", &"")).is_empty(): + errors.append("Defender is incorrectly bound to a wave: %s" % actor_id) + + var assets: Variant = raid.get(&"assets", null) + if not assets is Dictionary: + errors.append("Raid assets collection is malformed.") + else: + for asset_id: StringName in _sorted_keys(assets): + var asset: Variant = assets[asset_id] + if not asset is Dictionary or asset.get(&"asset_id", &"") != asset_id: + errors.append("Defense asset key mismatch: %s" % asset_id) + continue + var kind := int(asset.get(&"kind", -1)) + var maximum := int(asset.get(&"max_integrity", 0)) + var integrity := int(asset.get(&"integrity", -1)) + if kind < 0 or kind >= AssetKind.size() or maximum <= 0 or integrity < 0 or integrity > maximum: + errors.append("Defense asset state is invalid: %s" % asset_id) + if int(asset.get(&"initial_integrity", 0)) <= 0 \ + or int(asset.get(&"initial_integrity", 0)) > maximum: + errors.append("Defense asset initial integrity is invalid: %s" % asset_id) + if int(asset.get(&"defense", -1)) < 0 or int(asset.get(&"armor", -1)) < 0 \ + or int(asset.get(&"revision", -1)) < 0: + errors.append("Defense asset stats are invalid: %s" % asset_id) + var asset_entries: Variant = asset.get(&"entry_points", null) + if not asset_entries is Array or asset_entries != _sorted_unique_names(asset_entries): + errors.append("Defense asset entry points are malformed: %s" % asset_id) + else: + for asset_entry: StringName in asset_entries: + if not entry_points is Array or not entry_points.has(asset_entry): + errors.append("Defense asset has unknown entry point: %s" % asset_id) + var listed_objective: bool = objective_ids is Array and objective_ids.has(asset_id) + if listed_objective != (kind == AssetKind.OBJECTIVE): + errors.append("Defense objective registration mismatch: %s" % asset_id) + if state != RaidState.PREPARING and assets is Dictionary and objective_ids is Array: + for objective_id: StringName in objective_ids: + if not assets.has(objective_id): + errors.append("Started Raid is missing objective asset: %s" % objective_id) + + var queued: Variant = raid.get(&"queued_skills", null) + var applied: Variant = raid.get(&"applied_skill_ids", null) + if not queued is Dictionary or not applied is Dictionary: + errors.append("Raid skill collections are malformed.") + else: + if state != RaidState.ACTIVE and not queued.is_empty(): + errors.append("Only an active Raid may retain queued skills.") + for action_id: StringName in _sorted_keys(queued): + var skill: Variant = queued[action_id] + if not skill is Dictionary or skill.get(&"action_id", &"") != action_id \ + or not VALID_SKILL_KINDS.has(StringName(skill.get(&"kind", &""))): + errors.append("Queued defense skill is invalid: %s" % action_id) + elif applied.has(action_id): + errors.append("Defense skill is both queued and applied: %s" % action_id) + elif not combatants is Dictionary or not combatants.has(skill.get(&"actor_id", &"")): + errors.append("Queued defense skill actor is missing: %s" % action_id) + else: + var skill_actor: Variant = combatants[skill.get(&"actor_id", &"")] + if not skill_actor is Dictionary or skill_actor.get(&"side", &"") != &"DEFENDER" \ + or int(skill_actor.get(&"state", -1)) != CombatantState.ACTIVE: + errors.append("Queued defense skill actor is not an active defender: %s" % action_id) + var potency := int(skill.get(&"potency", -1)) + if potency < 0 or potency > 100: + errors.append("Queued defense skill potency is invalid: %s" % action_id) + if int(skill.get(&"round_number", -1)) != int(raid.get(&"current_round", 0)) + 1: + errors.append("Queued defense skill round is invalid: %s" % action_id) + if not skill.get(&"metadata", null) is Dictionary: + errors.append("Queued defense skill metadata is invalid: %s" % action_id) + else: + var metadata_report := _canonicalize_metadata(skill.get(&"metadata", {})) + if not bool(metadata_report[&"ok"]) \ + or metadata_report[&"value"] != skill.get(&"metadata", {}): + errors.append("Queued defense skill metadata is not canonical: %s" % action_id) + var skill_kind := StringName(skill.get(&"kind", &"")) + var target_id := StringName(skill.get(&"target_id", &"")) + if skill_kind == &"HEAL": + var heal_target: Variant = combatants.get(target_id, null) if combatants is Dictionary else null + if not heal_target is Dictionary or heal_target.get(&"side", &"") != &"DEFENDER": + errors.append("Queued HEAL target is invalid: %s" % action_id) + elif skill_kind == &"FORTIFY": + var fortify_target: Variant = assets.get(target_id, null) if assets is Dictionary else null + if not fortify_target is Dictionary or int(fortify_target.get(&"integrity", 0)) <= 0: + errors.append("Queued FORTIFY target is invalid: %s" % action_id) + for action_id: StringName in _sorted_keys(applied): + if int(applied[action_id]) <= 0 or int(applied[action_id]) > int(raid.get(&"current_round", 0)): + errors.append("Applied defense skill round is invalid: %s" % action_id) + + var resolutions: Variant = raid.get(&"resolved_rounds", null) + if not resolutions is Dictionary: + errors.append("Raid resolution collection is malformed.") + else: + var round_times: Dictionary = {} + for resolution_id: StringName in _sorted_keys(resolutions): + var payload: Variant = resolutions[resolution_id] + if not payload is Dictionary or payload.get(&"resolution_id", &"") != resolution_id \ + or payload.get(&"raid_id", &"") != raid_id: + errors.append("Defense resolution payload mismatch: %s" % resolution_id) + continue + var operation_kind := _resolution_operation_kind(payload) + if operation_kind.is_empty(): + errors.append("Defense resolution operation kind is invalid: %s" % resolution_id) + var resolution_minute := int(payload.get(&"absolute_minute", -1)) + if resolution_minute < created_at or resolution_minute > state_changed_at: + errors.append("Defense resolution timestamp is outside Raid bounds: %s" % resolution_id) + if operation_kind in [OPERATION_ROUND, OPERATION_RETREAT] \ + and (started_at < 0 or resolution_minute < started_at): + errors.append("Started operation resolution precedes Raid start: %s" % resolution_id) + if operation_kind == OPERATION_RETREAT: + if StringName(payload.get(&"result", &"")) != &"DEFENDERS_RETREATED" \ + or state != RaidState.DEFENDERS_RETREATED: + errors.append("Retreat resolution does not match the Raid result: %s" % resolution_id) + if resolution_minute != finished_at: + errors.append("Retreat resolution timestamp differs from Raid finish: %s" % resolution_id) + elif operation_kind == OPERATION_CANCEL: + if StringName(payload.get(&"result", &"")) != &"CANCELLED" \ + or state != RaidState.CANCELLED: + errors.append("Cancel resolution does not match the Raid result: %s" % resolution_id) + if resolution_minute != finished_at: + errors.append("Cancel resolution timestamp differs from Raid finish: %s" % resolution_id) + if operation_kind == OPERATION_ROUND: + var round_number := int(payload.get(&"round_number", -1)) + if round_number <= 0 or round_number > int(raid.get(&"current_round", 0)) \ + or round_times.has(round_number): + errors.append("Defense round history number is invalid or duplicated: %s" % resolution_id) + else: + round_times[round_number] = resolution_minute + if round_times.size() != int(raid.get(&"current_round", 0)): + errors.append("Defense round history count differs from current round.") + var previous_round_minute := started_at + for round_number: int in range(1, int(raid.get(&"current_round", 0)) + 1): + if not round_times.has(round_number): + continue + var round_minute := int(round_times[round_number]) + if previous_round_minute < 0 \ + or round_minute < previous_round_minute + int(raid.get(&"round_minutes", 0)): + errors.append("Defense round history violates its configured interval: %d" % round_number) + previous_round_minute = round_minute + + if _is_terminal(state): + if finished_at != state_changed_at \ + or raid.get(&"result", &"PENDING") == &"PENDING": + errors.append("Terminal Raid result or finish time is invalid.") + if StringName(raid.get(&"result", &"")) != StringName(RaidState.keys()[state]): + errors.append("Terminal Raid result does not match its state.") + else: + if finished_at != -1 or raid.get(&"result", &"PENDING") != &"PENDING": + errors.append("Live Raid has a terminal result.") + if state == RaidState.PREPARING and int(raid.get(&"started_at", -1)) != -1: + errors.append("Preparing Raid already has a start time.") + if state != RaidState.PREPARING and state != RaidState.CANCELLED \ + and int(raid.get(&"started_at", -1)) < int(raid.get(&"created_at", 0)): + errors.append("Started Raid has an invalid start time.") + if state == RaidState.CANCELLED and int(raid.get(&"started_at", -1)) != -1 \ + and int(raid.get(&"started_at", -1)) < int(raid.get(&"created_at", 0)): + errors.append("Cancelled Raid has an invalid start time.") + return {&"ok": errors.is_empty(), &"errors": errors} + + +func _validate_raid_clock(raid: Dictionary, clock_minute: int) -> Dictionary: + var errors: Array[String] = [] + for timestamp_key: StringName in [&"created_at", &"state_changed_at", &"started_at", &"finished_at"]: + var timestamp := int(raid.get(timestamp_key, -1)) + if timestamp >= 0 and timestamp > clock_minute: + errors.append("%s exceeds the Defense clock." % timestamp_key) + var resolutions: Variant = raid.get(&"resolved_rounds", null) + if resolutions is Dictionary: + for resolution_id: StringName in _sorted_keys(resolutions): + var payload: Variant = resolutions[resolution_id] + if payload is Dictionary and int(payload.get(&"absolute_minute", -1)) > clock_minute: + errors.append("Resolution %s exceeds the Defense clock." % resolution_id) + return {&"ok": errors.is_empty(), &"errors": errors} + + +func _asset_relevant_to_wave(asset: Dictionary, wave: Dictionary) -> bool: + if int(asset[&"kind"]) == AssetKind.OBJECTIVE: + return (wave[&"objective_ids"] as Array).has(asset[&"asset_id"]) + var entries: Array = asset[&"entry_points"] + if entries.is_empty(): + return true + for entry_id: StringName in entries: + if (wave[&"entry_points"] as Array).has(entry_id): + return true + return false + + +func _wave_attacker_ids(raid: Dictionary, wave_id: StringName) -> Array[StringName]: + var result: Array[StringName] = [] + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] == &"ATTACKER" and actor[&"wave_id"] == wave_id: + result.append(actor_id) + return result + + +func _skill_ids(skills: Array[Dictionary]) -> Array[StringName]: + var result: Array[StringName] = [] + for skill: Dictionary in skills: + result.append(skill[&"action_id"]) + return result + + +func _active_wave(raid: Dictionary) -> Dictionary: + var waves: Array = raid.get(&"waves", []) + var index := int(raid.get(&"current_wave_index", -1)) + if index < 0 or index >= waves.size() or not waves[index] is Dictionary: + return {} + return waves[index] + + +func _wave_exists(raid: Dictionary, wave_id: StringName) -> bool: + return not _wave_by_id(raid, wave_id).is_empty() + + +func _wave_by_id(raid: Dictionary, wave_id: StringName) -> Dictionary: + if wave_id.is_empty(): + return {} + for wave: Dictionary in raid[&"waves"]: + if wave[&"wave_id"] == wave_id: + return wave + return {} + + +func _raid_ref(raid_id: StringName) -> Dictionary: + if not _raids.has(raid_id): + return {} + return _raids[raid_id] + + +func _is_terminal(state: int) -> bool: + return TERMINAL_STATES.has(state) + + +func _resolution_operation_kind(payload: Dictionary) -> StringName: + var explicit := StringName(_value(payload, &"operation_kind", &"")) + if VALID_OPERATION_KINDS.has(explicit): + return explicit + if payload.has(&"operation_kind") or payload.has("operation_kind"): + return &"" + # Version-1 snapshots created before operation_kind existed remain readable. + # Round payloads have scoring fields; terminal command payloads identify + # themselves by result. Ambiguous legacy entries are rejected. + if payload.has(&"defense_score") or payload.has("defense_score"): + return OPERATION_ROUND + var result := StringName(_value(payload, &"result", &"")) + if result == &"DEFENDERS_RETREATED": + return OPERATION_RETREAT + if result == &"CANCELLED": + return OPERATION_CANCEL + return &"" + + +func _replay_resolution( + raid: Dictionary, + resolution_id: StringName, + expected_kind: StringName +) -> CommandResult: + var payload: Dictionary = raid[&"resolved_rounds"][resolution_id] + var actual_kind := _resolution_operation_kind(payload) + if actual_kind != expected_kind: + return _invalid("Defense resolution ID belongs to a different operation.", { + &"resolution_id": resolution_id, + &"expected_operation_kind": expected_kind, + &"actual_operation_kind": actual_kind, + }) + var replay := _success(payload.duplicate(true)) + replay.is_replay = true + last_error = "" + return replay + + +func _normalize_boundary_result(raw: Variant) -> Dictionary: + if raw is Dictionary: + var result: Dictionary = (raw as Dictionary).duplicate(true) + result[&"ok"] = bool(raw.get(&"ok", raw.get("ok", false))) + return result + if raw is bool: + return {&"ok": raw} + return {&"ok": false, &"reason": &"INVALID_BOUNDARY_RESULT"} + + +func _sorted_keys(dictionary: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_key: Variant in dictionary.keys(): + result.append(StringName(raw_key)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +func _sorted_unique_names(raw: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if raw is Array: + for value: Variant in raw: + var name := StringName(value) + if not name.is_empty() and not result.has(name): + result.append(name) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +func _names_to_strings(raw: Variant) -> Array[String]: + var result: Array[String] = [] + if raw is Array: + for value: Variant in raw: + result.append(String(value)) + return result + + +func _canonicalize_metadata(raw: Variant, depth: int = 0, path: String = "$") -> Dictionary: + if depth > MAX_METADATA_DEPTH: + return {&"ok": false, &"reason": &"MAX_DEPTH_EXCEEDED", &"path": path} + match typeof(raw): + TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_STRING: + return {&"ok": true, &"value": raw} + TYPE_STRING_NAME: + return {&"ok": true, &"value": String(raw)} + TYPE_FLOAT: + var number := float(raw) + if not is_finite(number): + return {&"ok": false, &"reason": &"NON_FINITE_FLOAT", &"path": path} + if number == 0.0: + number = 0.0 + return {&"ok": true, &"value": number} + TYPE_ARRAY: + var values: Array = [] + var raw_array: Array = raw + for index: int in range(raw_array.size()): + var child := _canonicalize_metadata(raw_array[index], depth + 1, "%s[%d]" % [path, index]) + if not bool(child[&"ok"]): + return child + values.append(child[&"value"]) + return {&"ok": true, &"value": values} + TYPE_DICTIONARY: + var raw_dictionary: Dictionary = raw + var source_by_key: Dictionary = {} + var canonical_keys: Array[String] = [] + for raw_key: Variant in raw_dictionary.keys(): + if not (raw_key is String or raw_key is StringName): + return {&"ok": false, &"reason": &"NON_STRING_KEY", &"path": path} + var key := String(raw_key) + if source_by_key.has(key): + return {&"ok": false, &"reason": &"DUPLICATE_CANONICAL_KEY", &"path": "%s.%s" % [path, key]} + source_by_key[key] = raw_key + canonical_keys.append(key) + canonical_keys.sort() + var values: Dictionary = {} + for key: String in canonical_keys: + var child := _canonicalize_metadata( + raw_dictionary[source_by_key[key]], + depth + 1, + "%s.%s" % [path, key] + ) + if not bool(child[&"ok"]): + return child + values[key] = child[&"value"] + return {&"ok": true, &"value": values} + _: + return { + &"ok": false, + &"reason": &"UNSUPPORTED_VARIANT_TYPE", + &"path": path, + &"variant_type": type_string(typeof(raw)), + } + + +func _canonical_snapshot_value(raw: Variant) -> Variant: + if raw is StringName: + return String(raw) + if raw is float and raw == floor(raw): + return int(raw) + if raw is Array: + var result: Array = [] + for value: Variant in raw: + result.append(_canonical_snapshot_value(value)) + return result + if raw is Dictionary: + var result: Dictionary = {} + for key: Variant in raw.keys(): + result[String(key)] = _canonical_snapshot_value(raw[key]) + return result + return raw + + +func _value(dictionary: Dictionary, key: StringName, default_value: Variant) -> Variant: + if dictionary.has(key): + return dictionary[key] + var string_key := String(key) + if dictionary.has(string_key): + return dictionary[string_key] + return default_value + + +func _success(details: Dictionary = {}) -> CommandResult: + last_error = "" + return CommandResultType.succeeded(0, details) + + +func _invalid(message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed(CommandResultType.Code.INVALID_PAYLOAD, 0, message, details) + + +func _duplicate(kind: String, value_id: StringName) -> CommandResult: + return _invalid("%s already exists: %s" % [kind, value_id], {&"id": value_id}) + + +func _not_found(kind: String, value_id: StringName) -> CommandResult: + last_error = "%s not found: %s" % [kind, value_id] + return CommandResultType.failed( + CommandResultType.Code.NOT_FOUND, + 0, + last_error, + {&"id": value_id} + ) diff --git a/scripts/defense/defense_service.gd.uid b/scripts/defense/defense_service.gd.uid new file mode 100644 index 0000000..4138559 --- /dev/null +++ b/scripts/defense/defense_service.gd.uid @@ -0,0 +1 @@ +uid://cai6ix6leyrxp diff --git a/scripts/economy/economy_service.gd b/scripts/economy/economy_service.gd new file mode 100644 index 0000000..9778dcf --- /dev/null +++ b/scripts/economy/economy_service.gd @@ -0,0 +1,895 @@ +class_name EconomyService +extends RefCounted + +## Authoritative Gold ledger. There is intentionally no public balance setter: +## every change is derived from an append-only, balanced LedgerEntry. + +signal transaction_posted(transaction_id: StringName, entries: Array) + +const LedgerEntryType = preload("res://scripts/economy/ledger_entry.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 1 +const SYSTEM_SOURCE: StringName = &"SYSTEM_SOURCE" +const SYSTEM_SINK: StringName = &"SYSTEM_SINK" +const DEFAULT_CATEGORY: StringName = &"TRANSFER" + +var current_minute := 0 + +var _accounts: Dictionary = {} +var _balance_cache: Dictionary = {} +var _ledger: Array[LedgerEntry] = [] +var _transaction_ids: Dictionary = {} +var _idempotency_keys: Dictionary = {} +var _next_transaction_sequence := 1 +var _pending_transaction_notifications: Dictionary = {} + + +func _init(account_definitions: Dictionary = {}) -> void: + # SOURCE may go negative because it is the explicit counter-account for + # initialization/generation. SINK only receives destruction entries. + _register_account_internal(SYSTEM_SOURCE, true, true) + _register_account_internal(SYSTEM_SINK, false, true) + var account_ids: Array = account_definitions.keys() + account_ids.sort_custom(_variant_string_less) + for account_value: Variant in account_ids: + var account_id := StringName(account_value) + if account_id in [SYSTEM_SOURCE, SYSTEM_SINK]: + continue + var definition: Variant = account_definitions[account_value] + var allow_negative := false + if definition is bool: + allow_negative = definition + elif definition is Dictionary: + allow_negative = bool(definition.get( + &"allow_negative", + definition.get("allow_negative", false) + )) + _register_account_internal(account_id, allow_negative, false) + + +func set_current_minute(absolute_minute: int) -> bool: + if absolute_minute < 0: + return false + current_minute = absolute_minute + return true + + +func register_account(account_id: StringName, allow_negative := false) -> bool: + if account_id.is_empty() or _accounts.has(account_id): + return false + return _register_account_internal(account_id, allow_negative, false) + + +func has_account(account_id: StringName) -> bool: + return _accounts.has(account_id) + + +func account_allows_negative(account_id: StringName) -> bool: + if not _accounts.has(account_id): + return false + return bool((_accounts[account_id] as Dictionary).get(&"allow_negative", false)) + + +func get_account(account_id: StringName) -> Dictionary: + if not _accounts.has(account_id): + return {} + var result: Dictionary = (_accounts[account_id] as Dictionary).duplicate(true) + result[&"account_id"] = account_id + result[&"balance"] = get_balance(account_id) + return result + + +func get_accounts() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for account_id: StringName in _sorted_string_name_keys(_accounts): + result.append(get_account(account_id)) + return result + + +func get_balance(account_id: StringName) -> int: + return int(_balance_cache.get(account_id, 0)) + + +func initialize_account( + account_id: StringName, + amount: int, + absolute_minute: int = 0, + idempotency_key: StringName = &"", + emit_notification := true +) -> CommandResult: + if amount == 0: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "An initial balance must be non-zero." + ) + if amount > 0: + return transfer( + SYSTEM_SOURCE, + account_id, + amount, + absolute_minute, + &"INITIALIZATION", + account_id, + idempotency_key, + &"account.initial_balance", + &"", + emit_notification + ) + return transfer( + account_id, + SYSTEM_SINK, + -amount, + absolute_minute, + &"INITIALIZATION", + account_id, + idempotency_key, + &"account.initial_balance", + &"", + emit_notification + ) + + +## Moves Gold from from_account to to_account. The receiver is debited and the +## sender is credited, so signed balance is always debit minus credit. +func transfer( + from_account: StringName, + to_account: StringName, + amount: int, + absolute_minute: int = -1, + category: StringName = DEFAULT_CATEGORY, + source_entity_id: StringName = &"", + idempotency_key: StringName = &"", + memo_key: StringName = &"", + related_transaction_id: StringName = &"", + emit_notification := true +) -> CommandResult: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var entry := LedgerEntryType.new().configure( + &"", + minute, + to_account, + from_account, + amount, + category, + source_entity_id, + related_transaction_id, + memo_key + ) + return post([entry], idempotency_key, &"", emit_notification) + + +## Convenience API for callers which naturally specify debit and credit. +func post_entry( + debit_account: StringName, + credit_account: StringName, + amount: int, + absolute_minute: int, + category: StringName, + source_entity_id: StringName = &"", + idempotency_key: StringName = &"", + memo_key: StringName = &"", + related_transaction_id: StringName = &"" +) -> CommandResult: + var entry := LedgerEntryType.new().configure( + &"", + absolute_minute, + debit_account, + credit_account, + amount, + category, + source_entity_id, + related_transaction_id, + memo_key + ) + return post([entry], idempotency_key) + + +## Appends one transaction. All entries are validated and all resulting account +## balances are checked before any live state is changed. +func post( + entries: Array, + idempotency_key: StringName = &"", + transaction_id: StringName = &"", + emit_notification := true +) -> CommandResult: + if entries.is_empty(): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "A transaction needs at least one entry.") + if not idempotency_key.is_empty() and _idempotency_keys.has(idempotency_key): + return _failure( + CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, + "The idempotency key has already been posted.", + {&"transaction_id": _idempotency_keys[idempotency_key]} + ) + + var normalized: Array[LedgerEntry] = [] + var draft_ids: Dictionary = {} + for value: Variant in entries: + var entry := _entry_from_value(value) + if entry == null: + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "A ledger line is malformed.") + if not entry.transaction_id.is_empty(): + draft_ids[entry.transaction_id] = true + normalized.append(entry) + if draft_ids.size() > 1: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "All entries in one post must share a transaction ID." + ) + + var generated := false + var generated_sequence := _next_transaction_sequence + var effective_id := transaction_id + if effective_id.is_empty() and draft_ids.size() == 1: + effective_id = draft_ids.keys()[0] + if effective_id.is_empty(): + var generated_data := _peek_generated_transaction_id() + effective_id = generated_data[&"transaction_id"] + generated_sequence = int(generated_data[&"sequence"]) + generated = true + if effective_id.is_empty() or _transaction_ids.has(effective_id): + return _failure( + CommandResultType.Code.DUPLICATE_COMMAND_ID, + "The transaction ID is empty or already exists.", + {&"transaction_id": effective_id} + ) + + var candidate_balances := _balance_cache.duplicate(true) + var debit_total := 0 + var credit_total := 0 + for entry: LedgerEntry in normalized: + if not entry.transaction_id.is_empty() and entry.transaction_id != effective_id: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "A ledger line conflicts with the requested transaction ID." + ) + entry.transaction_id = effective_id + var errors := entry.validation_errors() + if not errors.is_empty(): + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "A ledger line violates the ledger schema.", + {&"errors": errors} + ) + if not _accounts.has(entry.debit_account) or not _accounts.has(entry.credit_account): + return _failure( + CommandResultType.Code.NOT_FOUND, + "A ledger account is not registered.", + { + &"debit_account": entry.debit_account, + &"credit_account": entry.credit_account, + } + ) + if not entry.related_transaction_id.is_empty() \ + and not _transaction_ids.has(entry.related_transaction_id): + return _failure( + CommandResultType.Code.NOT_FOUND, + "The related transaction does not exist.", + {&"related_transaction_id": entry.related_transaction_id} + ) + if not _system_account_use_is_valid(entry): + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "SYSTEM_SOURCE and SYSTEM_SINK are restricted to explicit system flows.", + {&"category": entry.category} + ) + candidate_balances[entry.debit_account] = int(candidate_balances[entry.debit_account]) + entry.amount + candidate_balances[entry.credit_account] = int(candidate_balances[entry.credit_account]) - entry.amount + debit_total += entry.amount + credit_total += entry.amount + + if debit_total != credit_total: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Debit and credit totals differ.") + for account_id: StringName in _sorted_string_name_keys(candidate_balances): + if int(candidate_balances[account_id]) < 0 and not account_allows_negative(account_id): + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "An account does not permit a negative balance.", + { + &"account_id": account_id, + &"available": get_balance(account_id), + &"resulting_balance": int(candidate_balances[account_id]), + } + ) + + # Commit starts here. Every possibly failing check is complete. + var start_index := _ledger.size() + for entry: LedgerEntry in normalized: + _ledger.append(entry.copy()) + _balance_cache = candidate_balances + _transaction_ids[effective_id] = { + &"start_index": start_index, + &"entry_count": normalized.size(), + &"idempotency_key": idempotency_key, + } + if not idempotency_key.is_empty(): + _idempotency_keys[idempotency_key] = effective_id + if generated: + _next_transaction_sequence = generated_sequence + 1 + var posted_entries := get_transaction_entries(effective_id) + if emit_notification: + transaction_posted.emit(effective_id, posted_entries) + else: + _pending_transaction_notifications[effective_id] = true + return CommandResultType.succeeded(0, { + &"transaction_id": effective_id, + &"entry_count": normalized.size(), + &"entries": _entries_to_dictionaries(posted_entries), + }) + + +func has_pending_transaction_notification(transaction_id: StringName) -> bool: + return _pending_transaction_notifications.has(transaction_id) + + +func commit_transaction_notification(transaction_id: StringName) -> bool: + if not _pending_transaction_notifications.has(transaction_id) \ + or not _transaction_ids.has(transaction_id): + return false + _pending_transaction_notifications.erase(transaction_id) + transaction_posted.emit(transaction_id, get_transaction_entries(transaction_id)) + return true + + +func discard_transaction_notification(transaction_id: StringName) -> bool: + if not _pending_transaction_notifications.has(transaction_id): + return false + _pending_transaction_notifications.erase(transaction_id) + return true + + +func reverse( + transaction_id: StringName, + absolute_minute: int = -1, + idempotency_key: StringName = &"", + category: StringName = &"REVERSAL", + source_entity_id: StringName = &"", + memo_key: StringName = &"transaction.reversal" +) -> CommandResult: + var originals := get_transaction_entries(transaction_id) + if originals.is_empty(): + return _failure( + CommandResultType.Code.NOT_FOUND, + "The transaction to reverse was not found.", + {&"transaction_id": transaction_id} + ) + if _has_related_financial_entry(transaction_id): + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "The transaction has already been reversed or refunded.", + {&"transaction_id": transaction_id} + ) + var minute := current_minute if absolute_minute < 0 else absolute_minute + var entries: Array[LedgerEntry] = [] + for original: LedgerEntry in originals: + entries.append(original.reversed( + &"", + minute, + category, + source_entity_id, + memo_key + )) + return post(entries, idempotency_key) + + +func reverse_transaction( + transaction_id: StringName, + absolute_minute: int = -1, + idempotency_key: StringName = &"" +) -> CommandResult: + return reverse(transaction_id, absolute_minute, idempotency_key) + + +## Partial refunds are deliberately limited to a one-line source transaction; +## this avoids silently choosing an allocation across compound entries. +func refund( + transaction_id: StringName, + amount: int, + absolute_minute: int = -1, + idempotency_key: StringName = &"", + source_entity_id: StringName = &"", + memo_key: StringName = &"transaction.refund" +) -> CommandResult: + var originals := get_transaction_entries(transaction_id) + if originals.is_empty(): + return _failure(CommandResultType.Code.NOT_FOUND, "The transaction to refund was not found.") + if originals.size() != 1: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "A partial refund needs a one-line source transaction." + ) + var original: LedgerEntry = originals[0] + var refundable := original.amount - get_refunded_amount(transaction_id) + if amount <= 0 or amount > refundable: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "The refund amount is outside the remaining refundable amount.", + {&"refundable": refundable} + ) + var minute := current_minute if absolute_minute < 0 else absolute_minute + var entry := original.reversed( + &"", + minute, + &"REFUND", + source_entity_id, + memo_key + ) + entry.amount = amount + return post([entry], idempotency_key) + + +func refund_transaction( + transaction_id: StringName, + amount: int, + absolute_minute: int = -1, + idempotency_key: StringName = &"" +) -> CommandResult: + return refund(transaction_id, amount, absolute_minute, idempotency_key) + + +func get_refunded_amount(transaction_id: StringName) -> int: + var originals := get_transaction_entries(transaction_id) + if originals.size() != 1: + return 0 + var original: LedgerEntry = originals[0] + var total := 0 + for entry: LedgerEntry in _ledger: + if entry.related_transaction_id == transaction_id \ + and entry.debit_account == original.credit_account \ + and entry.credit_account == original.debit_account: + total += entry.amount + return total + + +func has_transaction(transaction_id: StringName) -> bool: + return _transaction_ids.has(transaction_id) + + +func get_transaction_id_for_idempotency_key(idempotency_key: StringName) -> StringName: + return _idempotency_keys.get(idempotency_key, &"") + + +func get_entries() -> Array[LedgerEntry]: + var result: Array[LedgerEntry] = [] + for entry: LedgerEntry in _ledger: + result.append(entry.copy()) + return result + + +func get_transaction_entries(transaction_id: StringName) -> Array[LedgerEntry]: + var result: Array[LedgerEntry] = [] + if not _transaction_ids.has(transaction_id): + return result + var metadata: Dictionary = _transaction_ids[transaction_id] + var start_index := int(metadata[&"start_index"]) + var entry_count := int(metadata[&"entry_count"]) + for index in range(start_index, start_index + entry_count): + result.append(_ledger[index].copy()) + return result + + +func aggregate_categories(from_minute: int, to_minute_exclusive: int) -> Dictionary: + var totals: Dictionary = {} + for entry: LedgerEntry in _ledger: + if entry.absolute_minute < from_minute or entry.absolute_minute >= to_minute_exclusive: + continue + totals[entry.category] = int(totals.get(entry.category, 0)) + entry.amount + return _sorted_dictionary(totals) + + +func aggregate_accounts(from_minute: int, to_minute_exclusive: int) -> Dictionary: + var totals: Dictionary = {} + for account_id: StringName in _sorted_string_name_keys(_accounts): + totals[account_id] = {&"debit": 0, &"credit": 0, &"net": 0} + for entry: LedgerEntry in _ledger: + if entry.absolute_minute < from_minute or entry.absolute_minute >= to_minute_exclusive: + continue + var debit: Dictionary = totals[entry.debit_account] + debit[&"debit"] = int(debit[&"debit"]) + entry.amount + debit[&"net"] = int(debit[&"net"]) + entry.amount + var credit: Dictionary = totals[entry.credit_account] + credit[&"credit"] = int(credit[&"credit"]) + entry.amount + credit[&"net"] = int(credit[&"net"]) - entry.amount + return totals + + +func get_daily_summary(day: int) -> Dictionary: + if day < 1: + return {} + var start_minute := (day - 1) * 1_440 + var end_minute := start_minute + 1_440 + var transaction_set: Dictionary = {} + var entry_count := 0 + var total_debit := 0 + var total_credit := 0 + for entry: LedgerEntry in _ledger: + if entry.absolute_minute < start_minute or entry.absolute_minute >= end_minute: + continue + entry_count += 1 + transaction_set[entry.transaction_id] = true + total_debit += entry.amount + total_credit += entry.amount + return { + &"day": day, + &"start_minute": start_minute, + &"end_minute_exclusive": end_minute, + &"category_totals": aggregate_categories(start_minute, end_minute), + &"account_totals": aggregate_accounts(start_minute, end_minute), + &"transaction_count": transaction_set.size(), + &"entry_count": entry_count, + &"total_debit": total_debit, + &"total_credit": total_credit, + &"balanced": total_debit == total_credit, + } + + +func daily_summary(day: int) -> Dictionary: + return get_daily_summary(day) + + +func validate_ledger() -> Dictionary: + var calculated := _calculate_and_validate(_accounts, _ledger, _idempotency_keys) + var errors: Array = calculated[&"errors"] + var expected_balances: Dictionary = calculated[&"balances"] + if expected_balances != _balance_cache: + errors.append({ + &"code": &"BALANCE_CACHE_MISMATCH", + &"expected": expected_balances, + &"actual": _balance_cache.duplicate(true), + }) + var expected_transactions: Dictionary = calculated[&"transactions"] + if expected_transactions != _transaction_ids: + errors.append({ + &"code": &"TRANSACTION_INDEX_MISMATCH", + &"expected": expected_transactions, + &"actual": _transaction_ids.duplicate(true), + }) + return { + &"ok": errors.is_empty(), + &"errors": errors, + &"total_debit": calculated[&"total_debit"], + &"total_credit": calculated[&"total_credit"], + &"account_count": _accounts.size(), + &"transaction_count": _transaction_ids.size(), + &"entry_count": _ledger.size(), + } + + +func validate() -> Dictionary: + return validate_ledger() + + +func rebuild_balance_cache() -> bool: + var calculated := _calculate_and_validate(_accounts, _ledger, _idempotency_keys) + if not (calculated[&"errors"] as Array).is_empty(): + return false + _balance_cache = (calculated[&"balances"] as Dictionary).duplicate(true) + return true + + +func create_snapshot() -> Dictionary: + var saved_accounts: Array[Dictionary] = [] + for account_id: StringName in _sorted_string_name_keys(_accounts): + var definition: Dictionary = _accounts[account_id] + saved_accounts.append({ + &"account_id": String(account_id), + &"allow_negative": bool(definition[&"allow_negative"]), + &"system": bool(definition[&"system"]), + }) + var saved_entries: Array[Dictionary] = [] + for entry: LedgerEntry in _ledger: + saved_entries.append(entry.to_dictionary()) + var saved_idempotency: Array[Dictionary] = [] + for key: StringName in _sorted_string_name_keys(_idempotency_keys): + saved_idempotency.append({ + &"key": String(key), + &"transaction_id": String(_idempotency_keys[key]), + }) + return { + &"schema_version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"next_transaction_sequence": _next_transaction_sequence, + &"accounts": saved_accounts, + &"entries": saved_entries, + &"idempotency": saved_idempotency, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if int(snapshot.get(&"schema_version", snapshot.get("schema_version", 0))) != SNAPSHOT_VERSION: + return false + var saved_accounts: Variant = snapshot.get(&"accounts", snapshot.get("accounts", null)) + var saved_entries: Variant = snapshot.get(&"entries", snapshot.get("entries", null)) + var saved_idempotency: Variant = snapshot.get(&"idempotency", snapshot.get("idempotency", null)) + if not saved_accounts is Array or not saved_entries is Array or not saved_idempotency is Array: + return false + + var restored_accounts: Dictionary = {} + for value: Variant in saved_accounts: + if not value is Dictionary: + return false + var account_id := StringName(value.get(&"account_id", value.get("account_id", ""))) + if account_id.is_empty() or restored_accounts.has(account_id): + return false + restored_accounts[account_id] = { + &"allow_negative": bool(value.get(&"allow_negative", value.get("allow_negative", false))), + &"system": bool(value.get(&"system", value.get("system", false))), + } + if not restored_accounts.has(SYSTEM_SOURCE) or not restored_accounts.has(SYSTEM_SINK): + return false + if not bool((restored_accounts[SYSTEM_SOURCE] as Dictionary)[&"allow_negative"]) \ + or bool((restored_accounts[SYSTEM_SINK] as Dictionary)[&"allow_negative"]): + return false + if not bool((restored_accounts[SYSTEM_SOURCE] as Dictionary)[&"system"]) \ + or not bool((restored_accounts[SYSTEM_SINK] as Dictionary)[&"system"]): + return false + for account_id: Variant in restored_accounts: + if account_id not in [SYSTEM_SOURCE, SYSTEM_SINK] \ + and bool((restored_accounts[account_id] as Dictionary)[&"system"]): + return false + + var restored_ledger: Array[LedgerEntry] = [] + for value: Variant in saved_entries: + if not value is Dictionary: + return false + restored_ledger.append(LedgerEntryType.from_dictionary(value)) + + var restored_idempotency: Dictionary = {} + var idempotent_transactions: Dictionary = {} + for value: Variant in saved_idempotency: + if not value is Dictionary: + return false + var key := StringName(value.get(&"key", value.get("key", ""))) + var transaction_id := StringName(value.get( + &"transaction_id", + value.get("transaction_id", "") + )) + if key.is_empty() or transaction_id.is_empty() \ + or restored_idempotency.has(key) or idempotent_transactions.has(transaction_id): + return false + restored_idempotency[key] = transaction_id + idempotent_transactions[transaction_id] = true + + var calculated := _calculate_and_validate( + restored_accounts, + restored_ledger, + restored_idempotency + ) + if not (calculated[&"errors"] as Array).is_empty(): + return false + var restored_transactions: Dictionary = calculated[&"transactions"] + var next_sequence := int(snapshot.get( + &"next_transaction_sequence", + snapshot.get("next_transaction_sequence", 0) + )) + var restored_minute := int(snapshot.get(&"current_minute", snapshot.get("current_minute", -1))) + if next_sequence < 1 or restored_minute < 0: + return false + + # Atomic swap: live fields are untouched until every saved value is valid. + _accounts = restored_accounts + _ledger = restored_ledger + _idempotency_keys = restored_idempotency + _transaction_ids = restored_transactions + _balance_cache = (calculated[&"balances"] as Dictionary).duplicate(true) + _next_transaction_sequence = next_sequence + _pending_transaction_notifications.clear() + current_minute = restored_minute + return true + + +func _register_account_internal( + account_id: StringName, + allow_negative: bool, + is_system: bool +) -> bool: + if account_id.is_empty() or _accounts.has(account_id): + return false + _accounts[account_id] = { + &"allow_negative": allow_negative, + &"system": is_system, + } + _balance_cache[account_id] = 0 + return true + + +func _entry_from_value(value: Variant) -> LedgerEntry: + if value is LedgerEntry: + return (value as LedgerEntry).copy() + if value is Dictionary: + return LedgerEntryType.from_dictionary(value) + return null + + +func _peek_generated_transaction_id() -> Dictionary: + var sequence := maxi(_next_transaction_sequence, 1) + var transaction_id := StringName("ledger_%08d" % sequence) + while _transaction_ids.has(transaction_id): + sequence += 1 + transaction_id = StringName("ledger_%08d" % sequence) + return {&"transaction_id": transaction_id, &"sequence": sequence} + + +func _has_related_financial_entry(transaction_id: StringName) -> bool: + for entry: LedgerEntry in _ledger: + if entry.related_transaction_id == transaction_id: + return true + return false + + +func _system_account_use_is_valid(entry: LedgerEntry) -> bool: + if entry.debit_account not in [SYSTEM_SOURCE, SYSTEM_SINK] \ + and entry.credit_account not in [SYSTEM_SOURCE, SYSTEM_SINK]: + return true + var normalized := String(entry.category).to_upper() + return normalized.begins_with("INIT") \ + or normalized in ["GENERATION", "DESTRUCTION", "REVERSAL", "REFUND", "MIGRATION"] + + +func _calculate_and_validate( + accounts: Dictionary, + ledger: Array[LedgerEntry], + idempotency: Dictionary +) -> Dictionary: + var errors: Array[Dictionary] = [] + var balances: Dictionary = {} + for account_id: Variant in accounts: + balances[StringName(account_id)] = 0 + var transactions: Dictionary = {} + var closed_transactions: Dictionary = {} + var active_transaction: StringName = &"" + var transaction_start := 0 + var transaction_count := 0 + var transaction_delta: Dictionary = {} + var total_debit := 0 + var total_credit := 0 + + for index in ledger.size(): + var entry: LedgerEntry = ledger[index] + var entry_errors := entry.validation_errors() + if not entry_errors.is_empty(): + errors.append({&"code": &"INVALID_ENTRY", &"index": index, &"errors": entry_errors}) + continue + if not accounts.has(entry.debit_account) or not accounts.has(entry.credit_account): + errors.append({&"code": &"UNKNOWN_ACCOUNT", &"index": index}) + continue + if not _system_account_use_is_valid(entry): + errors.append({&"code": &"INVALID_SYSTEM_ACCOUNT_USE", &"index": index}) + if not entry.related_transaction_id.is_empty() \ + and entry.related_transaction_id == entry.transaction_id: + errors.append({&"code": &"SELF_RELATED_TRANSACTION", &"index": index}) + + if active_transaction != entry.transaction_id: + if not active_transaction.is_empty(): + _finalize_calculated_transaction( + active_transaction, + transaction_start, + transaction_count, + transaction_delta, + accounts, + balances, + transactions, + idempotency, + errors + ) + closed_transactions[active_transaction] = true + if closed_transactions.has(entry.transaction_id): + errors.append({&"code": &"NON_CONTIGUOUS_TRANSACTION", &"index": index}) + active_transaction = entry.transaction_id + transaction_start = index + transaction_count = 0 + transaction_delta = {} + transaction_count += 1 + transaction_delta[entry.debit_account] = int(transaction_delta.get(entry.debit_account, 0)) + entry.amount + transaction_delta[entry.credit_account] = int(transaction_delta.get(entry.credit_account, 0)) - entry.amount + total_debit += entry.amount + total_credit += entry.amount + + if not active_transaction.is_empty(): + _finalize_calculated_transaction( + active_transaction, + transaction_start, + transaction_count, + transaction_delta, + accounts, + balances, + transactions, + idempotency, + errors + ) + if total_debit != total_credit: + errors.append({&"code": &"LEDGER_IMBALANCE"}) + for entry: LedgerEntry in ledger: + if not entry.related_transaction_id.is_empty() \ + and not transactions.has(entry.related_transaction_id): + errors.append({ + &"code": &"MISSING_RELATED_TRANSACTION", + &"transaction_id": entry.transaction_id, + }) + for key: Variant in idempotency: + var related_id := StringName(idempotency[key]) + if StringName(key).is_empty() or not transactions.has(related_id): + errors.append({&"code": &"INVALID_IDEMPOTENCY_REFERENCE", &"key": key}) + return { + &"errors": errors, + &"balances": balances, + &"transactions": transactions, + &"total_debit": total_debit, + &"total_credit": total_credit, + } + + +func _finalize_calculated_transaction( + transaction_id: StringName, + start_index: int, + entry_count: int, + delta: Dictionary, + accounts: Dictionary, + balances: Dictionary, + transactions: Dictionary, + idempotency: Dictionary, + errors: Array[Dictionary] +) -> void: + if transactions.has(transaction_id): + errors.append({&"code": &"DUPLICATE_TRANSACTION_ID", &"transaction_id": transaction_id}) + return + var candidate := balances.duplicate(true) + for account_id: Variant in delta: + candidate[account_id] = int(candidate.get(account_id, 0)) + int(delta[account_id]) + for account_id: Variant in candidate: + var definition: Dictionary = accounts.get(account_id, {}) + if int(candidate[account_id]) < 0 and not bool(definition.get(&"allow_negative", false)): + errors.append({ + &"code": &"NEGATIVE_BALANCE_NOT_ALLOWED", + &"transaction_id": transaction_id, + &"account_id": account_id, + }) + return + balances.clear() + balances.merge(candidate, true) + var idempotency_key: StringName = &"" + for key: Variant in idempotency: + if StringName(idempotency[key]) == transaction_id: + idempotency_key = StringName(key) + break + transactions[transaction_id] = { + &"start_index": start_index, + &"entry_count": entry_count, + &"idempotency_key": idempotency_key, + } + + +static func _entries_to_dictionaries(entries: Array[LedgerEntry]) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for entry: LedgerEntry in entries: + result.append(entry.to_dictionary()) + return result + + +static func _sorted_string_name_keys(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for key: Variant in source: + result.append(StringName(key)) + result.sort_custom(func(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + ) + return result + + +static func _sorted_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + for key: StringName in _sorted_string_name_keys(source): + result[key] = source[key] + return result + + +static func _variant_string_less(left: Variant, right: Variant) -> bool: + return String(left) < String(right) + + +static func _failure( + code: CommandResult.Code, + message: String, + details: Dictionary = {} +) -> CommandResult: + return CommandResultType.failed(code, 0, message, details) diff --git a/scripts/economy/economy_service.gd.uid b/scripts/economy/economy_service.gd.uid new file mode 100644 index 0000000..ad9116c --- /dev/null +++ b/scripts/economy/economy_service.gd.uid @@ -0,0 +1 @@ +uid://03xji7ptsclm diff --git a/scripts/economy/ledger_backed_resource_state.gd b/scripts/economy/ledger_backed_resource_state.gd new file mode 100644 index 0000000..c32b408 --- /dev/null +++ b/scripts/economy/ledger_backed_resource_state.gd @@ -0,0 +1,146 @@ +class_name LedgerBackedResourceState +extends RefCounted + +## Construction/operating adapter that keeps Gold authoritative in the ledger +## while preserving the generic resource-state contract used by services. + +const PLAYER_CASH := &"PLAYER_CASH" + +var _state: Variant +var _economy: EconomyService +var _counter_account: StringName +var _expense_category: StringName +var _refund_category: StringName +var _source_entity_id: StringName +var _minute_provider: Callable + + +func _init( + state: Variant, + economy: EconomyService, + counter_account: StringName, + expense_category: StringName, + refund_category: StringName, + source_entity_id: StringName, + minute_provider: Callable = Callable() +) -> void: + _state = state + _economy = economy + _counter_account = counter_account + _expense_category = expense_category + _refund_category = refund_category + _source_entity_id = source_entity_id + _minute_provider = minute_provider + + +func get_resource(resource_name: StringName) -> int: + if resource_name == &"gold": + return _economy.get_balance(PLAYER_CASH) + return int(_state.call(&"get_resource", resource_name)) + + +func can_afford(cost: Dictionary) -> bool: + for raw_name: Variant in cost: + var resource_name := StringName(raw_name) + var amount := int(cost[raw_name]) + if amount < 0 or get_resource(resource_name) < amount: + return false + return true + + +func spend(cost: Dictionary) -> bool: + if not can_afford(cost): + return false + var deltas: Dictionary = {} + for raw_name: Variant in cost: + deltas[StringName(raw_name)] = -int(cost[raw_name]) + return apply_resource_deltas(deltas) + + +func apply_resource_deltas(deltas: Dictionary) -> bool: + if not _validate_deltas(deltas): + return false + var before := create_snapshot() + var non_gold: Dictionary = {} + var gold_delta := 0 + for raw_name: Variant in deltas: + var resource_name := StringName(raw_name) + if resource_name == &"gold": + gold_delta += int(deltas[raw_name]) + else: + non_gold[resource_name] = int(deltas[raw_name]) + if not non_gold.is_empty() and not bool(_state.call(&"apply_resource_deltas", non_gold)): + return false + + if gold_delta != 0: + var result: CommandResult + if gold_delta < 0: + result = _economy.transfer( + PLAYER_CASH, + _counter_account, + -gold_delta, + _current_minute(), + _expense_category, + _source_entity_id + ) + else: + result = _economy.transfer( + _counter_account, + PLAYER_CASH, + gold_delta, + _current_minute(), + _refund_category, + _source_entity_id + ) + if not result.is_success(): + restore_snapshot(before) + return false + _sync_gold_mirror() + return true + + +func create_snapshot() -> Dictionary: + return { + &"state": _state.call(&"create_snapshot"), + &"economy": _economy.create_snapshot(), + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if not snapshot.get(&"state", null) is Dictionary \ + or not snapshot.get(&"economy", null) is Dictionary: + return false + var previous_state: Dictionary = _state.call(&"create_snapshot") + var previous_economy: Dictionary = _economy.create_snapshot() + if not _economy.restore_snapshot(snapshot[&"economy"]): + return false + if not bool(_state.call(&"restore_snapshot", snapshot[&"state"])): + _economy.restore_snapshot(previous_economy) + _state.call(&"restore_snapshot", previous_state) + return false + if int(_state.call(&"get_resource", &"gold")) != _economy.get_balance(PLAYER_CASH): + _economy.restore_snapshot(previous_economy) + _state.call(&"restore_snapshot", previous_state) + return false + return true + + +func _validate_deltas(deltas: Dictionary) -> bool: + var next_values: Dictionary = {} + for raw_name: Variant in deltas: + var resource_name := StringName(raw_name) + var next_value := int(next_values.get(resource_name, get_resource(resource_name))) + int(deltas[raw_name]) + if next_value < 0: + return false + next_values[resource_name] = next_value + return true + + +func _sync_gold_mirror() -> void: + _state.call(&"set_resource", &"gold", _economy.get_balance(PLAYER_CASH)) + + +func _current_minute() -> int: + if _minute_provider.is_valid(): + return maxi(int(_minute_provider.call()), 0) + return _economy.current_minute diff --git a/scripts/economy/ledger_backed_resource_state.gd.uid b/scripts/economy/ledger_backed_resource_state.gd.uid new file mode 100644 index 0000000..0a92f6e --- /dev/null +++ b/scripts/economy/ledger_backed_resource_state.gd.uid @@ -0,0 +1 @@ +uid://b3l5lmaxndas7 diff --git a/scripts/economy/ledger_entry.gd b/scripts/economy/ledger_entry.gd new file mode 100644 index 0000000..c787e81 --- /dev/null +++ b/scripts/economy/ledger_entry.gd @@ -0,0 +1,131 @@ +class_name LedgerEntry +extends RefCounted + +## One immutable-in-practice double-entry ledger line. +## +## A debit increases the signed balance of debit_account and a credit decreases +## the signed balance of credit_account. EconomyService is the only component +## which may append entries or update its derived balance cache. + +var transaction_id: StringName = &"" +var absolute_minute := 0 +var debit_account: StringName = &"" +var credit_account: StringName = &"" +var amount := 0 +var category: StringName = &"" +var source_entity_id: StringName = &"" +var related_transaction_id: StringName = &"" +var memo_key: StringName = &"" + + +func configure( + p_transaction_id: StringName, + p_absolute_minute: int, + p_debit_account: StringName, + p_credit_account: StringName, + p_amount: int, + p_category: StringName, + p_source_entity_id: StringName = &"", + p_related_transaction_id: StringName = &"", + p_memo_key: StringName = &"" +) -> LedgerEntry: + transaction_id = p_transaction_id + absolute_minute = p_absolute_minute + debit_account = p_debit_account + credit_account = p_credit_account + amount = p_amount + category = p_category + source_entity_id = p_source_entity_id + related_transaction_id = p_related_transaction_id + memo_key = p_memo_key + return self + + +func validation_errors() -> Array[StringName]: + var errors: Array[StringName] = [] + if transaction_id.is_empty(): + errors.append(&"EMPTY_TRANSACTION_ID") + if absolute_minute < 0: + errors.append(&"INVALID_ABSOLUTE_MINUTE") + if debit_account.is_empty(): + errors.append(&"EMPTY_DEBIT_ACCOUNT") + if credit_account.is_empty(): + errors.append(&"EMPTY_CREDIT_ACCOUNT") + if not debit_account.is_empty() and debit_account == credit_account: + errors.append(&"IDENTICAL_ACCOUNTS") + if amount <= 0: + errors.append(&"INVALID_AMOUNT") + if category.is_empty(): + errors.append(&"EMPTY_CATEGORY") + if not related_transaction_id.is_empty() and related_transaction_id == transaction_id: + errors.append(&"SELF_RELATED_TRANSACTION") + return errors + + +func is_valid() -> bool: + return validation_errors().is_empty() + + +func reversed( + p_transaction_id: StringName, + p_absolute_minute: int, + p_category: StringName = &"REVERSAL", + p_source_entity_id: StringName = &"", + p_memo_key: StringName = &"" +) -> LedgerEntry: + return LedgerEntry.new().configure( + p_transaction_id, + p_absolute_minute, + credit_account, + debit_account, + amount, + p_category, + p_source_entity_id if not p_source_entity_id.is_empty() else source_entity_id, + transaction_id, + p_memo_key + ) + + +func copy() -> LedgerEntry: + return LedgerEntry.new().configure( + transaction_id, + absolute_minute, + debit_account, + credit_account, + amount, + category, + source_entity_id, + related_transaction_id, + memo_key + ) + + +func to_dictionary() -> Dictionary: + return { + &"transaction_id": String(transaction_id), + &"absolute_minute": absolute_minute, + &"debit_account": String(debit_account), + &"credit_account": String(credit_account), + &"amount": amount, + &"category": String(category), + &"source_entity_id": String(source_entity_id), + &"related_transaction_id": String(related_transaction_id), + &"memo_key": String(memo_key), + } + + +static func from_dictionary(data: Dictionary) -> LedgerEntry: + return LedgerEntry.new().configure( + StringName(data.get(&"transaction_id", data.get("transaction_id", ""))), + int(data.get(&"absolute_minute", data.get("absolute_minute", -1))), + StringName(data.get(&"debit_account", data.get("debit_account", ""))), + StringName(data.get(&"credit_account", data.get("credit_account", ""))), + int(data.get(&"amount", data.get("amount", 0))), + StringName(data.get(&"category", data.get("category", ""))), + StringName(data.get(&"source_entity_id", data.get("source_entity_id", ""))), + StringName(data.get( + &"related_transaction_id", + data.get("related_transaction_id", "") + )), + StringName(data.get(&"memo_key", data.get("memo_key", ""))) + ) diff --git a/scripts/economy/ledger_entry.gd.uid b/scripts/economy/ledger_entry.gd.uid new file mode 100644 index 0000000..a102330 --- /dev/null +++ b/scripts/economy/ledger_entry.gd.uid @@ -0,0 +1 @@ +uid://rrovqycon673 diff --git a/scripts/events/event_action.gd b/scripts/events/event_action.gd new file mode 100644 index 0000000..5211606 --- /dev/null +++ b/scripts/events/event_action.gd @@ -0,0 +1,68 @@ +class_name EventAction +extends RefCounted + +enum Type { + SET_FLAG, + ADD_RELATIONSHIP_VALUE, + DAMAGE_SHIELD, + ADD_LEDGER_ENTRY, + ADD_OR_REMOVE_ITEM, + CREATE_TASK, + CREATE_INCIDENT, + SET_FACILITY_STATE, + TELEPORT_ENTITY_FOR_RECOVERY, + SCHEDULE_EVENT, + SET_ROUTE_LOCK, +} + +var action_id: StringName = &"" +var type: Type = Type.SET_FLAG +var payload: Dictionary = {} + + +func configure( + p_action_id: StringName, + p_type: Variant, + p_payload: Dictionary = {} +) -> EventAction: + action_id = p_action_id + type = _type_from_variant(p_type) + payload = p_payload.duplicate(true) + return self + + +func is_valid() -> bool: + return not action_id.is_empty() and type >= Type.SET_FLAG and type <= Type.SET_ROUTE_LOCK + + +func create_snapshot() -> Dictionary: + return { + &"action_id": String(action_id), + &"type": Type.keys()[type], + &"payload": payload.duplicate(true), + } + + +static func from_variant(raw_value: Variant) -> EventAction: + if raw_value is EventAction: + return raw_value + if not raw_value is Dictionary: + return null + return EventAction.new().configure( + StringName(_value(raw_value, &"action_id", &"")), + _value(raw_value, &"type", "SET_FLAG"), + _value(raw_value, &"payload", {}) if _value(raw_value, &"payload", {}) is Dictionary else {} + ) + + +static func _type_from_variant(raw_value: Variant) -> Type: + if raw_value is int: + return clampi(int(raw_value), Type.SET_FLAG, Type.SET_ROUTE_LOCK) as Type + var index := Type.keys().find(String(raw_value).to_upper()) + return (index if index >= 0 else Type.SET_FLAG) as Type + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) diff --git a/scripts/events/event_action.gd.uid b/scripts/events/event_action.gd.uid new file mode 100644 index 0000000..2a424e8 --- /dev/null +++ b/scripts/events/event_action.gd.uid @@ -0,0 +1 @@ +uid://5wriphjpijed diff --git a/scripts/events/event_choice.gd b/scripts/events/event_choice.gd new file mode 100644 index 0000000..51927f2 --- /dev/null +++ b/scripts/events/event_choice.gd @@ -0,0 +1,105 @@ +class_name EventChoice +extends RefCounted + +const EventConditionType = preload("res://scripts/events/event_condition.gd") +const EventActionType = preload("res://scripts/events/event_action.gd") + +var choice_id: StringName = &"" +var text_key: StringName = &"" +var conditions: Array[EventCondition] = [] +var cost: Dictionary = {} +var effect_preview: Dictionary = {} +var actions: Array[EventAction] = [] + + +func configure( + p_choice_id: StringName, + p_text_key: StringName, + p_conditions: Array = [], + p_cost: Dictionary = {}, + p_effect_preview: Dictionary = {}, + p_actions: Array = [] +) -> EventChoice: + choice_id = p_choice_id + text_key = p_text_key + conditions.clear() + for raw_condition: Variant in p_conditions: + var condition := EventConditionType.from_variant(raw_condition) + if condition != null: + conditions.append(condition) + cost = p_cost.duplicate(true) + effect_preview = p_effect_preview.duplicate(true) + actions.clear() + for raw_action: Variant in p_actions: + var action := EventActionType.from_variant(raw_action) + if action != null: + actions.append(action) + return self + + +func is_valid() -> bool: + if choice_id.is_empty() or text_key.is_empty(): + return false + for condition: EventCondition in conditions: + if condition == null or not condition.is_valid(): + return false + for action: EventAction in actions: + if action == null or not action.is_valid(): + return false + for raw_key: Variant in cost: + if StringName(raw_key).is_empty() or int(cost[raw_key]) < 0: + return false + return true + + +func conditions_match(context: Dictionary, callbacks: Dictionary = {}) -> bool: + for condition: EventCondition in conditions: + if not condition.evaluate(context, callbacks): + return false + return true + + +func create_snapshot() -> Dictionary: + var condition_snapshots: Array[Dictionary] = [] + var action_snapshots: Array[Dictionary] = [] + for condition: EventCondition in conditions: + condition_snapshots.append(condition.create_snapshot()) + for action: EventAction in actions: + action_snapshots.append(action.create_snapshot()) + return { + &"choice_id": String(choice_id), + &"text_key": String(text_key), + &"conditions": condition_snapshots, + &"cost": cost.duplicate(true), + &"effect_preview": effect_preview.duplicate(true), + &"actions": action_snapshots, + } + + +static func from_variant(raw_value: Variant) -> EventChoice: + if raw_value is EventChoice: + return raw_value + if not raw_value is Dictionary: + return null + return EventChoice.new().configure( + StringName(_value(raw_value, &"choice_id", &"")), + StringName(_value(raw_value, &"text_key", &"")), + _array(_value(raw_value, &"conditions", [])), + _dictionary(_value(raw_value, &"cost", {})), + _dictionary(_value(raw_value, &"effect_preview", {})), + _array(_value(raw_value, &"actions", [])) + ) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _array(value: Variant) -> Array: + return value if value is Array else [] + + +static func _dictionary(value: Variant) -> Dictionary: + return value if value is Dictionary else {} diff --git a/scripts/events/event_choice.gd.uid b/scripts/events/event_choice.gd.uid new file mode 100644 index 0000000..15b6807 --- /dev/null +++ b/scripts/events/event_choice.gd.uid @@ -0,0 +1 @@ +uid://d0r5rxpsaqgd2 diff --git a/scripts/events/event_condition.gd b/scripts/events/event_condition.gd new file mode 100644 index 0000000..f398a3a --- /dev/null +++ b/scripts/events/event_condition.gd @@ -0,0 +1,214 @@ +class_name EventCondition +extends RefCounted + +## Side-effect-free, data-only event predicate. + +enum Operator { + EQ, + NE, + LT, + LTE, + GT, + GTE, + HAS_FLAG, + NOT_HAS_FLAG, + IN_RANGE, + ALL, + ANY, + NOT, + ENTITY_EXISTS, + PATH_EXISTS, + TIME_IN_WINDOW, +} + +var operator: Operator = Operator.EQ +var path: Array[StringName] = [] +var expected: Variant = null +var children: Array[EventCondition] = [] + + +func configure( + p_operator: Variant, + p_path: Variant = [], + p_expected: Variant = null, + p_children: Array = [] +) -> EventCondition: + operator = _operator_from_variant(p_operator) + path = _normalize_path(p_path) + expected = _copy(p_expected) + children.clear() + for raw_child: Variant in p_children: + var child := from_variant(raw_child) + if child != null: + children.append(child) + return self + + +func is_valid() -> bool: + if operator < Operator.EQ or operator > Operator.TIME_IN_WINDOW: + return false + if operator in [Operator.ALL, Operator.ANY] and children.is_empty(): + return false + if operator == Operator.NOT and children.size() != 1: + return false + if operator not in [Operator.ALL, Operator.ANY, Operator.NOT, Operator.TIME_IN_WINDOW] and path.is_empty(): + return false + for child: EventCondition in children: + if child == null or not child.is_valid(): + return false + return true + + +func evaluate(context: Dictionary, callbacks: Dictionary = {}) -> bool: + match operator: + Operator.ALL: + for child: EventCondition in children: + if not child.evaluate(context, callbacks): + return false + return true + Operator.ANY: + for child: EventCondition in children: + if child.evaluate(context, callbacks): + return true + return false + Operator.NOT: + return not children[0].evaluate(context, callbacks) + Operator.TIME_IN_WINDOW: + var minute := int(_resolve(context, path)) if not path.is_empty() else int(_dictionary_value(context, &"absolute_minute", 0)) + return _time_in_window(minute, expected) + + var actual: Variant = _resolve(context, path) + match operator: + Operator.EQ: + return actual == expected + Operator.NE: + return actual != expected + Operator.LT: + return _numeric(actual) < _numeric(expected) + Operator.LTE: + return _numeric(actual) <= _numeric(expected) + Operator.GT: + return _numeric(actual) > _numeric(expected) + Operator.GTE: + return _numeric(actual) >= _numeric(expected) + Operator.HAS_FLAG: + return _has_flag(actual, expected) + Operator.NOT_HAS_FLAG: + return not _has_flag(actual, expected) + Operator.IN_RANGE: + return _in_range(actual, expected) + Operator.ENTITY_EXISTS: + return _callback_predicate(callbacks, &"entity_exists", actual) + Operator.PATH_EXISTS: + return _callback_predicate(callbacks, &"path_exists", actual) + return false + + +func create_snapshot() -> Dictionary: + var child_snapshots: Array[Dictionary] = [] + for child: EventCondition in children: + child_snapshots.append(child.create_snapshot()) + var path_strings: Array[String] = [] + for part: StringName in path: + path_strings.append(String(part)) + return { + &"operator": Operator.keys()[operator], + &"path": path_strings, + &"expected": _copy(expected), + &"children": child_snapshots, + } + + +static func from_variant(raw_value: Variant) -> EventCondition: + if raw_value is EventCondition: + return raw_value + if not raw_value is Dictionary: + return null + var raw_children: Variant = _dictionary_value(raw_value, &"children", []) + return EventCondition.new().configure( + _dictionary_value(raw_value, &"operator", "EQ"), + _dictionary_value(raw_value, &"path", []), + _dictionary_value(raw_value, &"expected", _dictionary_value(raw_value, &"value", null)), + raw_children if raw_children is Array else [] + ) + + +static func _operator_from_variant(raw_value: Variant) -> Operator: + if raw_value is int: + return clampi(int(raw_value), Operator.EQ, Operator.TIME_IN_WINDOW) as Operator + var index := Operator.keys().find(String(raw_value).to_upper()) + return (index if index >= 0 else Operator.EQ) as Operator + + +static func _normalize_path(raw_value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if raw_value is String or raw_value is StringName: + for part: String in String(raw_value).split(".", false): + result.append(StringName(part)) + elif raw_value is Array: + for raw_part: Variant in raw_value: + var part := StringName(raw_part) + if not part.is_empty(): + result.append(part) + return result + + +static func _resolve(context: Dictionary, field_path: Array[StringName]) -> Variant: + var current: Variant = context + for part: StringName in field_path: + if not current is Dictionary: + return null + current = _dictionary_value(current, part, null) + return current + + +static func _numeric(value: Variant) -> float: + return float(value) if value is int or value is float else 0.0 + + +static func _has_flag(actual: Variant, flag_value: Variant) -> bool: + var flag := StringName(flag_value) + if actual is Dictionary: + return bool(_dictionary_value(actual, flag, false)) + if actual is Array: + for raw_entry: Variant in actual: + if StringName(raw_entry) == flag: + return true + return false + + +static func _in_range(actual: Variant, bounds: Variant) -> bool: + if not bounds is Array or bounds.size() < 2: + return false + var numeric := _numeric(actual) + return numeric >= _numeric(bounds[0]) and numeric <= _numeric(bounds[1]) + + +static func _time_in_window(absolute_minute: int, raw_window: Variant) -> bool: + if not raw_window is Array or raw_window.size() < 2: + return false + var minute_of_day := posmod(absolute_minute, 1_440) + var start := posmod(int(raw_window[0]), 1_440) + var finish := posmod(int(raw_window[1]), 1_440) + if start == finish: + return true + if start < finish: + return minute_of_day >= start and minute_of_day < finish + return minute_of_day >= start or minute_of_day < finish + + +static func _callback_predicate(callbacks: Dictionary, callback_id: StringName, value: Variant) -> bool: + var callback: Variant = _dictionary_value(callbacks, callback_id, Callable()) + if callback is Callable and callback.is_valid(): + return bool(callback.call(value)) + return false + + +static func _dictionary_value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _copy(value: Variant) -> Variant: + return value.duplicate(true) if value is Dictionary or value is Array else value diff --git a/scripts/events/event_condition.gd.uid b/scripts/events/event_condition.gd.uid new file mode 100644 index 0000000..9cd76cb --- /dev/null +++ b/scripts/events/event_condition.gd.uid @@ -0,0 +1 @@ +uid://6enckempkxxo diff --git a/scripts/events/event_definition.gd b/scripts/events/event_definition.gd new file mode 100644 index 0000000..72cceb6 --- /dev/null +++ b/scripts/events/event_definition.gd @@ -0,0 +1,265 @@ +class_name EventDefinition +extends RefCounted + +const EventConditionType = preload("res://scripts/events/event_condition.gd") +const EventActionType = preload("res://scripts/events/event_action.gd") +const EventChoiceType = preload("res://scripts/events/event_choice.gd") + +enum TransactionPolicy { + ROLLBACK_ALL, + SKIP_FAILED, + BRANCH_FAILURE, +} + +enum OnceScope { + NONE, + PER_DAY, + PER_ACTOR_PAIR, + PER_SAVE, +} + +enum MissingActorPolicy { + CANCEL, + REPLACE_WITH_ROLE, + CONTINUE_WITHOUT, + BRANCH_FAILURE, +} + +enum ContentRating { + GENERAL, + MATURE, + RESTRICTED, +} + +enum FilterBehavior { + NONE, + FADE, + REPLACE, + DISABLE, +} + +var event_id: StringName = &"" +var category: StringName = &"FLAVOR" +var priority := 0 +var conditions: Array[EventCondition] = [] +var participant_roles: Array[StringName] = [] +var location_rule: Dictionary = {} +var start_window: Array[int] = [] +var expires_after := 0 +var foreground := true +var interrupt_policy: StringName = &"NORMAL" +var choices: Array[EventChoice] = [] +var automatic_actions: Array[EventAction] = [] +var fallback_actions: Array[EventAction] = [] +var failure_actions: Array[EventAction] = [] +var replacement_event_id: StringName = &"" +var once_scope: OnceScope = OnceScope.NONE +var transaction_policy: TransactionPolicy = TransactionPolicy.ROLLBACK_ALL +var missing_actor_policy: MissingActorPolicy = MissingActorPolicy.CANCEL +var content_rating: ContentRating = ContentRating.GENERAL +var content_tags: Array[StringName] = [] +var filter_behavior: FilterBehavior = FilterBehavior.NONE + + +func configure(p_event_id: StringName, options: Dictionary = {}) -> EventDefinition: + event_id = p_event_id + category = StringName(_value(options, &"category", &"FLAVOR")) + priority = int(_value(options, &"priority", 0)) + conditions = _conditions(_value(options, &"conditions", [])) + participant_roles = _sorted_unique_names(_value(options, &"participants", [])) + location_rule = _dictionary(_value(options, &"location_rule", {})).duplicate(true) + start_window = _integer_array(_value(options, &"start_window", [])) + expires_after = maxi(int(_value(options, &"expires_after", 0)), 0) + foreground = bool(_value(options, &"foreground", true)) + interrupt_policy = StringName(_value(options, &"interrupt_policy", &"NORMAL")) + choices = _choices(_value(options, &"choices", [])) + automatic_actions = _actions(_value(options, &"automatic_actions", [])) + fallback_actions = _actions(_value(options, &"fallback_actions", [])) + failure_actions = _actions(_value(options, &"failure_actions", [])) + replacement_event_id = StringName(_value(options, &"replacement_event_id", &"")) + once_scope = _enum_value(OnceScope.keys(), _value(options, &"once_scope", OnceScope.NONE), OnceScope.NONE) as OnceScope + transaction_policy = _enum_value(TransactionPolicy.keys(), _value(options, &"transaction_policy", TransactionPolicy.ROLLBACK_ALL), TransactionPolicy.ROLLBACK_ALL) as TransactionPolicy + missing_actor_policy = _enum_value(MissingActorPolicy.keys(), _value(options, &"missing_actor_policy", MissingActorPolicy.CANCEL), MissingActorPolicy.CANCEL) as MissingActorPolicy + content_rating = _enum_value(ContentRating.keys(), _value(options, &"content_rating", ContentRating.GENERAL), ContentRating.GENERAL) as ContentRating + content_tags = _sorted_unique_names(_value(options, &"content_tags", [])) + filter_behavior = _enum_value(FilterBehavior.keys(), _value(options, &"filter_behavior", FilterBehavior.NONE), FilterBehavior.NONE) as FilterBehavior + return self + + +func is_valid() -> bool: + if event_id.is_empty() or category.is_empty(): + return false + if filter_behavior == FilterBehavior.REPLACE and replacement_event_id.is_empty(): + return false + if replacement_event_id == event_id: + return false + if start_window.size() not in [0, 2]: + return false + for condition: EventCondition in conditions: + if condition == null or not condition.is_valid(): + return false + var choice_ids: Dictionary = {} + for choice: EventChoice in choices: + if choice == null or not choice.is_valid() or choice_ids.has(choice.choice_id): + return false + choice_ids[choice.choice_id] = true + for action_list: Array in [automatic_actions, fallback_actions, failure_actions]: + for action: EventAction in action_list: + if action == null or not action.is_valid(): + return false + return true + + +func get_choice(choice_id: StringName) -> EventChoice: + for choice: EventChoice in choices: + if choice.choice_id == choice_id: + return choice + return null + + +func conditions_match(context: Dictionary, callbacks: Dictionary = {}) -> bool: + for condition: EventCondition in conditions: + if not condition.evaluate(context, callbacks): + return false + return true + + +func create_snapshot() -> Dictionary: + return { + &"event_id": String(event_id), + &"category": String(category), + &"priority": priority, + &"conditions": _snapshots(conditions), + &"participants": _names_to_strings(participant_roles), + &"location_rule": location_rule.duplicate(true), + &"start_window": start_window.duplicate(), + &"expires_after": expires_after, + &"foreground": foreground, + &"interrupt_policy": String(interrupt_policy), + &"choices": _snapshots(choices), + &"automatic_actions": _snapshots(automatic_actions), + &"fallback_actions": _snapshots(fallback_actions), + &"failure_actions": _snapshots(failure_actions), + &"replacement_event_id": String(replacement_event_id), + &"once_scope": OnceScope.keys()[once_scope], + &"transaction_policy": TransactionPolicy.keys()[transaction_policy], + &"missing_actor_policy": MissingActorPolicy.keys()[missing_actor_policy], + &"content_rating": ContentRating.keys()[content_rating], + &"content_tags": _names_to_strings(content_tags), + &"filter_behavior": FilterBehavior.keys()[filter_behavior], + } + + +static func from_variant(raw_value: Variant) -> EventDefinition: + if raw_value is EventDefinition: + return raw_value + if not raw_value is Dictionary: + return null + # Serialized definitions are authoritative data, not a user-facing settings + # form. Unknown enum names and out-of-range integer values must fail closed + # instead of silently selecting a less restrictive fallback. + var enum_fields := [ + [&"once_scope", OnceScope.keys()], + [&"transaction_policy", TransactionPolicy.keys()], + [&"missing_actor_policy", MissingActorPolicy.keys()], + [&"content_rating", ContentRating.keys()], + [&"filter_behavior", FilterBehavior.keys()], + ] + for field: Array in enum_fields: + var key: StringName = field[0] + if raw_value.has(key) or raw_value.has(String(key)): + if _strict_enum_value(field[1], _value(raw_value, key, null)) < 0: + return null + return EventDefinition.new().configure( + StringName(_value(raw_value, &"event_id", &"")), + raw_value + ) + + +static func _conditions(raw_value: Variant) -> Array[EventCondition]: + var result: Array[EventCondition] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + var entry := EventConditionType.from_variant(raw_entry) + if entry != null: + result.append(entry) + return result + + +static func _choices(raw_value: Variant) -> Array[EventChoice]: + var result: Array[EventChoice] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + var entry := EventChoiceType.from_variant(raw_entry) + if entry != null: + result.append(entry) + return result + + +static func _actions(raw_value: Variant) -> Array[EventAction]: + var result: Array[EventAction] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + var entry := EventActionType.from_variant(raw_entry) + if entry != null: + result.append(entry) + return result + + +static func _snapshots(values: Array) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for value: Variant in values: + result.append(value.create_snapshot()) + return result + + +static func _sorted_unique_names(raw_value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + var entry := StringName(raw_entry) + if not entry.is_empty() and not result.has(entry): + result.append(entry) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _integer_array(raw_value: Variant) -> Array[int]: + var result: Array[int] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + result.append(int(raw_entry)) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _enum_value(keys: Array, raw_value: Variant, fallback: int) -> int: + if raw_value is int: + return clampi(int(raw_value), 0, keys.size() - 1) + var index := keys.find(String(raw_value).to_upper()) + return index if index >= 0 else fallback + + +static func _strict_enum_value(keys: Array, raw_value: Variant) -> int: + if raw_value is int: + var numeric := int(raw_value) + return numeric if numeric >= 0 and numeric < keys.size() else -1 + if raw_value is String or raw_value is StringName: + return keys.find(String(raw_value).to_upper()) + return -1 + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _dictionary(value: Variant) -> Dictionary: + return value if value is Dictionary else {} diff --git a/scripts/events/event_definition.gd.uid b/scripts/events/event_definition.gd.uid new file mode 100644 index 0000000..0e13753 --- /dev/null +++ b/scripts/events/event_definition.gd.uid @@ -0,0 +1 @@ +uid://cvyev0x3oeq1f diff --git a/scripts/events/event_instance.gd b/scripts/events/event_instance.gd new file mode 100644 index 0000000..b14afd4 --- /dev/null +++ b/scripts/events/event_instance.gd @@ -0,0 +1,151 @@ +class_name EventInstance +extends RefCounted + +enum State { + QUEUED, + RUNNING, + WAITING_CHOICE, + COMPLETED, + CANCELLED, + EXPIRED, + ERROR, + INTERRUPTED, +} + +var event_instance_id: StringName = &"" +var source_event_id: StringName = &"" +var resolved_event_id: StringName = &"" +var actor_ids: Array[StringName] = [] +var priority := 0 +var created_at := 0 +var expires_at := -1 +var foreground := true +var state: State = State.QUEUED +var deduplication_key: StringName = &"" +var context: Dictionary = {} +var presentation_mode: StringName = &"FULL" +var choice_instance_id: StringName = &"" +var selected_choice_id: StringName = &"" +var reserved_costs: Dictionary = {} +var failure_reason := "" +var retry_count := 0 + + +func configure( + p_instance_id: StringName, + p_source_event_id: StringName, + p_resolved_event_id: StringName, + p_actor_ids: Array, + p_priority: int, + p_created_at: int, + p_expires_at: int, + p_foreground: bool, + p_deduplication_key: StringName, + p_context: Dictionary = {}, + presentation: StringName = &"FULL" +) -> EventInstance: + event_instance_id = p_instance_id + source_event_id = p_source_event_id + resolved_event_id = p_resolved_event_id + actor_ids = _sorted_unique_names(p_actor_ids) + priority = p_priority + created_at = maxi(p_created_at, 0) + expires_at = p_expires_at + foreground = p_foreground + deduplication_key = p_deduplication_key + context = p_context.duplicate(true) + presentation_mode = presentation + return self + + +func is_valid() -> bool: + if event_instance_id.is_empty() or source_event_id.is_empty() or resolved_event_id.is_empty(): + return false + if created_at < 0 or (expires_at >= 0 and expires_at < created_at): + return false + if state < State.QUEUED or state > State.INTERRUPTED: + return false + return true + + +func is_terminal() -> bool: + return state in [State.COMPLETED, State.CANCELLED, State.EXPIRED, State.ERROR] + + +func create_snapshot() -> Dictionary: + return { + &"event_instance_id": String(event_instance_id), + &"source_event_id": String(source_event_id), + &"resolved_event_id": String(resolved_event_id), + &"actor_ids": _names_to_strings(actor_ids), + &"priority": priority, + &"created_at": created_at, + &"expires_at": expires_at, + &"foreground": foreground, + &"state": State.keys()[state], + &"deduplication_key": String(deduplication_key), + &"context": context.duplicate(true), + &"presentation_mode": String(presentation_mode), + &"choice_instance_id": String(choice_instance_id), + &"selected_choice_id": String(selected_choice_id), + &"reserved_costs": reserved_costs.duplicate(true), + &"failure_reason": failure_reason, + &"retry_count": retry_count, + } + + +static func from_snapshot(snapshot: Dictionary) -> EventInstance: + var instance := EventInstance.new().configure( + StringName(_value(snapshot, &"event_instance_id", &"")), + StringName(_value(snapshot, &"source_event_id", &"")), + StringName(_value(snapshot, &"resolved_event_id", &"")), + _array(_value(snapshot, &"actor_ids", [])), + int(_value(snapshot, &"priority", 0)), + int(_value(snapshot, &"created_at", 0)), + int(_value(snapshot, &"expires_at", -1)), + bool(_value(snapshot, &"foreground", true)), + StringName(_value(snapshot, &"deduplication_key", &"")), + _dictionary(_value(snapshot, &"context", {})), + StringName(_value(snapshot, &"presentation_mode", &"FULL")) + ) + var state_name := String(_value(snapshot, &"state", "QUEUED")).to_upper() + var state_index := State.keys().find(state_name) + instance.state = (state_index if state_index >= 0 else State.ERROR) as State + instance.choice_instance_id = StringName(_value(snapshot, &"choice_instance_id", &"")) + instance.selected_choice_id = StringName(_value(snapshot, &"selected_choice_id", &"")) + instance.reserved_costs = _dictionary(_value(snapshot, &"reserved_costs", {})).duplicate(true) + instance.failure_reason = String(_value(snapshot, &"failure_reason", "")) + instance.retry_count = maxi(int(_value(snapshot, &"retry_count", 0)), 0) + return instance + + +static func _sorted_unique_names(raw_value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + var entry := StringName(raw_entry) + if not entry.is_empty() and not result.has(entry): + result.append(entry) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _array(value: Variant) -> Array: + return value if value is Array else [] + + +static func _dictionary(value: Variant) -> Dictionary: + return value if value is Dictionary else {} diff --git a/scripts/events/event_instance.gd.uid b/scripts/events/event_instance.gd.uid new file mode 100644 index 0000000..f38ff23 --- /dev/null +++ b/scripts/events/event_instance.gd.uid @@ -0,0 +1 @@ +uid://c0xntlstpehhd diff --git a/scripts/events/event_service.gd b/scripts/events/event_service.gd new file mode 100644 index 0000000..58570a6 --- /dev/null +++ b/scripts/events/event_service.gd @@ -0,0 +1,1258 @@ +class_name EventService +extends RefCounted + +## Deterministic event candidate queue, foreground actor locking and transactional +## choice authority. Narrative text is deliberately outside this service; only +## localization keys, content ratings/tags and safe replacement IDs are retained. + +signal event_registered(definition: EventDefinition) +signal event_enqueued(instance: EventInstance) +signal event_started(instance: EventInstance) +signal event_finished(instance: EventInstance) +signal event_cancelled(instance: EventInstance, reason: StringName) +signal choice_resolved(instance_id: StringName, choice_id: StringName, result: Dictionary) + +const EventDefinitionType = preload("res://scripts/events/event_definition.gd") +const EventInstanceType = preload("res://scripts/events/event_instance.gd") +const EventActionType = preload("res://scripts/events/event_action.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 1 +const MAX_UNINTERRUPTIBLE_EXTENSION := 60 + +var last_error := "" +var pause_requested := false +var last_recovery_repair_report: Dictionary = {} + +var _definitions: Dictionary = {} +var _instances: Dictionary = {} +var _queue: Array[StringName] = [] +var _actor_locks: Dictionary = {} +var _foreground_instance_id: StringName = &"" +var _active_deduplication_keys: Dictionary = {} +var _processed_choices: Dictionary = {} +var _completed_scope_keys: Dictionary = {} +var _flags: Dictionary = {} +var _next_event_sequence := 1 +var _next_choice_sequence := 1 + +var _filter_mode: EventDefinition.FilterBehavior = EventDefinition.FilterBehavior.NONE +var _maximum_content_rating: EventDefinition.ContentRating = EventDefinition.ContentRating.GENERAL +var _disabled_content_tags: Array[StringName] = [] + +var _actor_exists := Callable() +var _action_executor := Callable() +var _cost_reserver := Callable() +var _cost_releaser := Callable() +var _cost_committer := Callable() +var _condition_callbacks: Dictionary = {} +var _relationship_service: Variant = null + + +func configure_callbacks( + actor_exists: Callable = Callable(), + action_executor: Callable = Callable(), + cost_reserver: Callable = Callable(), + cost_releaser: Callable = Callable(), + cost_committer: Callable = Callable(), + entity_exists: Callable = Callable(), + path_exists: Callable = Callable() +) -> EventService: + _actor_exists = actor_exists + _action_executor = action_executor + _cost_reserver = cost_reserver + _cost_releaser = cost_releaser + _cost_committer = cost_committer + _condition_callbacks = { + &"entity_exists": entity_exists, + &"path_exists": path_exists, + } + return self + + +func configure_relationship_service(service: Variant) -> EventService: + _relationship_service = service + return self + + +func configure_content_filter( + mode: EventDefinition.FilterBehavior, + maximum_rating: EventDefinition.ContentRating = EventDefinition.ContentRating.GENERAL, + disabled_tags: Array = [] +) -> EventService: + _filter_mode = mode + _maximum_content_rating = maximum_rating + _disabled_content_tags = _sorted_unique_names(disabled_tags) + return self + + +## A small, immutable boundary used by load safety. Callers must not need +## access to EventService's internal filter fields in order to preserve a +## stricter locally configured profile across snapshot restoration. +func get_content_filter_profile() -> Dictionary: + return { + &"filter_mode": _filter_mode, + &"filter_mode_name": EventDefinitionType.FilterBehavior.keys()[_filter_mode], + &"maximum_content_rating": _maximum_content_rating, + &"maximum_content_rating_name": EventDefinitionType.ContentRating.keys()[_maximum_content_rating], + &"disabled_content_tags": _disabled_content_tags.duplicate(), + } + + +func register_definition(value: Variant) -> CommandResult: + if value == null or not value is EventDefinition: + return _invalid("EventDefinition is missing or invalid.") + var definition: EventDefinition = value + if not definition.is_valid(): + return _invalid("EventDefinition fails schema validation.") + if _definitions.has(definition.event_id): + return _invalid("Duplicate EventDefinition: %s" % definition.event_id) + _definitions[definition.event_id] = definition + event_registered.emit(definition) + last_error = "" + return _success({&"event_id": definition.event_id}) + + +func create_definition(event_id: StringName, options: Dictionary = {}) -> CommandResult: + return register_definition(EventDefinitionType.new().configure(event_id, options)) + + +func get_definition(event_id: StringName) -> EventDefinition: + return _definitions.get(event_id) as EventDefinition + + +func get_definitions() -> Array[EventDefinition]: + var result: Array[EventDefinition] = [] + for event_id: StringName in _sorted_name_keys(_definitions): + result.append(_definitions[event_id]) + return result + + +func validate_definitions() -> Dictionary: + var errors: Array[String] = [] + for definition: EventDefinition in get_definitions(): + if not definition.is_valid(): + errors.append("Invalid definition: %s" % definition.event_id) + if definition.filter_behavior == EventDefinitionType.FilterBehavior.REPLACE: + if not _definitions.has(definition.replacement_event_id): + errors.append("Replacement missing: %s -> %s" % [definition.event_id, definition.replacement_event_id]) + elif _replacement_cycle_exists(definition.event_id): + errors.append("Replacement cycle: %s" % definition.event_id) + return {&"ok": errors.is_empty(), &"errors": errors} + + +func resolve_content_event(event_id: StringName) -> Dictionary: + var source := get_definition(event_id) + if source == null: + return {&"enabled": false, &"reason": &"UNKNOWN_EVENT"} + if _filter_mode == EventDefinitionType.FilterBehavior.NONE or not _is_content_restricted(source): + return { + &"enabled": true, + &"event_id": event_id, + &"presentation_mode": &"FULL", + } + var behavior := source.filter_behavior + if behavior == EventDefinitionType.FilterBehavior.NONE: + behavior = _filter_mode + match behavior: + EventDefinitionType.FilterBehavior.FADE: + return { + &"enabled": true, + &"event_id": event_id, + &"presentation_mode": &"FADE", + } + EventDefinitionType.FilterBehavior.REPLACE: + var replacement := get_definition(source.replacement_event_id) + if replacement == null or replacement.event_id == source.event_id: + return {&"enabled": false, &"reason": &"REPLACEMENT_MISSING"} + if _is_content_restricted(replacement): + return {&"enabled": false, &"reason": &"REPLACEMENT_UNSAFE"} + return { + &"enabled": true, + &"event_id": replacement.event_id, + &"presentation_mode": &"REPLACED", + } + EventDefinitionType.FilterBehavior.DISABLE: + return {&"enabled": false, &"reason": &"CONTENT_DISABLED"} + return {&"enabled": false, &"reason": &"CONTENT_DISABLED"} + + +func enqueue_candidate( + event_id: StringName, + actor_ids: Array, + absolute_minute: int, + options: Dictionary = {} +) -> CommandResult: + var source := get_definition(event_id) + if source == null: + return _not_found("EventDefinition", event_id) + if absolute_minute < 0: + return _invalid("Event candidate requires a non-negative minute.") + var content_resolution := resolve_content_event(event_id) + if not bool(content_resolution.get(&"enabled", false)): + return _invalid("Event candidate was disabled by Content Filter.", content_resolution) + var resolved_event_id := StringName(content_resolution[&"event_id"]) + var definition := get_definition(resolved_event_id) + if definition == null: + return _invalid("Resolved EventDefinition is unavailable.") + var normalized_actor_ids := _sorted_unique_names(actor_ids) + var deduplication_key := StringName(_value( + options, + &"deduplication_key", + _default_deduplication_key(event_id, normalized_actor_ids) + )) + if not deduplication_key.is_empty() and _active_deduplication_keys.has(deduplication_key): + return _invalid("Duplicate active Event candidate.", { + &"existing_instance_id": _active_deduplication_keys[deduplication_key], + }) + var scope_key := _scope_key(source, normalized_actor_ids, absolute_minute) + if not scope_key.is_empty() and _completed_scope_keys.has(scope_key): + return _invalid("Event once-scope has already completed.", {&"scope_key": scope_key}) + var instance_id := StringName(_value(options, &"event_instance_id", &"")) + var generated_id := instance_id.is_empty() + if generated_id: + instance_id = StringName("event_instance_%08d" % _next_event_sequence) + if _instances.has(instance_id): + return _invalid("Duplicate Event instance ID: %s" % instance_id) + var expires_at := int(_value(options, &"expires_at", -1)) + if expires_at < 0 and definition.expires_after > 0: + expires_at = absolute_minute + definition.expires_after + if expires_at >= 0 and expires_at < absolute_minute: + return _invalid("Event expiry cannot precede creation.") + var context := _dictionary(_value(options, &"context", {})).duplicate(true) + context[&"absolute_minute"] = absolute_minute + context[&"actor_ids"] = normalized_actor_ids.duplicate() + var instance := EventInstanceType.new().configure( + instance_id, + event_id, + resolved_event_id, + normalized_actor_ids, + definition.priority, + absolute_minute, + expires_at, + definition.foreground, + deduplication_key, + context, + StringName(content_resolution.get(&"presentation_mode", &"FULL")) + ) + _instances[instance_id] = instance + _queue.append(instance_id) + if not deduplication_key.is_empty(): + _active_deduplication_keys[deduplication_key] = instance_id + if generated_id: + _next_event_sequence += 1 + _sort_queue() + event_enqueued.emit(instance) + last_error = "" + return _success({ + &"event_instance_id": instance_id, + &"resolved_event_id": resolved_event_id, + &"presentation_mode": instance.presentation_mode, + }) + + +func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> CommandResult: + if absolute_minute < 0: + return _invalid("Event processing requires a non-negative minute.") + process_expired(absolute_minute) + audit_actor_availability(absolute_minute) + if not _foreground_instance_id.is_empty(): + return _invalid("A foreground Event is already active.", { + &"event_instance_id": _foreground_instance_id, + }) + _sort_queue() + for instance_id: StringName in _queue.duplicate(): + var instance := get_instance(instance_id) + if instance == null or instance.state != EventInstanceType.State.QUEUED: + _queue.erase(instance_id) + continue + var definition := get_definition(instance.resolved_event_id) + if definition == null: + _cancel_instance(instance, &"DEFINITION_MISSING", EventInstanceType.State.ERROR) + continue + var missing := _missing_actor_ids(instance.actor_ids) + if not missing.is_empty(): + _handle_missing_actors(instance, definition, missing, absolute_minute) + continue + var context := _merged_context(instance, absolute_minute, extra_context) + if not definition.conditions_match(context, _condition_callbacks): + continue + if instance.foreground and not _acquire_actor_locks(instance): + continue + _queue.erase(instance_id) + instance.state = EventInstanceType.State.RUNNING + if instance.foreground: + _foreground_instance_id = instance.event_instance_id + event_started.emit(instance) + var automatic_result := _execute_actions( + definition.automatic_actions, + definition.transaction_policy, + definition.failure_actions, + context + ) + if not bool(automatic_result.get(&"ok", false)): + instance.failure_reason = String(automatic_result.get(&"error", "Automatic Event Action failed.")) + if bool(automatic_result.get(&"fatal", false)): + instance.state = EventInstanceType.State.ERROR + pause_requested = true + _finish_instance(instance, false) + else: + _cancel_instance(instance, &"ACTION_FAILED", EventInstanceType.State.CANCELLED) + return _invalid(instance.failure_reason, automatic_result) + if not definition.choices.is_empty(): + instance.state = EventInstanceType.State.WAITING_CHOICE + instance.choice_instance_id = StringName("choice_instance_%08d" % _next_choice_sequence) + _next_choice_sequence += 1 + last_error = "" + return _success({ + &"event_instance_id": instance.event_instance_id, + &"choice_instance_id": instance.choice_instance_id, + &"waiting_for_choice": true, + }) + _finish_instance(instance, true) + last_error = "" + return _success({ + &"event_instance_id": instance.event_instance_id, + &"completed": true, + }) + return _invalid("No eligible Event candidate is ready.") + + +func process_expired(absolute_minute: int) -> Array[Dictionary]: + var results: Array[Dictionary] = [] + for instance_id: StringName in _queue.duplicate(): + var instance := get_instance(instance_id) + if instance == null or instance.expires_at < 0 or absolute_minute <= instance.expires_at: + continue + var definition := get_definition(instance.resolved_event_id) + var context := _merged_context(instance, absolute_minute, {}) + var fallback_result := {&"ok": true} + if definition != null and not definition.fallback_actions.is_empty(): + fallback_result = _execute_actions( + definition.fallback_actions, + EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, + [], + context + ) + _cancel_instance(instance, &"EXPIRED", EventInstanceType.State.EXPIRED) + results.append({ + &"event_instance_id": instance_id, + &"fallback_ok": bool(fallback_result.get(&"ok", false)), + }) + return results + + +func reserve_choice_cost( + event_instance_id: StringName, + choice_id: StringName, + absolute_minute: int, + extra_context: Dictionary = {} +) -> CommandResult: + var instance := get_instance(event_instance_id) + if instance == null: + return _not_found("EventInstance", event_instance_id) + if instance.state != EventInstanceType.State.WAITING_CHOICE: + return _invalid("Event is not waiting for a Choice.") + var definition := get_definition(instance.resolved_event_id) + var choice := definition.get_choice(choice_id) if definition != null else null + if choice == null: + return _not_found("EventChoice", choice_id) + var missing := _missing_actor_ids(instance.actor_ids) + if not missing.is_empty(): + _handle_missing_actors(instance, definition, missing, absolute_minute) + return _invalid("Event actor is unavailable.", {&"missing_actor_ids": missing}) + var context := _merged_context(instance, absolute_minute, extra_context) + if not choice.conditions_match(context, _condition_callbacks): + return _invalid("Choice conditions changed and no longer pass.") + if instance.reserved_costs.has(choice_id): + var existing: Dictionary = instance.reserved_costs[choice_id] + return _success({&"reservation": existing.duplicate(true), &"is_replay": true}) + var reserve_result := _reserve_cost(choice.cost, context) + if not bool(reserve_result.get(&"ok", false)): + return _invalid(String(reserve_result.get(&"error", "Choice cost is unavailable.")), reserve_result) + instance.reserved_costs[choice_id] = { + &"cost": choice.cost.duplicate(true), + &"token": _copy(reserve_result.get(&"token", choice.cost)), + } + last_error = "" + return _success({&"reservation": instance.reserved_costs[choice_id].duplicate(true)}) + + +func submit_choice( + event_instance_id: StringName, + choice_instance_id: StringName, + choice_id: StringName, + absolute_minute: int, + extra_context: Dictionary = {} +) -> CommandResult: + if _processed_choices.has(choice_instance_id): + var replay := CommandResultType.from_dictionary(_processed_choices[choice_instance_id]) + replay.is_replay = true + return replay + var instance := get_instance(event_instance_id) + if instance == null: + return _not_found("EventInstance", event_instance_id) + if choice_instance_id.is_empty() or instance.choice_instance_id != choice_instance_id: + return _invalid("Choice instance token does not match the active Event.") + if instance.state != EventInstanceType.State.WAITING_CHOICE: + return _invalid("Event is not waiting for a Choice.") + var definition := get_definition(instance.resolved_event_id) + if definition == null: + return _invalid("EventDefinition is missing.") + var missing := _missing_actor_ids(instance.actor_ids) + if not missing.is_empty(): + _handle_missing_actors(instance, definition, missing, absolute_minute) + return _invalid("Event actor is unavailable.", {&"missing_actor_ids": missing}) + var choice := definition.get_choice(choice_id) + if choice == null: + return _not_found("EventChoice", choice_id) + var context := _merged_context(instance, absolute_minute, extra_context) + if not choice.conditions_match(context, _condition_callbacks): + return _invalid("Choice conditions changed and no longer pass.") + if not instance.reserved_costs.has(choice_id): + var reserve_result := reserve_choice_cost(event_instance_id, choice_id, absolute_minute, extra_context) + if not reserve_result.is_success(): + return reserve_result + var transaction_result := _execute_actions( + choice.actions, + definition.transaction_policy, + definition.failure_actions, + context + ) + if not bool(transaction_result.get(&"ok", false)): + _release_reserved_cost(instance, choice_id, context) + if bool(transaction_result.get(&"fatal", false)): + instance.state = EventInstanceType.State.ERROR + instance.failure_reason = String(transaction_result.get(&"error", "Failure branch failed.")) + pause_requested = true + _finish_instance(instance, false) + var failed_result := _invalid( + String(transaction_result.get(&"error", "Choice Action transaction failed.")), + transaction_result + ) + _cache_choice_result(choice_instance_id, failed_result) + return failed_result + var commit_result := _commit_reserved_cost(instance, choice_id, context) + if not bool(commit_result.get(&"ok", false)): + _rollback_records(transaction_result.get(&"records", []), context) + _release_reserved_cost(instance, choice_id, context) + var commit_failure := _invalid( + String(commit_result.get(&"error", "Choice cost commit failed.")), + commit_result + ) + _cache_choice_result(choice_instance_id, commit_failure) + return commit_failure + instance.selected_choice_id = choice_id + _finish_instance(instance, true) + var result := _success({ + &"event_instance_id": event_instance_id, + &"choice_instance_id": choice_instance_id, + &"choice_id": choice_id, + &"completed": true, + &"skipped_action_ids": transaction_result.get(&"skipped_action_ids", []), + &"branched_failure": bool(transaction_result.get(&"branched_failure", false)), + }) + _cache_choice_result(choice_instance_id, result) + choice_resolved.emit(event_instance_id, choice_id, result.to_dictionary()) + last_error = "" + return result + + +func audit_actor_availability(absolute_minute: int) -> Array[Dictionary]: + var reports: Array[Dictionary] = [] + for instance: EventInstance in get_instances(): + if instance.state not in [EventInstanceType.State.RUNNING, EventInstanceType.State.WAITING_CHOICE, EventInstanceType.State.INTERRUPTED]: + continue + var missing := _missing_actor_ids(instance.actor_ids) + if missing.is_empty(): + continue + var definition := get_definition(instance.resolved_event_id) + _handle_missing_actors(instance, definition, missing, absolute_minute) + reports.append({ + &"event_instance_id": instance.event_instance_id, + &"missing_actor_ids": missing, + &"state": EventInstanceType.State.keys()[instance.state], + }) + return reports + + +func cancel_event(event_instance_id: StringName, reason: StringName = &"CANCELLED") -> CommandResult: + var instance := get_instance(event_instance_id) + if instance == null: + return _not_found("EventInstance", event_instance_id) + if instance.is_terminal(): + return _invalid("Event is already terminal.") + _cancel_instance(instance, reason, EventInstanceType.State.CANCELLED) + last_error = "" + return _success({&"event_instance_id": event_instance_id}) + + +func get_instance(event_instance_id: StringName) -> EventInstance: + return _instances.get(event_instance_id) as EventInstance + + +func get_instances() -> Array[EventInstance]: + var result: Array[EventInstance] = [] + for instance_id: StringName in _sorted_name_keys(_instances): + result.append(_instances[instance_id]) + return result + + +func get_queued_instances() -> Array[EventInstance]: + _sort_queue() + var result: Array[EventInstance] = [] + for instance_id: StringName in _queue: + var instance := get_instance(instance_id) + if instance != null: + result.append(instance) + return result + + +func get_foreground_instance() -> EventInstance: + return get_instance(_foreground_instance_id) + + +func get_actor_locks() -> Dictionary: + return _actor_locks.duplicate(true) + + +func has_flag(flag_id: StringName) -> bool: + return bool(_flags.get(flag_id, false)) + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + var queued_seen: Dictionary = {} + for instance_id: StringName in _queue: + if queued_seen.has(instance_id): + errors.append("Duplicate queue instance: %s" % instance_id) + queued_seen[instance_id] = true + var instance := get_instance(instance_id) + if instance == null or instance.state != EventInstanceType.State.QUEUED: + errors.append("Queue references a non-QUEUED instance: %s" % instance_id) + for instance: EventInstance in get_instances(): + if not instance.is_valid() or not _definitions.has(instance.resolved_event_id): + errors.append("Invalid EventInstance: %s" % instance.event_instance_id) + if not _foreground_instance_id.is_empty(): + var foreground := get_instance(_foreground_instance_id) + if foreground == null or not foreground.foreground or foreground.state not in [EventInstanceType.State.RUNNING, EventInstanceType.State.WAITING_CHOICE, EventInstanceType.State.INTERRUPTED]: + errors.append("Foreground pointer is invalid.") + for raw_actor_id: Variant in _actor_locks: + var actor_id := StringName(raw_actor_id) + var instance_id := StringName(_actor_locks[raw_actor_id]) + var instance := get_instance(instance_id) + if instance == null or not instance.actor_ids.has(actor_id) or instance_id != _foreground_instance_id: + errors.append("Actor Lock is orphaned: %s" % actor_id) + return {&"ok": errors.is_empty(), &"errors": errors} + + +func create_snapshot() -> Dictionary: + var instances: Array[Dictionary] = [] + for instance: EventInstance in get_instances(): + instances.append(instance.create_snapshot()) + var queue_strings: Array[String] = [] + for instance_id: StringName in _queue: + queue_strings.append(String(instance_id)) + return { + &"version": SNAPSHOT_VERSION, + &"instances": instances, + &"queue": queue_strings, + &"actor_locks": _string_key_dictionary(_actor_locks), + &"foreground_instance_id": String(_foreground_instance_id), + &"active_deduplication_keys": _string_key_dictionary(_active_deduplication_keys), + &"processed_choices": _string_key_dictionary(_processed_choices), + &"completed_scope_keys": _string_key_dictionary(_completed_scope_keys), + &"flags": _string_key_dictionary(_flags), + &"next_event_sequence": _next_event_sequence, + &"next_choice_sequence": _next_choice_sequence, + &"pause_requested": pause_requested, + &"filter_mode": EventDefinitionType.FilterBehavior.keys()[_filter_mode], + &"maximum_content_rating": EventDefinitionType.ContentRating.keys()[_maximum_content_rating], + &"disabled_content_tags": _names_to_strings(_disabled_content_tags), + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + var parsed := _parse_snapshot(snapshot) + if not bool(parsed.get(&"ok", false)): + last_error = String(parsed.get(&"error", "Invalid EventService snapshot.")) + return false + _instances = parsed[&"instances"] + _queue = parsed[&"queue"] + _actor_locks = parsed[&"actor_locks"] + _foreground_instance_id = parsed[&"foreground_instance_id"] + _active_deduplication_keys = parsed[&"active_deduplication_keys"] + _processed_choices = parsed[&"processed_choices"] + _completed_scope_keys = parsed[&"completed_scope_keys"] + _flags = parsed[&"flags"] + _next_event_sequence = parsed[&"next_event_sequence"] + _next_choice_sequence = parsed[&"next_choice_sequence"] + pause_requested = parsed[&"pause_requested"] + _filter_mode = parsed[&"filter_mode"] + _maximum_content_rating = parsed[&"maximum_content_rating"] + _disabled_content_tags = parsed[&"disabled_content_tags"] + last_error = "" + return true + + +## Recovery-only boundary. It can release stale Actor Locks and the matching +## foreground pointer only when that foreground instance is absent or already +## terminal. Active-foreground lock mismatches remain hard validation errors; +## this routine never invents actors, events, choices, or route ownership. +func restore_snapshot_with_lock_recovery(snapshot: Dictionary) -> bool: + last_recovery_repair_report = { + &"automatic_repairs_applied": false, + &"released_actor_ids": [], + &"released_foreground_instance_id": &"", + &"reason": &"NONE", + } + var raw_instances: Variant = _value(snapshot, &"instances", null) + var raw_locks: Variant = _value(snapshot, &"actor_locks", null) + if not raw_instances is Array or not raw_locks is Dictionary: + return restore_snapshot(snapshot) + var foreground_id := StringName(_value(snapshot, &"foreground_instance_id", &"")) + if foreground_id.is_empty(): + return restore_snapshot(snapshot) + + var matching_foreground: Dictionary = {} + for raw_instance: Variant in raw_instances: + if raw_instance is Dictionary and StringName(_value(raw_instance, &"event_instance_id", &"")) == foreground_id: + matching_foreground = raw_instance + break + var repair_reason: StringName = &"MISSING_FOREGROUND" + var known_foreground_actors: Array[StringName] = [] + if not matching_foreground.is_empty(): + var state_name := String(_value(matching_foreground, &"state", "")).to_upper() + var state_index := EventInstanceType.State.keys().find(state_name) + if state_index < 0: + return restore_snapshot(snapshot) + var state := state_index as EventInstance.State + if not bool(_value(matching_foreground, &"foreground", true)) or state not in [ + EventInstanceType.State.COMPLETED, + EventInstanceType.State.CANCELLED, + EventInstanceType.State.EXPIRED, + EventInstanceType.State.ERROR, + ]: + return restore_snapshot(snapshot) + repair_reason = &"TERMINAL_FOREGROUND" + var raw_actor_ids: Variant = _value(matching_foreground, &"actor_ids", null) + if not raw_actor_ids is Array: + return restore_snapshot(snapshot) + known_foreground_actors = _sorted_unique_names(raw_actor_ids) + + var candidate := snapshot.duplicate(true) + var released_actor_ids: Array[StringName] = [] + var retained_locks: Dictionary = {} + for raw_actor_id: Variant in raw_locks: + var actor_id := StringName(raw_actor_id) + var lock_instance_id := StringName(raw_locks[raw_actor_id]) + var belongs_to_known_actor_set := ( + matching_foreground.is_empty() + or known_foreground_actors.has(actor_id) + ) + if actor_id.is_empty() or lock_instance_id != foreground_id or not belongs_to_known_actor_set: + last_error = "EventService recovery found unrelated Actor Lock corruption." + return false + released_actor_ids.append(actor_id) + released_actor_ids.sort_custom( + func(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + ) + candidate[&"foreground_instance_id"] = "" + candidate[&"actor_locks"] = retained_locks + if not restore_snapshot(candidate): + return false + last_recovery_repair_report = { + &"automatic_repairs_applied": true, + &"released_actor_ids": released_actor_ids, + &"released_foreground_instance_id": foreground_id, + &"reason": repair_reason, + } + return true + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION: + return {&"ok": false, &"error": "Unsupported EventService snapshot version."} + var raw_instances: Variant = _value(snapshot, &"instances", null) + var raw_queue: Variant = _value(snapshot, &"queue", null) + var raw_locks: Variant = _value(snapshot, &"actor_locks", null) + if not raw_instances is Array or not raw_queue is Array or not raw_locks is Dictionary: + return {&"ok": false, &"error": "EventService snapshot collections are malformed."} + var instances: Dictionary = {} + for raw_instance: Variant in raw_instances: + if not raw_instance is Dictionary: + return {&"ok": false, &"error": "EventInstance snapshot is malformed."} + var instance := EventInstanceType.from_snapshot(raw_instance) + if not instance.is_valid() or instances.has(instance.event_instance_id) or not _definitions.has(instance.resolved_event_id): + return {&"ok": false, &"error": "EventInstance snapshot reference is invalid."} + instances[instance.event_instance_id] = instance + var queue: Array[StringName] = [] + var queue_seen: Dictionary = {} + for raw_instance_id: Variant in raw_queue: + var instance_id := StringName(raw_instance_id) + var instance: EventInstance = instances.get(instance_id) + if instance == null or instance.state != EventInstanceType.State.QUEUED or queue_seen.has(instance_id): + return {&"ok": false, &"error": "Event queue reference is invalid."} + queue_seen[instance_id] = true + queue.append(instance_id) + var foreground_id := StringName(_value(snapshot, &"foreground_instance_id", &"")) + if not foreground_id.is_empty(): + var foreground: EventInstance = instances.get(foreground_id) + if foreground == null or not foreground.foreground or foreground.state not in [EventInstanceType.State.RUNNING, EventInstanceType.State.WAITING_CHOICE, EventInstanceType.State.INTERRUPTED]: + return {&"ok": false, &"error": "Foreground Event reference is invalid."} + var locks: Dictionary = {} + for raw_actor_id: Variant in raw_locks: + var actor_id := StringName(raw_actor_id) + var instance_id := StringName(raw_locks[raw_actor_id]) + var locked_instance: EventInstance = instances.get(instance_id) + if actor_id.is_empty() or locked_instance == null or instance_id != foreground_id or not locked_instance.actor_ids.has(actor_id): + return {&"ok": false, &"error": "Actor Lock snapshot is invalid."} + locks[actor_id] = instance_id + if not foreground_id.is_empty(): + var active: EventInstance = instances[foreground_id] + for actor_id: StringName in active.actor_ids: + if locks.get(actor_id, &"") != foreground_id: + return {&"ok": false, &"error": "Foreground Actor Lock is incomplete."} + var active_dedup := _name_dictionary(_value(snapshot, &"active_deduplication_keys", {})) + for raw_key: Variant in active_dedup: + var instance_id := StringName(active_dedup[raw_key]) + var instance: EventInstance = instances.get(instance_id) + if instance == null or instance.is_terminal() or instance.deduplication_key != StringName(raw_key): + return {&"ok": false, &"error": "Active deduplication index is invalid."} + var next_event := int(_value(snapshot, &"next_event_sequence", 0)) + var next_choice := int(_value(snapshot, &"next_choice_sequence", 0)) + if next_event < 1 or next_choice < 1: + return {&"ok": false, &"error": "Event sequence counters are invalid."} + var filter_mode_value := _strict_enum_value( + EventDefinitionType.FilterBehavior.keys(), + _value(snapshot, &"filter_mode", null) + ) + if filter_mode_value < 0: + return {&"ok": false, &"error": "EventService filter_mode snapshot enum is invalid."} + var maximum_rating_value := _strict_enum_value( + EventDefinitionType.ContentRating.keys(), + _value(snapshot, &"maximum_content_rating", null) + ) + if maximum_rating_value < 0: + return {&"ok": false, &"error": "EventService maximum_content_rating snapshot enum is invalid."} + var raw_disabled_tags: Variant = _value(snapshot, &"disabled_content_tags", null) + if not raw_disabled_tags is Array: + return {&"ok": false, &"error": "EventService disabled_content_tags snapshot is malformed."} + var filter_mode := filter_mode_value as EventDefinition.FilterBehavior + var maximum_rating := maximum_rating_value as EventDefinition.ContentRating + return { + &"ok": true, + &"instances": instances, + &"queue": queue, + &"actor_locks": locks, + &"foreground_instance_id": foreground_id, + &"active_deduplication_keys": active_dedup, + &"processed_choices": _dictionary(_value(snapshot, &"processed_choices", {})).duplicate(true), + &"completed_scope_keys": _dictionary(_value(snapshot, &"completed_scope_keys", {})).duplicate(true), + &"flags": _dictionary(_value(snapshot, &"flags", {})).duplicate(true), + &"next_event_sequence": next_event, + &"next_choice_sequence": next_choice, + &"pause_requested": bool(_value(snapshot, &"pause_requested", false)), + &"filter_mode": filter_mode, + &"maximum_content_rating": maximum_rating, + &"disabled_content_tags": _sorted_unique_names(raw_disabled_tags), + } + + +func _execute_actions( + actions: Array, + policy: EventDefinition.TransactionPolicy, + failure_actions: Array, + context: Dictionary +) -> Dictionary: + match policy: + EventDefinitionType.TransactionPolicy.SKIP_FAILED: + var records: Array[Dictionary] = [] + var skipped: Array[StringName] = [] + for action: EventAction in actions: + var validation := _execute_action(action, &"validate", context, null) + if not bool(validation.get(&"ok", false)): + skipped.append(action.action_id) + continue + var applied := _execute_action(action, &"apply", context, null) + if bool(applied.get(&"ok", false)): + records.append({&"action": action, &"token": _copy(applied.get(&"token", null))}) + else: + skipped.append(action.action_id) + return {&"ok": true, &"records": records, &"skipped_action_ids": skipped} + EventDefinitionType.TransactionPolicy.BRANCH_FAILURE: + var primary := _run_rollback_all(actions, context) + if bool(primary.get(&"ok", false)): + return primary + var branch := _run_rollback_all(failure_actions, context) + if bool(branch.get(&"ok", false)): + branch[&"branched_failure"] = true + branch[&"primary_error"] = primary.get(&"error", "") + return branch + return { + &"ok": false, + &"fatal": true, + &"error": "Failure branch failed: %s" % branch.get(&"error", "unknown error"), + } + return _run_rollback_all(actions, context) + + +func _run_rollback_all(actions: Array, context: Dictionary) -> Dictionary: + for action: EventAction in actions: + var validation := _execute_action(action, &"validate", context, null) + if not bool(validation.get(&"ok", false)): + return {&"ok": false, &"error": validation.get(&"error", "Action validation failed."), &"records": []} + var records: Array[Dictionary] = [] + for action: EventAction in actions: + var applied := _execute_action(action, &"apply", context, null) + if not bool(applied.get(&"ok", false)): + _rollback_records(records, context) + return {&"ok": false, &"error": applied.get(&"error", "Action apply failed."), &"records": []} + records.append({&"action": action, &"token": _copy(applied.get(&"token", null))}) + return {&"ok": true, &"records": records, &"skipped_action_ids": []} + + +func _rollback_records(raw_records: Variant, context: Dictionary) -> void: + if not raw_records is Array: + return + for index: int in range(raw_records.size() - 1, -1, -1): + var record: Dictionary = raw_records[index] + _execute_action(record[&"action"], &"rollback", context, record.get(&"token")) + + +func _execute_action( + action: EventAction, + phase: StringName, + context: Dictionary, + rollback_token: Variant +) -> Dictionary: + if _action_executor.is_valid(): + var custom_result: Variant = _action_executor.call(action, phase, context, rollback_token) + var normalized := _normalize_operation_result(custom_result) + if not bool(normalized.get(&"not_handled", false)): + return normalized + return _execute_builtin_action(action, phase, context, rollback_token) + + +func _execute_builtin_action( + action: EventAction, + phase: StringName, + context: Dictionary, + rollback_token: Variant +) -> Dictionary: + match action.type: + EventActionType.Type.SET_FLAG: + var flag_id := StringName(_value(action.payload, &"flag_id", &"")) + if flag_id.is_empty(): + return {&"ok": false, &"error": "SET_FLAG requires flag_id."} + if phase == &"validate": + return {&"ok": true} + if phase == &"apply": + var token := {&"had": _flags.has(flag_id), &"value": _flags.get(flag_id)} + _flags[flag_id] = bool(_value(action.payload, &"enabled", true)) + return {&"ok": true, &"token": token} + if phase == &"rollback" and rollback_token is Dictionary: + if bool(rollback_token.get(&"had", false)): + _flags[flag_id] = rollback_token.get(&"value") + else: + _flags.erase(flag_id) + return {&"ok": true} + EventActionType.Type.SCHEDULE_EVENT: + var event_id := StringName(_value(action.payload, &"event_id", &"")) + if event_id.is_empty() or not _definitions.has(event_id): + return {&"ok": false, &"error": "SCHEDULE_EVENT references an unknown event."} + if phase == &"validate": + return {&"ok": true} + if phase == &"apply": + var result := enqueue_candidate( + event_id, + _array(_value(action.payload, &"actor_ids", _value(context, &"actor_ids", []))), + int(_value(context, &"absolute_minute", 0)), + _dictionary(_value(action.payload, &"options", {})) + ) + return {&"ok": result.is_success(), &"error": result.message, &"token": result.details.get(&"event_instance_id", &"")} + if phase == &"rollback": + var instance_id := StringName(rollback_token) + var scheduled := get_instance(instance_id) + if scheduled != null and not scheduled.is_terminal(): + _cancel_instance(scheduled, &"TRANSACTION_ROLLBACK", EventInstanceType.State.CANCELLED) + return {&"ok": true} + EventActionType.Type.ADD_RELATIONSHIP_VALUE, EventActionType.Type.DAMAGE_SHIELD, EventActionType.Type.SET_ROUTE_LOCK: + return _execute_relationship_action(action, phase, context, rollback_token) + return {&"ok": false, &"error": "No executor is configured for Action type %s." % EventActionType.Type.keys()[action.type]} + + +func _execute_relationship_action( + action: EventAction, + phase: StringName, + context: Dictionary, + rollback_token: Variant +) -> Dictionary: + if _relationship_service == null: + return {&"ok": false, &"error": "RelationshipService is not configured."} + var relationship_id := StringName(_value(action.payload, &"relationship_id", &"")) + if relationship_id.is_empty() or _relationship_service.get_relationship(relationship_id) == null: + return {&"ok": false, &"error": "Relationship Action references an unknown state."} + if phase == &"validate": + return {&"ok": true} + if phase == &"rollback": + if rollback_token is Dictionary and rollback_token.has(&"snapshot"): + return {&"ok": bool(_relationship_service.restore_snapshot(rollback_token[&"snapshot"]))} + return {&"ok": false, &"error": "Relationship rollback token is missing."} + var token := {&"snapshot": _relationship_service.create_snapshot()} + var result: Variant + match action.type: + EventActionType.Type.ADD_RELATIONSHIP_VALUE: + result = _relationship_service.apply_value_change( + relationship_id, + StringName(_value(action.payload, &"metric_id", &"")), + int(_value(action.payload, &"delta", 0)), + int(_value(context, &"absolute_minute", 0)), + _dictionary(_value(action.payload, &"options", {&"important": true, &"allow_threshold_crossing": true})) + ) + EventActionType.Type.DAMAGE_SHIELD: + result = _relationship_service.apply_shield_attack( + relationship_id, + StringName(_value(action.payload, &"action_key", action.action_id)), + int(_value(context, &"absolute_minute", 0)), + _dictionary(_value(action.payload, &"terms", action.payload)) + ) + EventActionType.Type.SET_ROUTE_LOCK: + var lock_value := int(_value(action.payload, &"route_lock", 0)) + result = _relationship_service.request_route_lock( + relationship_id, + lock_value, + StringName(_value(action.payload, &"route_id", &"")), + bool(_value(action.payload, &"irreversible_event", false)) + ) + return {&"ok": result.is_success(), &"error": result.message, &"token": token} + + +func _handle_missing_actors( + instance: EventInstance, + definition: EventDefinition, + missing_actor_ids: Array[StringName], + absolute_minute: int +) -> void: + _release_all_reserved_costs(instance, _merged_context(instance, absolute_minute, {})) + if definition == null: + _cancel_instance(instance, &"DEFINITION_MISSING", EventInstanceType.State.ERROR) + return + match definition.missing_actor_policy: + EventDefinitionType.MissingActorPolicy.CONTINUE_WITHOUT: + for actor_id: StringName in missing_actor_ids: + instance.actor_ids.erase(actor_id) + _actor_locks.erase(actor_id) + if instance.actor_ids.is_empty() and instance.foreground: + _cancel_instance(instance, &"ALL_ACTORS_MISSING", EventInstanceType.State.CANCELLED) + EventDefinitionType.MissingActorPolicy.REPLACE_WITH_ROLE: + var replacements: Dictionary = _dictionary(_value(instance.context, &"replacement_actor_ids", {})) + var replacement_failed := false + for actor_id: StringName in missing_actor_ids: + var replacement_id := StringName(_value(replacements, actor_id, &"")) + if replacement_id.is_empty() or not _actor_is_available(replacement_id): + replacement_failed = true + break + instance.actor_ids.erase(actor_id) + instance.actor_ids.append(replacement_id) + _actor_locks.erase(actor_id) + _actor_locks[replacement_id] = instance.event_instance_id + instance.actor_ids = _sorted_unique_names(instance.actor_ids) + if replacement_failed: + _cancel_instance(instance, &"ACTOR_REPLACEMENT_FAILED", EventInstanceType.State.CANCELLED) + EventDefinitionType.MissingActorPolicy.BRANCH_FAILURE: + var branch := _execute_actions( + definition.failure_actions, + EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, + [], + _merged_context(instance, absolute_minute, {&"missing_actor_ids": missing_actor_ids}) + ) + if bool(branch.get(&"ok", false)): + _cancel_instance(instance, &"ACTOR_MISSING_BRANCH", EventInstanceType.State.CANCELLED) + else: + instance.failure_reason = String(branch.get(&"error", "Missing Actor failure branch failed.")) + _cancel_instance(instance, &"ACTOR_MISSING_BRANCH_FAILED", EventInstanceType.State.ERROR) + pause_requested = true + _: + _cancel_instance(instance, &"ACTOR_MISSING", EventInstanceType.State.CANCELLED) + + +func _acquire_actor_locks(instance: EventInstance) -> bool: + for actor_id: StringName in instance.actor_ids: + if _actor_locks.has(actor_id): + return false + for actor_id: StringName in instance.actor_ids: + _actor_locks[actor_id] = instance.event_instance_id + return true + + +func _release_actor_locks(instance: EventInstance) -> void: + for actor_id: StringName in instance.actor_ids: + if _actor_locks.get(actor_id, &"") == instance.event_instance_id: + _actor_locks.erase(actor_id) + if _foreground_instance_id == instance.event_instance_id: + _foreground_instance_id = &"" + + +func _finish_instance(instance: EventInstance, completed: bool) -> void: + _release_all_reserved_costs(instance, instance.context) + _release_actor_locks(instance) + _queue.erase(instance.event_instance_id) + if not instance.deduplication_key.is_empty(): + _active_deduplication_keys.erase(instance.deduplication_key) + if completed: + instance.state = EventInstanceType.State.COMPLETED + var source := get_definition(instance.source_event_id) + if source != null: + var scope_key := _scope_key(source, instance.actor_ids, instance.created_at) + if not scope_key.is_empty(): + _completed_scope_keys[scope_key] = instance.event_instance_id + event_finished.emit(instance) + + +func _cancel_instance(instance: EventInstance, reason: StringName, terminal_state: EventInstance.State) -> void: + _release_all_reserved_costs(instance, instance.context) + _release_actor_locks(instance) + _queue.erase(instance.event_instance_id) + if not instance.deduplication_key.is_empty(): + _active_deduplication_keys.erase(instance.deduplication_key) + instance.state = terminal_state + instance.failure_reason = String(reason) + event_cancelled.emit(instance, reason) + + +func _reserve_cost(cost: Dictionary, context: Dictionary) -> Dictionary: + if cost.is_empty(): + return {&"ok": true, &"token": {}} + if not _cost_reserver.is_valid(): + return {&"ok": false, &"error": "Choice Cost reserver is not configured."} + return _normalize_operation_result(_cost_reserver.call(cost.duplicate(true), context)) + + +func _release_reserved_cost(instance: EventInstance, choice_id: StringName, context: Dictionary) -> Dictionary: + if not instance.reserved_costs.has(choice_id): + return {&"ok": true} + var reservation: Dictionary = instance.reserved_costs[choice_id] + var result := {&"ok": true} + if _cost_releaser.is_valid(): + result = _normalize_operation_result(_cost_releaser.call(_copy(reservation.get(&"token")), context)) + instance.reserved_costs.erase(choice_id) + return result + + +func _release_all_reserved_costs(instance: EventInstance, context: Dictionary) -> void: + var choice_ids := _sorted_name_keys(instance.reserved_costs) + for choice_id: StringName in choice_ids: + _release_reserved_cost(instance, choice_id, context) + + +func _commit_reserved_cost(instance: EventInstance, choice_id: StringName, context: Dictionary) -> Dictionary: + if not instance.reserved_costs.has(choice_id): + return {&"ok": true} + var reservation: Dictionary = instance.reserved_costs[choice_id] + var result := {&"ok": true} + if _cost_committer.is_valid(): + result = _normalize_operation_result(_cost_committer.call(_copy(reservation.get(&"token")), context)) + if bool(result.get(&"ok", false)): + instance.reserved_costs.erase(choice_id) + return result + + +func _cache_choice_result(choice_instance_id: StringName, result: CommandResult) -> void: + _processed_choices[choice_instance_id] = result.to_dictionary() + + +func _missing_actor_ids(actor_ids: Array[StringName]) -> Array[StringName]: + var missing: Array[StringName] = [] + for actor_id: StringName in actor_ids: + if not _actor_is_available(actor_id): + missing.append(actor_id) + return missing + + +func _actor_is_available(actor_id: StringName) -> bool: + return bool(_actor_exists.call(actor_id)) if _actor_exists.is_valid() else true + + +func _merged_context(instance: EventInstance, absolute_minute: int, extra: Dictionary) -> Dictionary: + var result := instance.context.duplicate(true) + for raw_key: Variant in extra: + result[raw_key] = _copy(extra[raw_key]) + result[&"absolute_minute"] = absolute_minute + result[&"event_instance_id"] = instance.event_instance_id + result[&"actor_ids"] = instance.actor_ids.duplicate() + return result + + +func _is_content_restricted(definition: EventDefinition) -> bool: + if definition.content_rating > _maximum_content_rating: + return true + for tag: StringName in definition.content_tags: + if _disabled_content_tags.has(tag): + return true + return false + + +func _replacement_cycle_exists(event_id: StringName) -> bool: + var visited: Dictionary = {} + var current_id := event_id + while not current_id.is_empty(): + if visited.has(current_id): + return true + visited[current_id] = true + var definition := get_definition(current_id) + if definition == null or definition.filter_behavior != EventDefinitionType.FilterBehavior.REPLACE: + return false + current_id = definition.replacement_event_id + return false + + +func _scope_key(definition: EventDefinition, actor_ids: Array[StringName], absolute_minute: int) -> StringName: + match definition.once_scope: + EventDefinitionType.OnceScope.PER_SAVE: + return StringName("save:%s" % definition.event_id) + EventDefinitionType.OnceScope.PER_DAY: + return StringName("day:%s:%d" % [definition.event_id, absolute_minute / 1_440]) + EventDefinitionType.OnceScope.PER_ACTOR_PAIR: + return StringName("pair:%s:%s" % [definition.event_id, ",".join(_names_to_strings(actor_ids))]) + return &"" + + +func _sort_queue() -> void: + _queue.sort_custom( + func(a_id: StringName, b_id: StringName) -> bool: + var a := get_instance(a_id) + var b := get_instance(b_id) + if a == null: + return false + if b == null: + return true + if a.priority != b.priority: + return a.priority > b.priority + var a_expiry := a.expires_at if a.expires_at >= 0 else 9_223_372_036_854_775_000 + var b_expiry := b.expires_at if b.expires_at >= 0 else 9_223_372_036_854_775_000 + if a_expiry != b_expiry: + return a_expiry < b_expiry + return String(a.event_instance_id) < String(b.event_instance_id) + ) + + +static func _default_deduplication_key(event_id: StringName, actor_ids: Array[StringName]) -> StringName: + return StringName("%s:%s" % [event_id, ",".join(_names_to_strings(actor_ids))]) + + +static func _normalize_operation_result(raw_result: Variant) -> Dictionary: + if raw_result is Dictionary: + var result: Dictionary = raw_result.duplicate(true) + if bool(result.get(&"handled", result.get("handled", true))) == false: + return {&"ok": false, &"not_handled": true} + if not result.has(&"ok") and not result.has("ok"): + result[&"ok"] = true + return result + if raw_result is bool: + return {&"ok": raw_result, &"error": "Operation returned false." if not raw_result else ""} + if raw_result is String or raw_result is StringName: + var message := String(raw_result) + return {&"ok": message.is_empty(), &"error": message} + if raw_result == null: + return {&"ok": true} + return {&"ok": true, &"token": _copy(raw_result)} + + +static func _enum_value(keys: Array, raw_value: Variant, fallback: int) -> int: + if raw_value is int: + return clampi(int(raw_value), 0, keys.size() - 1) + var index := keys.find(String(raw_value).to_upper()) + return index if index >= 0 else fallback + + +static func _strict_enum_value(keys: Array, raw_value: Variant) -> int: + if raw_value is int: + var numeric := int(raw_value) + return numeric if numeric >= 0 and numeric < keys.size() else -1 + if raw_value is String or raw_value is StringName: + return keys.find(String(raw_value).to_upper()) + return -1 + + +static func _sorted_unique_names(raw_value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if raw_value is Array: + for raw_entry: Variant in raw_value: + var entry := StringName(raw_entry) + if not entry.is_empty() and not result.has(entry): + result.append(entry) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _sorted_name_keys(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_key: Variant in source: + result.append(StringName(raw_key)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _names_to_strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array[String] = [] + for raw_key: Variant in source: + keys.append(String(raw_key)) + keys.sort() + for key: String in keys: + var value: Variant = source.get(StringName(key), source.get(key)) + result[key] = _copy(value) + return result + + +static func _name_dictionary(raw_value: Variant) -> Dictionary: + var result: Dictionary = {} + if raw_value is Dictionary: + for raw_key: Variant in raw_value: + result[StringName(raw_key)] = StringName(raw_value[raw_key]) + return result + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +static func _array(value: Variant) -> Array: + return value if value is Array else [] + + +static func _dictionary(value: Variant) -> Dictionary: + return value if value is Dictionary else {} + + +static func _copy(value: Variant) -> Variant: + return value.duplicate(true) if value is Dictionary or value is Array else value + + +func _success(details: Dictionary = {}) -> CommandResult: + return CommandResultType.succeeded(0, details) + + +func _invalid(message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed( + CommandResultType.Code.VALIDATION_FAILED, + 0, + message, + details + ) + + +func _not_found(kind: String, entity_id: StringName) -> CommandResult: + return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id}) diff --git a/scripts/events/event_service.gd.uid b/scripts/events/event_service.gd.uid new file mode 100644 index 0000000..4f95b82 --- /dev/null +++ b/scripts/events/event_service.gd.uid @@ -0,0 +1 @@ +uid://dyvxeffcwc22y diff --git a/scripts/facility/facility_instance.gd b/scripts/facility/facility_instance.gd new file mode 100644 index 0000000..94bcc8a --- /dev/null +++ b/scripts/facility/facility_instance.gd @@ -0,0 +1,490 @@ +class_name FacilityInstance +extends RefCounted + +const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd") + +## Values are deliberately ordered by operational priority. +enum State { + BROKEN, + NO_ACCESS, + NO_POWER, + NO_STAFF, + CLOSED, + DEGRADED, + OPEN, +} + +const FULL_DAY_MINUTES := 1_440 +const DEFAULT_DURABILITY := 10_000 +const DEGRADED_DURABILITY_THRESHOLD := 3_000 + +var instance_id: StringName = &"" +var definition_id: StringName = &"" +var origin := Vector3i.ZERO +var rotation := 0 +var occupied_cells: Array[Vector3i] = [] +var level := 1 +var state: State = State.OPEN +var state_reasons: Array[StringName] = [] +var durability := DEFAULT_DURABILITY +var local_inventory_id: StringName = &"" +var assigned_staff_by_shift: Dictionary = {} +var active_staff_ids: Array[StringName] = [] +var queue_entries: Array[StringName] = [] +var active_services: Dictionary = {} +var policy_current: Dictionary = {} +var policy_pending: Dictionary = {} +var policy_revision := 0 +var policy_pending_revision := 0 +var policy_pending_effective_at := -1 +var statistics_today: Dictionary = {} +var lifetime_statistics: Dictionary = {} +var created_at := 0 + +var capacity := 1 +var queue_capacity := 0 +var base_service_minutes := 1 +var base_quality := 0 +var base_price := 0 +var staff_min := 0 +var staff_max := 0 +var access_available := true +var power_available := true +var operator_open := true +var opening_intervals: Array[Vector2i] = [Vector2i(0, FULL_DAY_MINUTES)] +var degraded_reasons: Array[StringName] = [] + + +func configure( + p_instance_id: StringName, + p_definition_id: StringName, + options: Dictionary = {} +) -> FacilityInstance: + instance_id = p_instance_id + definition_id = p_definition_id + origin = _vector3i(_value(options, &"origin", Vector3i.ZERO)) + rotation = _normalize_rotation(int(_value(options, &"rotation", 0))) + occupied_cells = _cell_array(_value(options, &"occupied_cells", [])) + level = maxi(int(_value(options, &"level", 1)), 1) + durability = clampi(int(_value(options, &"durability", DEFAULT_DURABILITY)), 0, DEFAULT_DURABILITY) + local_inventory_id = StringName(_value(options, &"local_inventory_id", &"")) + capacity = maxi(int(_value(options, &"capacity", 1)), 1) + queue_capacity = maxi(int(_value(options, &"queue_capacity", 0)), 0) + base_service_minutes = maxi(int(_value(options, &"base_service_minutes", 1)), 1) + base_quality = int(_value(options, &"base_quality", 0)) + base_price = maxi(int(_value(options, &"base_price", 0)), 0) + staff_min = maxi(int(_value(options, &"staff_min", 0)), 0) + staff_max = maxi(int(_value(options, &"staff_max", maxi(staff_min, 0))), staff_min) + policy_current = _normalize_policy(_value(options, &"policy_current", {})) + policy_revision = maxi(int(_value(options, &"policy_revision", 0)), 0) + created_at = maxi(int(_value(options, &"created_at", 0)), 0) + access_available = bool(_value(options, &"access_available", true)) + power_available = bool(_value(options, &"power_available", true)) + operator_open = bool(_value(options, &"operator_open", true)) + var intervals := _parse_intervals(_value(options, &"opening_intervals", [])) + if not intervals.is_empty(): + opening_intervals = intervals + active_staff_ids = _sorted_unique_names(_value(options, &"active_staff_ids", [])) + degraded_reasons = _sorted_unique_names(_value(options, &"degraded_reasons", [])) + evaluate_state(created_at) + return self + + +func is_valid() -> bool: + return not instance_id.is_empty() \ + and not definition_id.is_empty() \ + and capacity > 0 \ + and queue_capacity >= 0 \ + and base_service_minutes > 0 \ + and staff_min >= 0 \ + and staff_max >= staff_min \ + and _intervals_are_valid(opening_intervals) + + +func set_opening_hours(open_minute_of_day: int, close_minute_of_day: int) -> bool: + if open_minute_of_day < 0 or open_minute_of_day >= FULL_DAY_MINUTES: + return false + if close_minute_of_day < 0 or close_minute_of_day > FULL_DAY_MINUTES: + return false + if open_minute_of_day == close_minute_of_day: + opening_intervals = [Vector2i(0, FULL_DAY_MINUTES)] + elif open_minute_of_day < close_minute_of_day: + opening_intervals = [Vector2i(open_minute_of_day, close_minute_of_day)] + else: + opening_intervals = [] + if close_minute_of_day > 0: + opening_intervals.append(Vector2i(0, close_minute_of_day)) + if open_minute_of_day < FULL_DAY_MINUTES: + opening_intervals.append(Vector2i(open_minute_of_day, FULL_DAY_MINUTES)) + return true + + +func set_opening_intervals(intervals: Variant) -> bool: + var parsed := _parse_intervals(intervals) + if parsed.is_empty() or not _intervals_are_valid(parsed): + return false + opening_intervals = parsed + return true + + +func is_within_opening_hours(absolute_minute: int) -> bool: + var minute_of_day := posmod(absolute_minute, FULL_DAY_MINUTES) + for interval: Vector2i in opening_intervals: + if minute_of_day >= interval.x and minute_of_day < interval.y: + return true + return false + + +func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> bool: + if _has_value(conditions, &"durability"): + durability = clampi(int(_value(conditions, &"durability", durability)), 0, DEFAULT_DURABILITY) + if _has_value(conditions, &"access_available"): + access_available = bool(_value(conditions, &"access_available", access_available)) + if _has_value(conditions, &"power_available"): + power_available = bool(_value(conditions, &"power_available", power_available)) + if _has_value(conditions, &"operator_open"): + operator_open = bool(_value(conditions, &"operator_open", operator_open)) + if _has_value(conditions, &"active_staff_ids"): + active_staff_ids = _sorted_unique_names(_value(conditions, &"active_staff_ids", [])) + if _has_value(conditions, &"degraded_reasons"): + degraded_reasons = _sorted_unique_names(_value(conditions, &"degraded_reasons", [])) + return evaluate_state(absolute_minute) + + +func evaluate_state(absolute_minute: int) -> bool: + var next_reasons: Array[StringName] = [] + var next_state := State.OPEN + if durability <= 0: + next_reasons.append(&"BROKEN") + next_state = State.BROKEN + if not access_available: + next_reasons.append(&"NO_ACCESS") + if next_state > State.NO_ACCESS: + next_state = State.NO_ACCESS + if not power_available: + next_reasons.append(&"NO_POWER") + if next_state > State.NO_POWER: + next_state = State.NO_POWER + if active_staff_ids.size() < staff_min: + next_reasons.append(&"NO_STAFF") + if next_state > State.NO_STAFF: + next_state = State.NO_STAFF + if not operator_open: + next_reasons.append(&"CLOSED_BY_OPERATOR") + if next_state > State.CLOSED: + next_state = State.CLOSED + if not is_within_opening_hours(absolute_minute): + next_reasons.append(&"OUTSIDE_OPENING_HOURS") + if next_state > State.CLOSED: + next_state = State.CLOSED + if durability > 0 and durability < DEGRADED_DURABILITY_THRESHOLD: + next_reasons.append(&"LOW_DURABILITY") + if next_state > State.DEGRADED: + next_state = State.DEGRADED + for reason: StringName in degraded_reasons: + if not next_reasons.has(reason): + next_reasons.append(reason) + if next_state > State.DEGRADED: + next_state = State.DEGRADED + var changed := state != next_state + state = next_state + state_reasons = next_reasons + return changed + + +func can_start_service(absolute_minute: int) -> bool: + evaluate_state(absolute_minute) + return state in [State.OPEN, State.DEGRADED] and available_slots() > 0 + + +func available_slots() -> int: + return maxi(capacity - active_services.size(), 0) + + +func has_queue_space() -> bool: + return queue_entries.size() < queue_capacity + + +func apply_policy_patch(patch: Dictionary) -> bool: + var changed := false + var keys: Array = patch.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + var key := StringName(raw_key) + var value: Variant = patch[raw_key] + var has_existing := _has_value(policy_current, key) + var existing: Variant = _value(policy_current, key, null) + if not has_existing or existing != value: + policy_current.erase(String(key)) + policy_current[key] = _copy(value) + changed = true + if changed: + policy_revision += 1 + return changed + + +func schedule_policy_patch(patch: Dictionary, effective_at: int) -> bool: + if effective_at < 0 or patch.is_empty(): + return false + policy_pending = _normalize_policy(patch) + policy_pending_effective_at = effective_at + policy_pending_revision = policy_revision + 1 + return true + + +func apply_pending_policy(absolute_minute: int) -> bool: + if policy_pending.is_empty() or policy_pending_effective_at < 0: + return false + if absolute_minute < policy_pending_effective_at: + return false + var changed := apply_policy_patch(policy_pending) + policy_pending.clear() + policy_pending_effective_at = -1 + policy_pending_revision = 0 + return changed + + +func create_snapshot() -> Dictionary: + var saved_services: Array[Dictionary] = [] + var service_ids := _sorted_keys(active_services) + for service_id: StringName in service_ids: + var service: ServiceInstance = active_services[service_id] + saved_services.append(service.create_snapshot()) + return { + &"instance_id": String(instance_id), + &"definition_id": String(definition_id), + &"origin": [origin.x, origin.y, origin.z], + &"rotation": rotation, + &"occupied_cells": _serialize_cells(occupied_cells), + &"level": level, + &"state": State.keys()[state], + &"state_reasons": _names_to_strings(state_reasons), + &"durability": durability, + &"local_inventory_id": String(local_inventory_id), + &"assigned_staff_by_shift": assigned_staff_by_shift.duplicate(true), + &"active_staff_ids": _names_to_strings(active_staff_ids), + &"queue_entries": _names_to_strings(queue_entries), + &"active_services": saved_services, + &"policy_current": policy_current.duplicate(true), + &"policy_pending": policy_pending.duplicate(true), + &"policy_revision": policy_revision, + &"policy_pending_revision": policy_pending_revision, + &"policy_pending_effective_at": policy_pending_effective_at, + &"statistics_today": statistics_today.duplicate(true), + &"lifetime_statistics": lifetime_statistics.duplicate(true), + &"created_at": created_at, + &"capacity": capacity, + &"queue_capacity": queue_capacity, + &"base_service_minutes": base_service_minutes, + &"base_quality": base_quality, + &"base_price": base_price, + &"staff_min": staff_min, + &"staff_max": staff_max, + &"access_available": access_available, + &"power_available": power_available, + &"operator_open": operator_open, + &"opening_intervals": _serialize_intervals(opening_intervals), + &"degraded_reasons": _names_to_strings(degraded_reasons), + } + + +static func from_snapshot(snapshot: Dictionary) -> FacilityInstance: + if not _snapshot_shapes_are_valid(snapshot): + return null + var instance := FacilityInstance.new().configure( + StringName(snapshot.get(&"instance_id", "")), + StringName(snapshot.get(&"definition_id", "")), + { + &"origin": snapshot.get(&"origin", []), + &"rotation": snapshot.get(&"rotation", 0), + &"occupied_cells": snapshot.get(&"occupied_cells", []), + &"level": snapshot.get(&"level", 1), + &"durability": snapshot.get(&"durability", DEFAULT_DURABILITY), + &"local_inventory_id": snapshot.get(&"local_inventory_id", ""), + &"capacity": snapshot.get(&"capacity", 1), + &"queue_capacity": snapshot.get(&"queue_capacity", 0), + &"base_service_minutes": snapshot.get(&"base_service_minutes", 1), + &"base_quality": snapshot.get(&"base_quality", 0), + &"base_price": snapshot.get(&"base_price", 0), + &"staff_min": snapshot.get(&"staff_min", 0), + &"staff_max": snapshot.get(&"staff_max", 0), + &"policy_current": snapshot.get(&"policy_current", {}), + &"policy_revision": snapshot.get(&"policy_revision", 0), + &"created_at": snapshot.get(&"created_at", 0), + &"access_available": snapshot.get(&"access_available", true), + &"power_available": snapshot.get(&"power_available", true), + &"operator_open": snapshot.get(&"operator_open", true), + &"opening_intervals": snapshot.get(&"opening_intervals", []), + &"active_staff_ids": snapshot.get(&"active_staff_ids", []), + &"degraded_reasons": snapshot.get(&"degraded_reasons", []), + } + ) + var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN"))) + if state_index < 0: + return null + instance.state = state_index as State + instance.state_reasons = _string_name_array(snapshot.get(&"state_reasons", [])) + instance.assigned_staff_by_shift = snapshot.get(&"assigned_staff_by_shift", {}).duplicate(true) + instance.queue_entries = _string_name_array(snapshot.get(&"queue_entries", [])) + instance.policy_pending = _normalize_policy(snapshot.get(&"policy_pending", {})) + instance.policy_pending_revision = maxi(int(snapshot.get(&"policy_pending_revision", 0)), 0) + instance.policy_pending_effective_at = int(snapshot.get(&"policy_pending_effective_at", -1)) + instance.statistics_today = snapshot.get(&"statistics_today", {}).duplicate(true) + instance.lifetime_statistics = snapshot.get(&"lifetime_statistics", {}).duplicate(true) + var seen_services: Dictionary = {} + for saved_service: Variant in snapshot.get(&"active_services", []): + var service := ServiceInstanceType.from_snapshot(saved_service) + if service == null or service.is_terminal() or service.facility_id != instance.instance_id: + return null + if seen_services.has(service.service_id): + return null + seen_services[service.service_id] = true + instance.active_services[service.service_id] = service + if not instance.is_valid() or instance.active_services.size() > instance.capacity: + return null + return instance + + +static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool: + var array_fields := [ + &"origin", &"occupied_cells", &"state_reasons", &"active_staff_ids", + &"queue_entries", &"active_services", &"opening_intervals", &"degraded_reasons", + ] + for field: StringName in array_fields: + if not snapshot.get(field, null) is Array: + return false + var dictionary_fields := [ + &"assigned_staff_by_shift", &"policy_current", &"policy_pending", + &"statistics_today", &"lifetime_statistics", + ] + for field: StringName in dictionary_fields: + if not snapshot.get(field, null) is Dictionary: + return false + return true + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _has_value(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _copy(value: Variant) -> Variant: + return value.duplicate(true) if value is Dictionary or value is Array else value + + +static func _dictionary_copy(source: Variant) -> Dictionary: + return source.duplicate(true) if source is Dictionary else {} + + +static func _normalize_policy(source: Variant) -> Dictionary: + var normalized: Dictionary = {} + if not source is Dictionary: + return normalized + for raw_key: Variant in source: + normalized[StringName(raw_key)] = _copy(source[raw_key]) + return normalized + + +static func _vector3i(source: Variant) -> Vector3i: + if source is Vector3i: + return source + if source is Array and source.size() == 3: + return Vector3i(int(source[0]), int(source[1]), int(source[2])) + return Vector3i.ZERO + + +static func _cell_array(source: Variant) -> Array[Vector3i]: + var cells: Array[Vector3i] = [] + if not source is Array: + return cells + for raw_cell: Variant in source: + if raw_cell is Vector3i: + cells.append(raw_cell) + elif raw_cell is Array and raw_cell.size() == 3: + cells.append(_vector3i(raw_cell)) + return cells + + +static func _parse_intervals(source: Variant) -> Array[Vector2i]: + var intervals: Array[Vector2i] = [] + if not source is Array: + return intervals + for raw_interval: Variant in source: + if raw_interval is Vector2i: + intervals.append(raw_interval) + elif raw_interval is Array and raw_interval.size() == 2: + intervals.append(Vector2i(int(raw_interval[0]), int(raw_interval[1]))) + intervals.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: + return a.x < b.x or (a.x == b.x and a.y < b.y) + ) + return intervals + + +static func _intervals_are_valid(intervals: Array[Vector2i]) -> bool: + if intervals.is_empty(): + return false + var previous_end := -1 + for interval: Vector2i in intervals: + if interval.x < 0 or interval.y > FULL_DAY_MINUTES or interval.x >= interval.y: + return false + if interval.x < previous_end: + return false + previous_end = interval.y + return true + + +static func _sorted_unique_names(source: Variant) -> Array[StringName]: + var values := _string_name_array(source) + var seen: Dictionary = {} + var unique: Array[StringName] = [] + for value: StringName in values: + if not value.is_empty() and not seen.has(value): + seen[value] = true + unique.append(value) + unique.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return unique + + +static func _string_name_array(source: Variant) -> Array[StringName]: + var values: Array[StringName] = [] + if source is Array: + for value: Variant in source: + values.append(StringName(value)) + return values + + +static func _sorted_keys(source: Dictionary) -> Array[StringName]: + var values: Array[StringName] = [] + for value: Variant in source.keys(): + values.append(StringName(value)) + values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return values + + +static func _serialize_cells(source: Array[Vector3i]) -> Array[Array]: + var cells: Array[Array] = [] + for cell: Vector3i in source: + cells.append([cell.x, cell.y, cell.z]) + return cells + + +static func _serialize_intervals(source: Array[Vector2i]) -> Array[Array]: + var intervals: Array[Array] = [] + for interval: Vector2i in source: + intervals.append([interval.x, interval.y]) + return intervals + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var values: Array[String] = [] + for value: StringName in source: + values.append(String(value)) + return values + + +static func _normalize_rotation(value: int) -> int: + var normalized := posmod(value, 360) + return (normalized / 90) * 90 diff --git a/scripts/facility/facility_instance.gd.uid b/scripts/facility/facility_instance.gd.uid new file mode 100644 index 0000000..5cbc7f1 --- /dev/null +++ b/scripts/facility/facility_instance.gd.uid @@ -0,0 +1 @@ +uid://dwac1ecwvokkl diff --git a/scripts/facility/facility_service.gd b/scripts/facility/facility_service.gd new file mode 100644 index 0000000..ec62d7b --- /dev/null +++ b/scripts/facility/facility_service.gd @@ -0,0 +1,945 @@ +class_name FacilityService +extends RefCounted + +signal instance_registered(instance: FacilityInstance) +signal instance_unregistered(instance_id: StringName) +signal facility_state_changed(instance_id: StringName, old_state: int, new_state: int, reasons: Array[StringName]) +signal request_enqueued(request: ServiceRequest) +signal request_left_queue(request_id: StringName, reason: StringName) +signal service_started(service: ServiceInstance) +signal service_interrupted(service: ServiceInstance, reason: StringName) +signal service_completed(service: ServiceInstance) +signal service_failed(service: ServiceInstance, reason: StringName) + +const FacilityInstanceType = preload("res://scripts/facility/facility_instance.gd") +const ServiceRequestType = preload("res://scripts/facility/service_request.gd") +const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 1 +const STAFF_REPLACEMENT_GRACE_MINUTES := 5 + +const INTERRUPTION_PLAYER_CLOSED := &"PLAYER_CLOSED" +const INTERRUPTION_NO_POWER := &"NO_POWER" +const INTERRUPTION_MANA_STOP := &"MANA_STOP" +const INTERRUPTION_STAFF_UNAVAILABLE := &"STAFF_UNAVAILABLE" +const INTERRUPTION_EMERGENCY := &"EMERGENCY" +const INTERRUPTION_FIRE := &"FIRE" +const INTERRUPTION_RIOT := &"RIOT" +const INTERRUPTION_GUEST_LEFT := &"GUEST_LEFT" +const INTERRUPTION_BROKEN := &"BROKEN" + +var current_minute := 0 +var last_error := "" + +var _instances: Dictionary = {} +var _requests: Dictionary = {} +var _known_instance_ids: Dictionary = {} +var _known_request_ids: Dictionary = {} +var _known_service_ids: Dictionary = {} +var _service_history: Dictionary = {} +var _next_service_sequence := 1 +var _start_validator := Callable() +var _completion_callback := Callable() + + +func configure_callbacks( + start_validator: Callable = Callable(), + completion_callback: Callable = Callable() +) -> FacilityService: + _start_validator = start_validator + _completion_callback = completion_callback + return self + + +func set_start_validator(callback: Callable) -> void: + _start_validator = callback + + +func set_completion_callback(callback: Callable) -> void: + _completion_callback = callback + + +func register_instance(instance: Variant) -> CommandResult: + if instance == null or not instance is FacilityInstance: + return _invalid("Facility instance is missing or has an invalid type.") + var facility: FacilityInstance = instance + if not facility.is_valid(): + return _invalid("Facility instance is invalid.", {&"instance_id": facility.instance_id}) + if _known_instance_ids.has(facility.instance_id): + return _duplicate("Facility instance", facility.instance_id) + if not facility.queue_entries.is_empty() or not facility.active_services.is_empty(): + return _invalid( + "A new facility must be registered before it owns requests or services.", + {&"instance_id": facility.instance_id} + ) + _instances[facility.instance_id] = facility + _known_instance_ids[facility.instance_id] = true + facility.evaluate_state(current_minute) + instance_registered.emit(facility) + last_error = "" + return _success({&"instance_id": facility.instance_id}) + + +func create_instance(configuration: Dictionary) -> CommandResult: + var instance := FacilityInstanceType.new().configure( + StringName(_value(configuration, &"instance_id", &"")), + StringName(_value(configuration, &"definition_id", &"")), + configuration + ) + var result := register_instance(instance) + if result.is_success(): + result.details[&"instance"] = instance + return result + + +func has_instance(instance_id: StringName) -> bool: + return _instances.has(instance_id) + + +func get_instance(instance_id: StringName) -> FacilityInstance: + return _instances.get(instance_id) as FacilityInstance + + +func get_facility(instance_id: StringName) -> FacilityInstance: + return get_instance(instance_id) + + +func get_instances() -> Array[FacilityInstance]: + var result: Array[FacilityInstance] = [] + for instance_id: StringName in _sorted_keys(_instances): + result.append(_instances[instance_id]) + return result + + +func unregister_instance(instance_id: StringName) -> CommandResult: + var instance := get_instance(instance_id) + if instance == null: + return _not_found("Facility instance", instance_id) + if not instance.queue_entries.is_empty() or not instance.active_services.is_empty(): + return _invalid( + "A facility with queued or active services cannot be unregistered.", + {&"instance_id": instance_id} + ) + _instances.erase(instance_id) + instance_unregistered.emit(instance_id) + last_error = "" + return _success({&"instance_id": instance_id}) + + +func set_operational_status( + instance_id: StringName, + conditions: Dictionary, + absolute_minute: int = -1 +) -> CommandResult: + var instance := get_instance(instance_id) + if instance == null: + return _not_found("Facility instance", instance_id) + var minute := _resolve_minute(absolute_minute) + var old_state := instance.state + instance.set_operational_conditions(conditions, minute) + _emit_state_change(instance, old_state) + last_error = "" + return _success({ + &"instance_id": instance_id, + &"state": FacilityInstanceType.State.keys()[instance.state], + &"state_reasons": instance.state_reasons.duplicate(), + }) + + +func set_opening_hours( + instance_id: StringName, + open_minute_of_day: int, + close_minute_of_day: int, + absolute_minute: int = -1 +) -> CommandResult: + var instance := get_instance(instance_id) + if instance == null: + return _not_found("Facility instance", instance_id) + if not instance.set_opening_hours(open_minute_of_day, close_minute_of_day): + return _invalid("Opening hours must be valid minute-of-day values.") + var old_state := instance.state + instance.evaluate_state(_resolve_minute(absolute_minute)) + _emit_state_change(instance, old_state) + last_error = "" + return _success({&"instance_id": instance_id}) + + +func set_policy( + instance_id: StringName, + patch: Dictionary, + effective_at: int = -1 +) -> CommandResult: + var instance := get_instance(instance_id) + if instance == null: + return _not_found("Facility instance", instance_id) + if patch.is_empty(): + return _invalid("Policy patch cannot be empty.") + if effective_at >= 0 and effective_at > current_minute: + instance.schedule_policy_patch(patch, effective_at) + last_error = "" + return _success({ + &"instance_id": instance_id, + &"pending_revision": instance.policy_pending_revision, + &"effective_at": effective_at, + }) + var changed := instance.apply_policy_patch(patch) + last_error = "" + return _success({ + &"instance_id": instance_id, + &"policy_revision": instance.policy_revision, + &"changed": changed, + }) + + +func create_request( + request_id: StringName, + facility_id: StringName, + guest_ids: Array[StringName], + service_type: StringName, + created_at: int, + options: Dictionary = {} +) -> ServiceRequest: + var instance := get_instance(facility_id) + if instance == null: + return null + var quotation := options.duplicate(true) + if not _has_value(quotation, &"quoted_price"): + quotation[&"quoted_price"] = instance.base_price + if not _has_value(quotation, &"expected_quality"): + quotation[&"expected_quality"] = instance.base_quality + if not _has_value(quotation, &"service_minutes"): + quotation[&"service_minutes"] = instance.base_service_minutes + quotation[&"policy_snapshot"] = instance.policy_current.duplicate(true) + quotation[&"policy_revision"] = instance.policy_revision + return ServiceRequestType.new().configure( + request_id, + facility_id, + guest_ids, + service_type, + created_at, + quotation + ) + + +func enqueue_request(request: Variant, absolute_minute: int = -1) -> CommandResult: + if request == null or not request is ServiceRequest: + return _invalid("Service request is missing or has an invalid type.") + var queued_request: ServiceRequest = request + if not queued_request.is_valid(): + return _invalid("Service request is invalid.") + if _known_request_ids.has(queued_request.request_id): + return _duplicate("Service request", queued_request.request_id) + var instance := get_instance(queued_request.facility_id) + if instance == null: + return _not_found("Facility instance", queued_request.facility_id) + if not instance.has_queue_space(): + return _invalid( + "Facility queue is full.", + {&"instance_id": instance.instance_id, &"queue_capacity": instance.queue_capacity} + ) + var minute := _resolve_minute(absolute_minute) + queued_request.enqueued_at = minute + _requests[queued_request.request_id] = queued_request + _known_request_ids[queued_request.request_id] = true + instance.queue_entries.append(queued_request.request_id) + _sort_queue(instance) + queued_request.expected_wait = _estimate_wait_before(instance, queued_request.request_id) + _increment_stat(instance.statistics_today, &"requests_enqueued") + _increment_stat(instance.lifetime_statistics, &"requests_enqueued") + request_enqueued.emit(queued_request) + last_error = "" + return _success({ + &"request_id": queued_request.request_id, + &"expected_wait": queued_request.expected_wait, + &"queue_position": instance.queue_entries.find(queued_request.request_id), + }) + + +func submit_request(request: Variant, absolute_minute: int = -1) -> CommandResult: + return enqueue_request(request, absolute_minute) + + +func get_request(request_id: StringName) -> ServiceRequest: + return _requests.get(request_id) as ServiceRequest + + +func get_queue(instance_id: StringName) -> Array[ServiceRequest]: + var queued: Array[ServiceRequest] = [] + var instance := get_instance(instance_id) + if instance == null: + return queued + for request_id: StringName in instance.queue_entries: + var request := get_request(request_id) + if request != null: + queued.append(request) + return queued + + +func peek_next_request(instance_id: StringName) -> ServiceRequest: + var instance := get_instance(instance_id) + if instance == null or instance.queue_entries.is_empty(): + return null + return get_request(instance.queue_entries[0]) + + +func leave_queue( + instance_id: StringName, + request_id: StringName, + reason: StringName = &"GUEST_LEFT" +) -> CommandResult: + var instance := get_instance(instance_id) + if instance == null: + return _not_found("Facility instance", instance_id) + var queue_index := instance.queue_entries.find(request_id) + if queue_index < 0: + return _not_found("Queued service request", request_id) + instance.queue_entries.remove_at(queue_index) + _requests.erase(request_id) + _increment_stat(instance.statistics_today, &"queue_departures") + _increment_stat(instance.lifetime_statistics, &"queue_departures") + request_left_queue.emit(request_id, reason) + last_error = "" + return _success({&"request_id": request_id, &"reason": reason}) + + +func cancel_request(request_id: StringName, reason: StringName = &"GUEST_LEFT") -> CommandResult: + for instance_id: StringName in _sorted_keys(_instances): + var instance: FacilityInstance = _instances[instance_id] + if instance.queue_entries.has(request_id): + return leave_queue(instance_id, request_id, reason) + return _not_found("Queued service request", request_id) + + +func start_next_service( + instance_id: StringName, + absolute_minute: int = -1, + validator: Callable = Callable() +) -> CommandResult: + var instance := get_instance(instance_id) + if instance == null: + return _not_found("Facility instance", instance_id) + var minute := _resolve_minute(absolute_minute) + instance.apply_pending_policy(minute) + var old_state := instance.state + instance.evaluate_state(minute) + _emit_state_change(instance, old_state) + if not instance.can_start_service(minute): + return _invalid( + "Facility cannot start a service in its current state.", + { + &"instance_id": instance_id, + &"state": FacilityInstanceType.State.keys()[instance.state], + &"reasons": instance.state_reasons.duplicate(), + } + ) + _cleanup_missing_queue_requests(instance) + if instance.queue_entries.is_empty(): + return _not_found("Queued service request", &"") + var request := get_request(instance.queue_entries[0]) + if request == null: + return _invalid("The next queue entry is corrupt.") + var effective_validator := validator if validator.is_valid() else _start_validator + var callback_failure := _run_boundary_callback( + effective_validator, + [request, instance, minute], + "Service start validation failed.", + CommandResultType.Code.VALIDATION_FAILED + ) + if callback_failure != null: + callback_failure.details[&"request_id"] = request.request_id + callback_failure.details[&"instance_id"] = instance_id + return callback_failure + + var service_id := _next_service_id() + var service := ServiceInstanceType.new().configure( + service_id, + request.request_id, + instance_id, + minute, + request.service_minutes if request.service_minutes > 0 else instance.base_service_minutes, + request.guest_ids, + instance.active_staff_ids, + { + &"quality_snapshot": request.expected_quality, + &"price_snapshot": request.quoted_price, + &"policy_snapshot": request.policy_snapshot, + &"inventory_reservations": request.reserved_inventory_ids, + } + ) + instance.queue_entries.remove_at(0) + instance.active_services[service_id] = service + _known_service_ids[service_id] = true + _increment_stat(instance.statistics_today, &"services_started") + _increment_stat(instance.lifetime_statistics, &"services_started") + service_started.emit(service) + last_error = "" + return _success({ + &"instance_id": instance_id, + &"request_id": request.request_id, + &"service_id": service_id, + &"slot": instance.capacity - instance.available_slots() - 1, + }) + + +func fill_available_slots( + instance_id: StringName, + absolute_minute: int = -1, + validator: Callable = Callable() +) -> Array[CommandResult]: + var results: Array[CommandResult] = [] + var instance := get_instance(instance_id) + if instance == null: + results.append(_not_found("Facility instance", instance_id)) + return results + var minute := _resolve_minute(absolute_minute) + while instance.available_slots() > 0 and not instance.queue_entries.is_empty(): + var result := start_next_service(instance_id, minute, validator) + results.append(result) + if not result.is_success(): + break + return results + + +func advance_one_minute(absolute_minute: int = -1) -> Array[CommandResult]: + if absolute_minute < 0: + current_minute += 1 + elif absolute_minute < current_minute: + return [_invalid("FacilityService cannot advance backwards in time.")] + else: + current_minute = absolute_minute + var results: Array[CommandResult] = [] + for instance_id: StringName in _sorted_keys(_instances): + var instance: FacilityInstance = _instances[instance_id] + instance.apply_pending_policy(current_minute) + var old_state := instance.state + instance.evaluate_state(current_minute) + _emit_state_change(instance, old_state) + var active_ids := _sorted_keys(instance.active_services) + for service_id: StringName in active_ids: + if not instance.active_services.has(service_id): + continue + var service: ServiceInstance = instance.active_services[service_id] + var preparation := _prepare_service_for_tick(instance, service) + if preparation != null: + results.append(preparation) + if not instance.active_services.has(service_id): + continue + if service.state != ServiceInstanceType.State.ACTIVE: + continue + var ready_to_complete := service.elapsed_minutes >= service.duration_minutes + if not ready_to_complete: + ready_to_complete = service.advance_one_minute() + if ready_to_complete: + results.append(_complete_service(instance, service)) + var starts := fill_available_slots(instance_id, current_minute) + results.append_array(starts) + last_error = "" + return results + + +func interrupt_service( + service_id: StringName, + reason: StringName, + absolute_minute: int = -1 +) -> CommandResult: + var located := _find_active_service(service_id) + if located.is_empty(): + return _not_found("Active service", service_id) + var instance: FacilityInstance = located[&"instance"] + var service: ServiceInstance = located[&"service"] + var minute := _resolve_minute(absolute_minute) + match reason: + INTERRUPTION_PLAYER_CLOSED: + return _success({&"service_id": service_id, &"continues": true}) + INTERRUPTION_NO_POWER, INTERRUPTION_MANA_STOP: + service.pause(INTERRUPTION_NO_POWER, minute) + INTERRUPTION_STAFF_UNAVAILABLE: + service.wait_for_staff(reason, minute, STAFF_REPLACEMENT_GRACE_MINUTES) + INTERRUPTION_BROKEN, INTERRUPTION_EMERGENCY, INTERRUPTION_FIRE, INTERRUPTION_RIOT, INTERRUPTION_GUEST_LEFT: + _fail_service(instance, service, reason, minute) + return _success({&"service_id": service_id, &"failed": true, &"reason": reason}) + _: + return _invalid("Unknown service interruption reason.", {&"reason": reason}) + service_interrupted.emit(service, reason) + last_error = "" + return _success({ + &"service_id": service_id, + &"state": ServiceInstanceType.State.keys()[service.state], + &"reason": reason, + }) + + +func resume_service(service_id: StringName) -> CommandResult: + var located := _find_active_service(service_id) + if located.is_empty(): + return _not_found("Active service", service_id) + var service: ServiceInstance = located[&"service"] + if not service.resume(): + return _invalid("Only an interrupted service can resume.") + last_error = "" + return _success({&"service_id": service_id}) + + +func replace_service_staff(service_id: StringName, staff_ids: Array[StringName]) -> CommandResult: + var located := _find_active_service(service_id) + if located.is_empty(): + return _not_found("Active service", service_id) + if staff_ids.is_empty(): + return _invalid("Replacement staff list cannot be empty.") + var service: ServiceInstance = located[&"service"] + service.staff_ids.assign(staff_ids) + if service.state == ServiceInstanceType.State.WAITING_FOR_STAFF: + service.resume() + last_error = "" + return _success({&"service_id": service_id}) + + +func get_service(service_id: StringName) -> ServiceInstance: + var located := _find_active_service(service_id) + if not located.is_empty(): + return located[&"service"] as ServiceInstance + return _service_history.get(service_id) as ServiceInstance + + +func get_active_services(instance_id: StringName) -> Array[ServiceInstance]: + var services: Array[ServiceInstance] = [] + var instance := get_instance(instance_id) + if instance == null: + return services + for service_id: StringName in _sorted_keys(instance.active_services): + services.append(instance.active_services[service_id]) + return services + + +func create_snapshot() -> Dictionary: + var saved_instances: Array[Dictionary] = [] + for instance_id: StringName in _sorted_keys(_instances): + var instance: FacilityInstance = _instances[instance_id] + saved_instances.append(instance.create_snapshot()) + var saved_requests: Array[Dictionary] = [] + for request_id: StringName in _sorted_keys(_requests): + var request: ServiceRequest = _requests[request_id] + saved_requests.append(request.create_snapshot()) + var saved_history: Array[Dictionary] = [] + for service_id: StringName in _sorted_keys(_service_history): + var service: ServiceInstance = _service_history[service_id] + saved_history.append(service.create_snapshot()) + return { + &"schema_version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"next_service_sequence": _next_service_sequence, + &"known_instance_ids": _keys_to_strings(_known_instance_ids), + &"known_request_ids": _keys_to_strings(_known_request_ids), + &"known_service_ids": _keys_to_strings(_known_service_ids), + &"instances": saved_instances, + &"requests": saved_requests, + &"service_history": saved_history, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if int(_value(snapshot, &"schema_version", 0)) != SNAPSHOT_VERSION: + last_error = "Unsupported FacilityService snapshot version." + return false + var array_fields := [ + &"known_instance_ids", &"known_request_ids", &"known_service_ids", + &"instances", &"requests", &"service_history", + ] + for field: StringName in array_fields: + if not _value(snapshot, field, null) is Array: + last_error = "FacilityService snapshot field '%s' must be an Array." % field + return false + + var restored_instances: Dictionary = {} + for saved_instance: Variant in _value(snapshot, &"instances", []): + if not saved_instance is Dictionary: + last_error = "Facility snapshot entry must be a Dictionary." + return false + var instance := FacilityInstanceType.from_snapshot(saved_instance) + if instance == null or restored_instances.has(instance.instance_id): + last_error = "Facility snapshot contains an invalid or duplicate instance." + return false + restored_instances[instance.instance_id] = instance + + var restored_requests: Dictionary = {} + for saved_request: Variant in _value(snapshot, &"requests", []): + if not saved_request is Dictionary: + last_error = "Request snapshot entry must be a Dictionary." + return false + var request := ServiceRequestType.from_snapshot(saved_request) + if request == null or restored_requests.has(request.request_id): + last_error = "Facility snapshot contains an invalid or duplicate request." + return false + restored_requests[request.request_id] = request + + var restored_history: Dictionary = {} + for saved_service: Variant in _value(snapshot, &"service_history", []): + if not saved_service is Dictionary: + last_error = "Service history entry must be a Dictionary." + return false + var service := ServiceInstanceType.from_snapshot(saved_service) + if service == null or not service.is_terminal() or restored_history.has(service.service_id): + last_error = "Facility snapshot contains invalid service history." + return false + restored_history[service.service_id] = service + + var restored_known_instances: Variant = _id_set(_value(snapshot, &"known_instance_ids", [])) + var restored_known_requests: Variant = _id_set(_value(snapshot, &"known_request_ids", [])) + var restored_known_services: Variant = _id_set(_value(snapshot, &"known_service_ids", [])) + if restored_known_instances == null or restored_known_requests == null or restored_known_services == null: + last_error = "Known ID lists contain an empty or duplicate ID." + return false + if not _validate_restored_graph( + restored_instances, + restored_requests, + restored_history, + restored_known_instances, + restored_known_requests, + restored_known_services + ): + return false + + var restored_next_sequence := int(_value(snapshot, &"next_service_sequence", 0)) + var restored_minute := int(_value(snapshot, &"current_minute", -1)) + if restored_next_sequence < 1 or restored_minute < 0: + last_error = "FacilityService counters are invalid." + return false + + # Commit only after every object and cross-reference has validated. + _instances = restored_instances + _requests = restored_requests + _service_history = restored_history + _known_instance_ids = restored_known_instances + _known_request_ids = restored_known_requests + _known_service_ids = restored_known_services + _next_service_sequence = restored_next_sequence + current_minute = restored_minute + last_error = "" + return true + + +func _validate_restored_graph( + instances: Dictionary, + requests: Dictionary, + history: Dictionary, + known_instances: Dictionary, + known_requests: Dictionary, + known_services: Dictionary +) -> bool: + var referenced_requests: Dictionary = {} + var active_service_ids: Dictionary = {} + for instance_id: StringName in _sorted_keys(instances): + var instance: FacilityInstance = instances[instance_id] + if not known_instances.has(instance_id): + last_error = "Active facility ID is absent from known IDs." + return false + for request_id: StringName in instance.queue_entries: + if referenced_requests.has(request_id) or not requests.has(request_id): + last_error = "Queue contains a duplicate or missing request." + return false + var request: ServiceRequest = requests[request_id] + if request.facility_id != instance_id: + last_error = "Queued request targets another facility." + return false + referenced_requests[request_id] = true + for service_id: StringName in _sorted_keys(instance.active_services): + var service: ServiceInstance = instance.active_services[service_id] + if active_service_ids.has(service_id) or history.has(service_id): + last_error = "Service ID occurs more than once." + return false + if not known_services.has(service_id) or not requests.has(service.request_id): + last_error = "Active service has a missing known ID or request." + return false + if referenced_requests.has(service.request_id): + last_error = "Request is referenced by multiple queue/service entries." + return false + var request: ServiceRequest = requests[service.request_id] + if request.facility_id != instance_id: + last_error = "Active service request targets another facility." + return false + referenced_requests[service.request_id] = true + active_service_ids[service_id] = true + for request_id: StringName in _sorted_keys(requests): + if not known_requests.has(request_id) or not referenced_requests.has(request_id): + last_error = "Live request is orphaned or absent from known IDs." + return false + for service_id: StringName in _sorted_keys(history): + if not known_services.has(service_id): + last_error = "Historical service is absent from known IDs." + return false + return true + + +func _prepare_service_for_tick( + instance: FacilityInstance, + service: ServiceInstance +) -> CommandResult: + if instance.state == FacilityInstanceType.State.BROKEN: + _fail_service(instance, service, INTERRUPTION_BROKEN, current_minute) + return _success({&"service_id": service.service_id, &"failed": true}) + if not instance.power_available: + if service.state == ServiceInstanceType.State.ACTIVE: + service.pause(INTERRUPTION_NO_POWER, current_minute) + service_interrupted.emit(service, INTERRUPTION_NO_POWER) + return null + if service.state == ServiceInstanceType.State.PAUSED \ + and service.interruption_reason == INTERRUPTION_NO_POWER: + service.resume() + var has_required_staff := instance.active_staff_ids.size() >= instance.staff_min + if not has_required_staff: + if service.state in [ServiceInstanceType.State.ACTIVE, ServiceInstanceType.State.PAUSED]: + service.wait_for_staff( + INTERRUPTION_STAFF_UNAVAILABLE, + current_minute, + STAFF_REPLACEMENT_GRACE_MINUTES + ) + service_interrupted.emit(service, INTERRUPTION_STAFF_UNAVAILABLE) + elif instance.staff_min > 0 \ + and service.state == ServiceInstanceType.State.WAITING_FOR_STAFF \ + and service.interruption_reason == INTERRUPTION_STAFF_UNAVAILABLE: + service.resume() + if service.state == ServiceInstanceType.State.WAITING_FOR_STAFF: + if service.staff_grace_deadline >= 0 and current_minute >= service.staff_grace_deadline: + _fail_service(instance, service, INTERRUPTION_STAFF_UNAVAILABLE, current_minute) + return _success({&"service_id": service.service_id, &"failed": true}) + return null + + +func _complete_service( + instance: FacilityInstance, + service: ServiceInstance, + callback: Callable = Callable() +) -> CommandResult: + var request := get_request(service.request_id) + if request == null: + _fail_service(instance, service, &"MISSING_REQUEST", current_minute) + return _invalid( + "Active service lost its request and was failed safely.", + {&"service_id": service.service_id} + ) + var effective_callback := callback if callback.is_valid() else _completion_callback + var callback_failure := _run_boundary_callback( + effective_callback, + [service, request, instance, current_minute], + "Service completion transaction failed.", + CommandResultType.Code.TRANSACTION_APPLY_FAILED + ) + if callback_failure != null: + callback_failure.details[&"service_id"] = service.service_id + callback_failure.details[&"request_id"] = request.request_id + return callback_failure + service.mark_completed(current_minute) + instance.active_services.erase(service.service_id) + _requests.erase(request.request_id) + _service_history[service.service_id] = service + _increment_stat(instance.statistics_today, &"services_completed") + _increment_stat(instance.lifetime_statistics, &"services_completed") + service_completed.emit(service) + return _success({ + &"instance_id": instance.instance_id, + &"request_id": request.request_id, + &"service_id": service.service_id, + &"completed": true, + }) + + +func _fail_service( + instance: FacilityInstance, + service: ServiceInstance, + reason: StringName, + absolute_minute: int +) -> void: + service.mark_failed(reason, absolute_minute) + instance.active_services.erase(service.service_id) + _requests.erase(service.request_id) + _service_history[service.service_id] = service + _increment_stat(instance.statistics_today, &"services_failed") + _increment_stat(instance.lifetime_statistics, &"services_failed") + service_failed.emit(service, reason) + + +func _run_boundary_callback( + callback: Callable, + arguments: Array, + fallback_message: String, + failure_code: CommandResult.Code +) -> CommandResult: + if not callback.is_valid(): + return null + var argument_count := callback.get_argument_count() + var effective_arguments := arguments + if argument_count >= 0 and argument_count < arguments.size(): + effective_arguments = arguments.slice(0, argument_count) + var returned: Variant = callback.callv(effective_arguments) + if returned == null or (returned is bool and returned): + return null + if returned is CommandResult: + var command_result: CommandResult = returned + return null if command_result.is_success() else command_result.copy() + if returned is Dictionary: + if bool(returned.get(&"ok", returned.get("ok", false))): + return null + var dictionary_message := String( + returned.get(&"message", returned.get("message", fallback_message)) + ) + return CommandResultType.failed(failure_code, 0, dictionary_message, returned) + var message := String(returned) if returned is String and not returned.is_empty() else fallback_message + return CommandResultType.failed(failure_code, 0, message) + + +func _estimate_wait_before(instance: FacilityInstance, target_request_id: StringName) -> int: + var outstanding_minutes := 0 + for service_id: StringName in _sorted_keys(instance.active_services): + var service: ServiceInstance = instance.active_services[service_id] + outstanding_minutes += service.remaining_minutes() + for request_id: StringName in instance.queue_entries: + if request_id == target_request_id: + break + var request := get_request(request_id) + if request != null: + outstanding_minutes += request.service_minutes + return ceili(float(outstanding_minutes) / float(maxi(instance.capacity, 1))) + + +func _cleanup_missing_queue_requests(instance: FacilityInstance) -> void: + var valid_ids: Array[StringName] = [] + for request_id: StringName in instance.queue_entries: + if _requests.has(request_id): + valid_ids.append(request_id) + else: + push_warning( + "Removed corrupt FacilityService queue entry '%s' from '%s'." \ + % [request_id, instance.instance_id] + ) + instance.queue_entries = valid_ids + _sort_queue(instance) + + +func _sort_queue(instance: FacilityInstance) -> void: + instance.queue_entries.sort_custom(Callable(self, "_queue_entry_less")) + + +func _queue_entry_less(left_id: StringName, right_id: StringName) -> bool: + var left := get_request(left_id) + var right := get_request(right_id) + if left == null: + return false + if right == null: + return true + if left.priority != right.priority: + return left.priority > right.priority + if left.enqueued_at != right.enqueued_at: + return left.enqueued_at < right.enqueued_at + return String(left.request_id) < String(right.request_id) + + +func _find_active_service(service_id: StringName) -> Dictionary: + for instance_id: StringName in _sorted_keys(_instances): + var instance: FacilityInstance = _instances[instance_id] + if instance.active_services.has(service_id): + return { + &"instance": instance, + &"service": instance.active_services[service_id], + } + return {} + + +func _next_service_id() -> StringName: + while true: + var candidate := StringName("service_%08d" % _next_service_sequence) + _next_service_sequence += 1 + if not _known_service_ids.has(candidate): + return candidate + return &"" + + +func _resolve_minute(absolute_minute: int) -> int: + if absolute_minute >= 0: + current_minute = maxi(current_minute, absolute_minute) + return current_minute + + +func _emit_state_change(instance: FacilityInstance, old_state: int) -> void: + if old_state != instance.state: + facility_state_changed.emit( + instance.instance_id, + old_state, + instance.state, + instance.state_reasons.duplicate() + ) + + +func _success(details: Dictionary = {}) -> CommandResult: + return CommandResultType.succeeded(0, details) + + +func _invalid(message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed( + CommandResultType.Code.VALIDATION_FAILED, + 0, + message, + details + ) + + +func _not_found(entity_name: String, entity_id: StringName) -> CommandResult: + var message := "%s '%s' was not found." % [entity_name, entity_id] + last_error = message + return CommandResultType.failed( + CommandResultType.Code.NOT_FOUND, + 0, + message, + {&"id": entity_id} + ) + + +func _duplicate(entity_name: String, entity_id: StringName) -> CommandResult: + var message := "%s '%s' already exists." % [entity_name, entity_id] + last_error = message + return CommandResultType.failed( + CommandResultType.Code.DUPLICATE_COMMAND_ID, + 0, + message, + {&"id": entity_id} + ) + + +static func _increment_stat(statistics: Dictionary, key: StringName) -> void: + statistics[key] = int(statistics.get(key, 0)) + 1 + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _has_value(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _sorted_keys(source: Dictionary) -> Array[StringName]: + var values: Array[StringName] = [] + for value: Variant in source.keys(): + values.append(StringName(value)) + values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return values + + +static func _keys_to_strings(source: Dictionary) -> Array[String]: + var values: Array[String] = [] + for key: StringName in _sorted_keys(source): + values.append(String(key)) + return values + + +static func _id_set(source: Variant) -> Variant: + if not source is Array: + return null + var ids: Dictionary = {} + for raw_id: Variant in source: + var id := StringName(raw_id) + if id.is_empty() or ids.has(id): + return null + ids[id] = true + return ids diff --git a/scripts/facility/facility_service.gd.uid b/scripts/facility/facility_service.gd.uid new file mode 100644 index 0000000..fff8c33 --- /dev/null +++ b/scripts/facility/facility_service.gd.uid @@ -0,0 +1 @@ +uid://ln0q7wmvm8kx diff --git a/scripts/facility/service_instance.gd b/scripts/facility/service_instance.gd new file mode 100644 index 0000000..cb181a8 --- /dev/null +++ b/scripts/facility/service_instance.gd @@ -0,0 +1,232 @@ +class_name ServiceInstance +extends RefCounted + +enum State { + ACTIVE, + PAUSED, + WAITING_FOR_STAFF, + COMPLETED, + FAILED, + CANCELLED, +} + +const NO_DEADLINE := -1 + +var service_id: StringName = &"" +var request_id: StringName = &"" +var facility_id: StringName = &"" +var started_at := 0 +var finish_at := 1 +var guest_ids: Array[StringName] = [] +var staff_ids: Array[StringName] = [] +var quality_snapshot := 0 +var price_snapshot := 0 +var policy_snapshot: Dictionary = {} +var inventory_reservations: Array[StringName] = [] +var interruption_policy: Dictionary = {} +var duration_minutes := 1 +var elapsed_minutes := 0 +var state: State = State.ACTIVE +var interruption_reason: StringName = &"" +var interrupted_at := -1 +var staff_grace_deadline := NO_DEADLINE +var completed_at := -1 +var failure_reason: StringName = &"" + + +func configure( + p_service_id: StringName, + p_request_id: StringName, + p_facility_id: StringName, + p_started_at: int, + p_duration_minutes: int, + p_guest_ids: Array[StringName], + p_staff_ids: Array[StringName], + options: Dictionary = {} +) -> ServiceInstance: + service_id = p_service_id + request_id = p_request_id + facility_id = p_facility_id + started_at = maxi(p_started_at, 0) + duration_minutes = maxi(p_duration_minutes, 1) + finish_at = started_at + duration_minutes + guest_ids.assign(p_guest_ids) + staff_ids.assign(p_staff_ids) + quality_snapshot = int(_value(options, &"quality_snapshot", 0)) + price_snapshot = maxi(int(_value(options, &"price_snapshot", 0)), 0) + policy_snapshot = _dictionary_copy(_value(options, &"policy_snapshot", {})) + inventory_reservations = _string_name_array( + _value(options, &"inventory_reservations", []) + ) + interruption_policy = _dictionary_copy(_value(options, &"interruption_policy", {})) + return self + + +func is_valid() -> bool: + return not service_id.is_empty() \ + and not request_id.is_empty() \ + and not facility_id.is_empty() \ + and not guest_ids.is_empty() \ + and duration_minutes > 0 \ + and elapsed_minutes >= 0 \ + and elapsed_minutes <= duration_minutes + + +func is_terminal() -> bool: + return state in [State.COMPLETED, State.FAILED, State.CANCELLED] + + +func can_progress() -> bool: + return state == State.ACTIVE and elapsed_minutes < duration_minutes + + +func advance_one_minute() -> bool: + if not can_progress(): + return false + elapsed_minutes = mini(elapsed_minutes + 1, duration_minutes) + return elapsed_minutes >= duration_minutes + + +func pause(reason: StringName, absolute_minute: int) -> bool: + if state != State.ACTIVE: + return false + state = State.PAUSED + interruption_reason = reason + interrupted_at = absolute_minute + return true + + +func wait_for_staff(reason: StringName, absolute_minute: int, grace_minutes := 5) -> bool: + if state not in [State.ACTIVE, State.PAUSED]: + return false + state = State.WAITING_FOR_STAFF + interruption_reason = reason + interrupted_at = absolute_minute + staff_grace_deadline = absolute_minute + maxi(grace_minutes, 0) + return true + + +func resume() -> bool: + if state not in [State.PAUSED, State.WAITING_FOR_STAFF]: + return false + state = State.ACTIVE + interruption_reason = &"" + interrupted_at = -1 + staff_grace_deadline = NO_DEADLINE + return true + + +func mark_completed(absolute_minute: int) -> void: + elapsed_minutes = duration_minutes + state = State.COMPLETED + completed_at = absolute_minute + interruption_reason = &"" + failure_reason = &"" + staff_grace_deadline = NO_DEADLINE + + +func mark_failed(reason: StringName, absolute_minute: int) -> void: + state = State.FAILED + failure_reason = reason + completed_at = absolute_minute + staff_grace_deadline = NO_DEADLINE + + +func mark_cancelled(reason: StringName, absolute_minute: int) -> void: + state = State.CANCELLED + failure_reason = reason + completed_at = absolute_minute + staff_grace_deadline = NO_DEADLINE + + +func remaining_minutes() -> int: + return maxi(duration_minutes - elapsed_minutes, 0) + + +func create_snapshot() -> Dictionary: + return { + &"service_id": String(service_id), + &"request_id": String(request_id), + &"facility_id": String(facility_id), + &"started_at": started_at, + &"finish_at": finish_at, + &"guest_ids": _names_to_strings(guest_ids), + &"staff_ids": _names_to_strings(staff_ids), + &"quality_snapshot": quality_snapshot, + &"price_snapshot": price_snapshot, + &"policy_snapshot": policy_snapshot.duplicate(true), + &"inventory_reservations": _names_to_strings(inventory_reservations), + &"interruption_policy": interruption_policy.duplicate(true), + &"duration_minutes": duration_minutes, + &"elapsed_minutes": elapsed_minutes, + &"state": State.keys()[state], + &"interruption_reason": String(interruption_reason), + &"interrupted_at": interrupted_at, + &"staff_grace_deadline": staff_grace_deadline, + &"completed_at": completed_at, + &"failure_reason": String(failure_reason), + } + + +static func from_snapshot(snapshot: Dictionary) -> ServiceInstance: + if not snapshot.get(&"guest_ids", null) is Array: + return null + if not snapshot.get(&"staff_ids", null) is Array: + return null + if not snapshot.get(&"policy_snapshot", null) is Dictionary: + return null + if not snapshot.get(&"inventory_reservations", null) is Array: + return null + if not snapshot.get(&"interruption_policy", null) is Dictionary: + return null + var service := ServiceInstance.new().configure( + StringName(snapshot.get(&"service_id", "")), + StringName(snapshot.get(&"request_id", "")), + StringName(snapshot.get(&"facility_id", "")), + int(snapshot.get(&"started_at", 0)), + int(snapshot.get(&"duration_minutes", 1)), + _string_name_array(snapshot.get(&"guest_ids", [])), + _string_name_array(snapshot.get(&"staff_ids", [])), + { + &"quality_snapshot": snapshot.get(&"quality_snapshot", 0), + &"price_snapshot": snapshot.get(&"price_snapshot", 0), + &"policy_snapshot": snapshot.get(&"policy_snapshot", {}), + &"inventory_reservations": snapshot.get(&"inventory_reservations", []), + &"interruption_policy": snapshot.get(&"interruption_policy", {}), + } + ) + var state_index := State.keys().find(String(snapshot.get(&"state", "ACTIVE"))) + if state_index < 0: + return null + service.state = state_index as State + service.finish_at = int(snapshot.get(&"finish_at", service.started_at + service.duration_minutes)) + service.elapsed_minutes = int(snapshot.get(&"elapsed_minutes", 0)) + service.interruption_reason = StringName(snapshot.get(&"interruption_reason", "")) + service.interrupted_at = int(snapshot.get(&"interrupted_at", -1)) + service.staff_grace_deadline = int(snapshot.get(&"staff_grace_deadline", NO_DEADLINE)) + service.completed_at = int(snapshot.get(&"completed_at", -1)) + service.failure_reason = StringName(snapshot.get(&"failure_reason", "")) + return service if service.is_valid() else null + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _dictionary_copy(source: Variant) -> Dictionary: + return source.duplicate(true) if source is Dictionary else {} + + +static func _string_name_array(source: Variant) -> Array[StringName]: + var values: Array[StringName] = [] + if source is Array: + for value: Variant in source: + values.append(StringName(value)) + return values + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var values: Array[String] = [] + for value: StringName in source: + values.append(String(value)) + return values diff --git a/scripts/facility/service_instance.gd.uid b/scripts/facility/service_instance.gd.uid new file mode 100644 index 0000000..c12f6b6 --- /dev/null +++ b/scripts/facility/service_instance.gd.uid @@ -0,0 +1 @@ +uid://cj7rmhd4xylof diff --git a/scripts/facility/service_request.gd b/scripts/facility/service_request.gd new file mode 100644 index 0000000..9b03640 --- /dev/null +++ b/scripts/facility/service_request.gd @@ -0,0 +1,138 @@ +class_name ServiceRequest +extends RefCounted + +## Immutable-at-enqueue quotation for one facility visit. +## +## FacilityService owns the lifecycle. Price and policy values are copied when +## this object is configured so later facility policy changes cannot alter an +## already queued request. + +var request_id: StringName = &"" +var guest_ids: Array[StringName] = [] +var facility_id: StringName = &"" +var service_type: StringName = &"default" +var created_at := 0 +var quoted_price := 0 +var policy_snapshot: Dictionary = {} +var policy_revision := 0 +var reserved_inventory_ids: Array[StringName] = [] +var preference_match := 0 +var expected_quality := 0 +var expected_wait := 0 +var priority := 0 +var enqueued_at := -1 +var service_minutes := 1 + + +func configure( + p_request_id: StringName, + p_facility_id: StringName, + p_guest_ids: Array[StringName], + p_service_type: StringName, + p_created_at: int, + options: Dictionary = {} +) -> ServiceRequest: + request_id = p_request_id + facility_id = p_facility_id + guest_ids.assign(p_guest_ids) + service_type = p_service_type + created_at = maxi(p_created_at, 0) + quoted_price = maxi(int(_value(options, &"quoted_price", 0)), 0) + policy_snapshot = _dictionary_copy(_value(options, &"policy_snapshot", {})) + policy_revision = maxi(int(_value(options, &"policy_revision", 0)), 0) + reserved_inventory_ids = _string_name_array( + _value(options, &"reserved_inventory_ids", []) + ) + preference_match = int(_value(options, &"preference_match", 0)) + expected_quality = int(_value(options, &"expected_quality", 0)) + expected_wait = maxi(int(_value(options, &"expected_wait", 0)), 0) + priority = int(_value(options, &"priority", 0)) + enqueued_at = int(_value(options, &"enqueued_at", -1)) + service_minutes = maxi(int(_value(options, &"service_minutes", 1)), 1) + return self + + +func is_valid() -> bool: + if request_id.is_empty() or facility_id.is_empty() or service_type.is_empty(): + return false + if guest_ids.is_empty() or service_minutes <= 0 or quoted_price < 0: + return false + var seen: Dictionary = {} + for guest_id: StringName in guest_ids: + if guest_id.is_empty() or seen.has(guest_id): + return false + seen[guest_id] = true + return true + + +func create_snapshot() -> Dictionary: + return { + &"request_id": String(request_id), + &"guest_ids": _names_to_strings(guest_ids), + &"facility_id": String(facility_id), + &"service_type": String(service_type), + &"created_at": created_at, + &"quoted_price": quoted_price, + &"policy_snapshot": policy_snapshot.duplicate(true), + &"policy_revision": policy_revision, + &"reserved_inventory_ids": _names_to_strings(reserved_inventory_ids), + &"preference_match": preference_match, + &"expected_quality": expected_quality, + &"expected_wait": expected_wait, + &"priority": priority, + &"enqueued_at": enqueued_at, + &"service_minutes": service_minutes, + } + + +static func from_snapshot(snapshot: Dictionary) -> ServiceRequest: + if not snapshot.get(&"guest_ids", null) is Array: + return null + if not snapshot.get(&"policy_snapshot", null) is Dictionary: + return null + if not snapshot.get(&"reserved_inventory_ids", null) is Array: + return null + var request := ServiceRequest.new().configure( + StringName(snapshot.get(&"request_id", "")), + StringName(snapshot.get(&"facility_id", "")), + _string_name_array(snapshot.get(&"guest_ids", [])), + StringName(snapshot.get(&"service_type", "default")), + int(snapshot.get(&"created_at", 0)), + { + &"quoted_price": snapshot.get(&"quoted_price", 0), + &"policy_snapshot": snapshot.get(&"policy_snapshot", {}), + &"policy_revision": snapshot.get(&"policy_revision", 0), + &"reserved_inventory_ids": snapshot.get(&"reserved_inventory_ids", []), + &"preference_match": snapshot.get(&"preference_match", 0), + &"expected_quality": snapshot.get(&"expected_quality", 0), + &"expected_wait": snapshot.get(&"expected_wait", 0), + &"priority": snapshot.get(&"priority", 0), + &"enqueued_at": snapshot.get(&"enqueued_at", -1), + &"service_minutes": snapshot.get(&"service_minutes", 1), + } + ) + return request if request.is_valid() else null + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _dictionary_copy(source: Variant) -> Dictionary: + return source.duplicate(true) if source is Dictionary else {} + + +static func _string_name_array(source: Variant) -> Array[StringName]: + var values: Array[StringName] = [] + if not source is Array: + return values + for value: Variant in source: + values.append(StringName(value)) + return values + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var values: Array[String] = [] + for value: StringName in source: + values.append(String(value)) + return values diff --git a/scripts/facility/service_request.gd.uid b/scripts/facility/service_request.gd.uid new file mode 100644 index 0000000..7a2f337 --- /dev/null +++ b/scripts/facility/service_request.gd.uid @@ -0,0 +1 @@ +uid://bqhf3q6264fc3 diff --git a/scripts/guest/guest_definition.gd b/scripts/guest/guest_definition.gd new file mode 100644 index 0000000..b96da18 --- /dev/null +++ b/scripts/guest/guest_definition.gd @@ -0,0 +1,269 @@ +class_name GuestDefinition +extends RefCounted + +## Immutable guest-archetype data used by GuestService. +## +## Runtime systems may construct definitions from Resources later. Keeping the +## simulation representation independent from Resource loading makes headless +## tests, migrations and deterministic snapshots straightforward. + +const NEED_IDS: Array[StringName] = [ + &"hunger", + &"sleep", + &"hygiene", + &"medical", + &"leisure", + &"intimacy", +] + +var guest_type_id: StringName = &"" +var tags: Array[StringName] = [] +var wealth_min := 0 +var wealth_max := 0 +var base_patience := 30 +var risk_tolerance := 50 +var preference_weights: Dictionary = {} +var schedule_profile: StringName = &"default" +var group_size_weights: Dictionary = {1: 100} +var legal_sensitivity := 50 +var quality_sensitivity := 50 +var adult_verified := false +var need_rates: Dictionary = {} +var credit_limit_gold := 0 +var minimum_quality := 0 +var _configuration_valid := true + + +func configure(p_guest_type_id: StringName, options: Dictionary = {}) -> GuestDefinition: + _configuration_valid = true + guest_type_id = p_guest_type_id + tags = _sorted_unique_names(_value(options, &"tags", [])) + wealth_min = maxi(int(_value(options, &"wealth_min", 0)), 0) + wealth_max = maxi(int(_value(options, &"wealth_max", wealth_min)), wealth_min) + base_patience = maxi(int(_value(options, &"base_patience", 30)), 0) + risk_tolerance = clampi(int(_value(options, &"risk_tolerance", 50)), 0, 100) + preference_weights = _normalized_name_int_dictionary( + _value(options, &"preference_weights", {}) + ) + schedule_profile = StringName(_value(options, &"schedule_profile", &"default")) + group_size_weights = _normalized_group_weights( + _value(options, &"group_size_weights", {1: 100}) + ) + legal_sensitivity = clampi(int(_value(options, &"legal_sensitivity", 50)), 0, 100) + quality_sensitivity = clampi(int(_value(options, &"quality_sensitivity", 50)), 0, 100) + var raw_adult_verified: Variant = _value(options, &"adult_verified", false) + if not raw_adult_verified is bool: + _configuration_valid = false + adult_verified = false + else: + adult_verified = raw_adult_verified + var normalized_need_rates: Variant = _normalized_need_dictionary( + _value(options, &"need_rates", {}), + false + ) + need_rates = normalized_need_rates if normalized_need_rates is Dictionary else {} + if not normalized_need_rates is Dictionary: + _configuration_valid = false + credit_limit_gold = maxi(int(_value(options, &"credit_limit_gold", 0)), 0) + minimum_quality = clampi(int(_value(options, &"minimum_quality", 0)), 0, 100) + return self + + +func is_valid() -> bool: + if not _configuration_valid: + return false + if guest_type_id.is_empty() or schedule_profile.is_empty(): + return false + if wealth_min < 0 or wealth_max < wealth_min or base_patience < 0: + return false + if risk_tolerance < 0 or risk_tolerance > 100: + return false + if legal_sensitivity < 0 or legal_sensitivity > 100: + return false + if quality_sensitivity < 0 or quality_sensitivity > 100: + return false + if minimum_quality < 0 or minimum_quality > 100 or credit_limit_gold < 0: + return false + if not _name_int_dictionary_is_valid(preference_weights): + return false + if group_size_weights.is_empty(): + return false + for raw_size: Variant in group_size_weights: + var group_size := int(raw_size) + if group_size < 1 or group_size > 4 or int(group_size_weights[raw_size]) <= 0: + return false + if need_rates.size() != NEED_IDS.size(): + return false + for need_id: StringName in NEED_IDS: + if not need_rates.has(need_id) or not need_rates[need_id] is int: + return false + return true + + +func get_need_rate(need_id: StringName) -> int: + return int(need_rates.get(need_id, 0)) + + +func preference_for(facility_tags: Variant) -> int: + var total := 0 + for tag: StringName in _sorted_unique_names(facility_tags): + total += int(preference_weights.get(tag, 0)) + return total + + +func create_snapshot() -> Dictionary: + return { + &"guest_type_id": String(guest_type_id), + &"tags": _names_to_strings(tags), + &"wealth_min": wealth_min, + &"wealth_max": wealth_max, + &"base_patience": base_patience, + &"risk_tolerance": risk_tolerance, + &"preference_weights": _string_key_dictionary(preference_weights), + &"schedule_profile": String(schedule_profile), + &"group_size_weights": group_size_weights.duplicate(true), + &"legal_sensitivity": legal_sensitivity, + &"quality_sensitivity": quality_sensitivity, + &"adult_verified": adult_verified, + &"need_rates": _string_key_dictionary(need_rates), + &"credit_limit_gold": credit_limit_gold, + &"minimum_quality": minimum_quality, + } + + +static func from_snapshot(snapshot: Dictionary) -> GuestDefinition: + if not snapshot.get(&"tags", null) is Array: + return null + if not snapshot.get(&"preference_weights", null) is Dictionary: + return null + if not snapshot.get(&"group_size_weights", null) is Dictionary: + return null + if not snapshot.get(&"need_rates", null) is Dictionary: + return null + var definition := GuestDefinition.new() + definition.guest_type_id = StringName(snapshot.get(&"guest_type_id", "")) + definition.tags = _sorted_unique_names(snapshot.get(&"tags", [])) + definition.wealth_min = int(snapshot.get(&"wealth_min", -1)) + definition.wealth_max = int(snapshot.get(&"wealth_max", -1)) + definition.base_patience = int(snapshot.get(&"base_patience", -1)) + definition.risk_tolerance = int(snapshot.get(&"risk_tolerance", -1)) + definition.preference_weights = _normalized_name_int_dictionary( + snapshot.get(&"preference_weights", {}) + ) + definition.schedule_profile = StringName(snapshot.get(&"schedule_profile", "")) + definition.group_size_weights = _normalized_group_weights( + snapshot.get(&"group_size_weights", {}) + ) + definition.legal_sensitivity = int(snapshot.get(&"legal_sensitivity", -1)) + definition.quality_sensitivity = int(snapshot.get(&"quality_sensitivity", -1)) + var raw_adult_verified: Variant = snapshot.get(&"adult_verified", null) + if not raw_adult_verified is bool: + return null + definition.adult_verified = raw_adult_verified + var normalized_need_rates: Variant = _normalized_need_dictionary( + snapshot.get(&"need_rates", {}), + true + ) + if not normalized_need_rates is Dictionary: + return null + definition.need_rates = normalized_need_rates + definition.credit_limit_gold = int(snapshot.get(&"credit_limit_gold", -1)) + definition.minimum_quality = int(snapshot.get(&"minimum_quality", -1)) + return definition if definition.is_valid() else null + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _normalized_need_dictionary(source: Variant, require_all: bool) -> Variant: + var result: Dictionary = {} + if not source is Dictionary: + return null + var seen: Dictionary = {} + for raw_key: Variant in source: + if not raw_key is String and not raw_key is StringName: + return null + var need_id := StringName(raw_key) + if not NEED_IDS.has(need_id) or seen.has(need_id): + return null + var raw_value: Variant = source[raw_key] + if not raw_value is int: + return null + seen[need_id] = true + result[need_id] = raw_value + if require_all and seen.size() != NEED_IDS.size(): + return null + for need_id: StringName in NEED_IDS: + if not result.has(need_id): + result[need_id] = 0 + return result + + +static func _zero_needs() -> Dictionary: + var result: Dictionary = {} + for need_id: StringName in NEED_IDS: + result[need_id] = 0 + return result + + +static func _normalized_name_int_dictionary(source: Variant) -> Dictionary: + var result: Dictionary = {} + if not source is Dictionary: + return result + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + var key := StringName(raw_key) + if key.is_empty(): + continue + result[key] = int(source[raw_key]) + return result + + +static func _normalized_group_weights(source: Variant) -> Dictionary: + var result: Dictionary = {} + if not source is Dictionary: + return result + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return int(a) < int(b)) + for raw_key: Variant in keys: + var group_size := int(raw_key) + if group_size >= 1 and group_size <= 4: + result[group_size] = int(source[raw_key]) + return result + + +static func _name_int_dictionary_is_valid(source: Dictionary) -> bool: + for raw_key: Variant in source: + if StringName(raw_key).is_empty() or not source[raw_key] is int: + return false + return true + + +static func _sorted_unique_names(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for raw_value: Variant in source: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + result[String(raw_key)] = source[raw_key] + return result diff --git a/scripts/guest/guest_definition.gd.uid b/scripts/guest/guest_definition.gd.uid new file mode 100644 index 0000000..41955d3 --- /dev/null +++ b/scripts/guest/guest_definition.gd.uid @@ -0,0 +1 @@ +uid://cxu6iqddv86k5 diff --git a/scripts/guest/guest_runtime.gd b/scripts/guest/guest_runtime.gd new file mode 100644 index 0000000..cb81f30 --- /dev/null +++ b/scripts/guest/guest_runtime.gd @@ -0,0 +1,388 @@ +class_name GuestRuntime +extends RefCounted + +## Authoritative per-guest state. All scalar simulation values are integers. + +enum State { + SPAWNING, + ENTERING, + DECIDING, + MOVING, + QUEUING, + RECEIVING_SERVICE, + LEAVING, + DESPAWNING, + REPLANNING, + ABANDONING, + INTERRUPTED, + FLEEING, + INCAPACITATED, +} + +const NEED_IDS: Array[StringName] = [ + &"hunger", + &"sleep", + &"hygiene", + &"medical", + &"leisure", + &"intimacy", +] + +var guest_id: StringName = &"" +var guest_type_id: StringName = &"" +var group_id: StringName = &"" +var wallet_gold := 0 +var credit_limit_gold := 0 +var current_cell := Vector3i.ZERO +var state: State = State.SPAWNING +var state_entered_at := 0 +var target_facility_id: StringName = &"" +var current_service_id: StringName = &"" +var queue_request_id: StringName = &"" +var needs: Dictionary = {} +var satisfaction_total := 0 +var satisfaction_samples := 0 +var patience_remaining := 0 +var debt_gold := 0 +var suspicion := 0 +var addiction_by_tag: Dictionary = {} +var visited_facility_ids: Array[StringName] = [] +var path_handle: Variant = null +var planned_exit_at := 0 + +## Recovery and lifecycle data used by GuestService. +var path_failure_count := 0 +var recovery_kind: StringName = &"" +var recovery_target_cell := Vector3i.ZERO +var last_progress_cell := Vector3i.ZERO +var last_progress_at := 0 +var unresolved_incident_ids: Array[StringName] = [] +var outstanding_reservation_ids: Array[StringName] = [] +var interrupted_service_ids: Array[StringName] = [] +var exit_reason: StringName = &"" +var transition_tick := -1 +var transitions_this_tick := 0 + + +func configure( + p_guest_id: StringName, + p_guest_type_id: StringName, + options: Dictionary = {} +) -> GuestRuntime: + guest_id = p_guest_id + guest_type_id = p_guest_type_id + group_id = StringName(_value(options, &"group_id", &"")) + wallet_gold = maxi(int(_value(options, &"wallet_gold", 0)), 0) + credit_limit_gold = maxi(int(_value(options, &"credit_limit_gold", 0)), 0) + current_cell = _vector3i(_value(options, &"current_cell", Vector3i.ZERO)) + state = _state_from_variant(_value(options, &"state", State.SPAWNING)) + state_entered_at = maxi(int(_value(options, &"state_entered_at", 0)), 0) + target_facility_id = StringName(_value(options, &"target_facility_id", &"")) + current_service_id = StringName(_value(options, &"current_service_id", &"")) + queue_request_id = StringName(_value(options, &"queue_request_id", &"")) + var normalized_needs: Variant = _normalized_needs( + _value(options, &"needs", {}), + true, + false + ) + needs = normalized_needs if normalized_needs is Dictionary else {} + satisfaction_total = maxi(int(_value(options, &"satisfaction_total", 0)), 0) + satisfaction_samples = maxi(int(_value(options, &"satisfaction_samples", 0)), 0) + patience_remaining = maxi(int(_value(options, &"patience_remaining", 0)), 0) + debt_gold = maxi(int(_value(options, &"debt_gold", 0)), 0) + suspicion = clampi(int(_value(options, &"suspicion", 0)), 0, 100) + addiction_by_tag = _normalized_clamped_dictionary( + _value(options, &"addiction_by_tag", {}) + ) + visited_facility_ids = _sorted_unique_names(_value(options, &"visited_facility_ids", [])) + path_handle = _copy(_value(options, &"path_handle", null)) + planned_exit_at = maxi(int(_value(options, &"planned_exit_at", state_entered_at)), state_entered_at) + path_failure_count = maxi(int(_value(options, &"path_failure_count", 0)), 0) + recovery_kind = StringName(_value(options, &"recovery_kind", &"")) + recovery_target_cell = _vector3i(_value(options, &"recovery_target_cell", current_cell)) + last_progress_cell = _vector3i(_value(options, &"last_progress_cell", current_cell)) + last_progress_at = maxi(int(_value(options, &"last_progress_at", state_entered_at)), 0) + unresolved_incident_ids = _sorted_unique_names( + _value(options, &"unresolved_incident_ids", []) + ) + outstanding_reservation_ids = _sorted_unique_names( + _value(options, &"outstanding_reservation_ids", []) + ) + interrupted_service_ids = _sorted_unique_names( + _value(options, &"interrupted_service_ids", []) + ) + exit_reason = StringName(_value(options, &"exit_reason", &"")) + transition_tick = int(_value(options, &"transition_tick", -1)) + transitions_this_tick = maxi(int(_value(options, &"transitions_this_tick", 0)), 0) + return self + + +func is_valid() -> bool: + if guest_id.is_empty() or guest_type_id.is_empty(): + return false + if wallet_gold < 0 or credit_limit_gold < 0 or debt_gold < 0: + return false + if state < State.SPAWNING or state > State.INCAPACITATED: + return false + if state_entered_at < 0 or planned_exit_at < state_entered_at: + return false + if satisfaction_total < 0 or satisfaction_samples < 0 or patience_remaining < 0: + return false + if suspicion < 0 or suspicion > 100 or path_failure_count < 0: + return false + if transitions_this_tick < 0: + return false + if needs.size() != NEED_IDS.size(): + return false + for need_id: StringName in NEED_IDS: + if not needs.has(need_id) or not needs[need_id] is int: + return false + var value := int(needs[need_id]) + if value < 0 or value > 100: + return false + for raw_tag: Variant in addiction_by_tag: + var addiction := int(addiction_by_tag[raw_tag]) + if StringName(raw_tag).is_empty() or addiction < 0 or addiction > 100: + return false + return _state_references_are_valid() + + +func average_satisfaction() -> int: + if satisfaction_samples <= 0: + return 50 + return clampi(satisfaction_total / satisfaction_samples, 0, 100) + + +func max_addiction() -> int: + var result := 0 + for value: Variant in addiction_by_tag.values(): + result = maxi(result, int(value)) + return result + + +func create_snapshot() -> Dictionary: + return { + &"guest_id": String(guest_id), + &"guest_type_id": String(guest_type_id), + &"group_id": String(group_id), + &"wallet_gold": wallet_gold, + &"credit_limit_gold": credit_limit_gold, + &"current_cell": _cell_to_array(current_cell), + &"state": State.keys()[state], + &"state_entered_at": state_entered_at, + &"target_facility_id": String(target_facility_id), + &"current_service_id": String(current_service_id), + &"queue_request_id": String(queue_request_id), + &"needs": _string_key_dictionary(needs), + &"satisfaction_total": satisfaction_total, + &"satisfaction_samples": satisfaction_samples, + &"patience_remaining": patience_remaining, + &"debt_gold": debt_gold, + &"suspicion": suspicion, + &"addiction_by_tag": _string_key_dictionary(addiction_by_tag), + &"visited_facility_ids": _names_to_strings(visited_facility_ids), + &"path_handle": _copy(path_handle), + &"planned_exit_at": planned_exit_at, + &"path_failure_count": path_failure_count, + &"recovery_kind": String(recovery_kind), + &"recovery_target_cell": _cell_to_array(recovery_target_cell), + &"last_progress_cell": _cell_to_array(last_progress_cell), + &"last_progress_at": last_progress_at, + &"unresolved_incident_ids": _names_to_strings(unresolved_incident_ids), + &"outstanding_reservation_ids": _names_to_strings(outstanding_reservation_ids), + &"interrupted_service_ids": _names_to_strings(interrupted_service_ids), + &"exit_reason": String(exit_reason), + &"transition_tick": transition_tick, + &"transitions_this_tick": transitions_this_tick, + } + + +static func from_snapshot(snapshot: Dictionary) -> GuestRuntime: + for array_key: StringName in [ + &"current_cell", + &"recovery_target_cell", + &"last_progress_cell", + &"visited_facility_ids", + &"unresolved_incident_ids", + &"outstanding_reservation_ids", + &"interrupted_service_ids", + ]: + if not snapshot.get(array_key, null) is Array: + return null + if not snapshot.get(&"needs", null) is Dictionary: + return null + if not snapshot.get(&"addiction_by_tag", null) is Dictionary: + return null + var state_names := State.keys() + var state_index := state_names.find(String(snapshot.get(&"state", ""))) + if state_index < 0: + return null + var runtime := GuestRuntime.new() + runtime.guest_id = StringName(snapshot.get(&"guest_id", "")) + runtime.guest_type_id = StringName(snapshot.get(&"guest_type_id", "")) + runtime.group_id = StringName(snapshot.get(&"group_id", "")) + runtime.wallet_gold = int(snapshot.get(&"wallet_gold", -1)) + runtime.credit_limit_gold = int(snapshot.get(&"credit_limit_gold", -1)) + runtime.current_cell = _vector3i(snapshot.get(&"current_cell", [])) + runtime.state = state_index as State + runtime.state_entered_at = int(snapshot.get(&"state_entered_at", -1)) + runtime.target_facility_id = StringName(snapshot.get(&"target_facility_id", "")) + runtime.current_service_id = StringName(snapshot.get(&"current_service_id", "")) + runtime.queue_request_id = StringName(snapshot.get(&"queue_request_id", "")) + var normalized_needs: Variant = _normalized_needs( + snapshot.get(&"needs", {}), + false, + true + ) + if not normalized_needs is Dictionary: + return null + runtime.needs = normalized_needs + runtime.satisfaction_total = int(snapshot.get(&"satisfaction_total", -1)) + runtime.satisfaction_samples = int(snapshot.get(&"satisfaction_samples", -1)) + runtime.patience_remaining = int(snapshot.get(&"patience_remaining", -1)) + runtime.debt_gold = int(snapshot.get(&"debt_gold", -1)) + runtime.suspicion = int(snapshot.get(&"suspicion", -1)) + runtime.addiction_by_tag = _normalized_clamped_dictionary_strict( + snapshot.get(&"addiction_by_tag", {}) + ) + runtime.visited_facility_ids = _sorted_unique_names(snapshot.get(&"visited_facility_ids", [])) + runtime.path_handle = _copy(snapshot.get(&"path_handle", null)) + runtime.planned_exit_at = int(snapshot.get(&"planned_exit_at", -1)) + runtime.path_failure_count = int(snapshot.get(&"path_failure_count", -1)) + runtime.recovery_kind = StringName(snapshot.get(&"recovery_kind", "")) + runtime.recovery_target_cell = _vector3i(snapshot.get(&"recovery_target_cell", [])) + runtime.last_progress_cell = _vector3i(snapshot.get(&"last_progress_cell", [])) + runtime.last_progress_at = int(snapshot.get(&"last_progress_at", -1)) + runtime.unresolved_incident_ids = _sorted_unique_names( + snapshot.get(&"unresolved_incident_ids", []) + ) + runtime.outstanding_reservation_ids = _sorted_unique_names( + snapshot.get(&"outstanding_reservation_ids", []) + ) + runtime.interrupted_service_ids = _sorted_unique_names( + snapshot.get(&"interrupted_service_ids", []) + ) + runtime.exit_reason = StringName(snapshot.get(&"exit_reason", "")) + runtime.transition_tick = int(snapshot.get(&"transition_tick", -1)) + runtime.transitions_this_tick = int(snapshot.get(&"transitions_this_tick", -1)) + return runtime if runtime.is_valid() else null + + +func _state_references_are_valid() -> bool: + if state == State.QUEUING: + return not target_facility_id.is_empty() and not queue_request_id.is_empty() + if state == State.RECEIVING_SERVICE: + return not target_facility_id.is_empty() and not current_service_id.is_empty() + return true + + +static func _state_from_variant(value: Variant) -> State: + if value is String or value is StringName: + var index := State.keys().find(String(value)) + return index as State if index >= 0 else State.SPAWNING + var parsed := int(value) + return parsed as State if parsed >= State.SPAWNING and parsed <= State.INCAPACITATED else State.SPAWNING + + +static func _normalized_needs( + source: Variant, + clamp_values: bool, + require_all: bool +) -> Variant: + var result: Dictionary = {} + if not source is Dictionary: + return null + var seen: Dictionary = {} + for raw_key: Variant in source: + if not raw_key is String and not raw_key is StringName: + return null + var need_id := StringName(raw_key) + if not NEED_IDS.has(need_id) or seen.has(need_id): + return null + var raw_value: Variant = source[raw_key] + if not raw_value is int: + return null + seen[need_id] = true + result[need_id] = clampi(raw_value, 0, 100) if clamp_values else raw_value + if require_all and seen.size() != NEED_IDS.size(): + return null + for need_id: StringName in NEED_IDS: + if not result.has(need_id): + result[need_id] = 0 + return result + + +static func _normalized_clamped_dictionary(source: Variant) -> Dictionary: + var result: Dictionary = {} + if not source is Dictionary: + return result + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + var key := StringName(raw_key) + if not key.is_empty(): + result[key] = clampi(int(source[raw_key]), 0, 100) + return result + + +static func _normalized_clamped_dictionary_strict(source: Variant) -> Dictionary: + var result: Dictionary = {} + if not source is Dictionary: + return result + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + var key := StringName(raw_key) + if not key.is_empty(): + result[key] = int(source[raw_key]) + return result + + +static func _sorted_unique_names(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for raw_value: Variant in source: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() == 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + return Vector3i.ZERO + + +static func _cell_to_array(cell: Vector3i) -> Array[int]: + return [cell.x, cell.y, cell.z] + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + result[String(raw_key)] = source[raw_key] + return result + + +static func _copy(value: Variant) -> Variant: + if value is Array or value is Dictionary: + return value.duplicate(true) + return value diff --git a/scripts/guest/guest_runtime.gd.uid b/scripts/guest/guest_runtime.gd.uid new file mode 100644 index 0000000..7d64240 --- /dev/null +++ b/scripts/guest/guest_runtime.gd.uid @@ -0,0 +1 @@ +uid://cip4mv8n4qotq diff --git a/scripts/guest/guest_service.gd b/scripts/guest/guest_service.gd new file mode 100644 index 0000000..bc6d0f9 --- /dev/null +++ b/scripts/guest/guest_service.gd @@ -0,0 +1,1611 @@ +class_name GuestService +extends RefCounted + +## Deterministic guest needs, decisions and lifecycle authority. +## +## Facility, path and economy integrations intentionally cross Dictionary and +## Callable boundaries. GuestService therefore remains independently testable +## and cannot accidentally mutate another service's private state. + +signal guest_spawned(guest: GuestRuntime) +signal guest_state_changed(guest_id: StringName, old_state: int, new_state: int, reason: StringName) +signal guest_queue_abandoned(guest_id: StringName, facility_id: StringName, request_id: StringName) +signal guest_recovery_started(guest_id: StringName, recovery_kind: StringName, target_cell: Vector3i) +signal guest_despawned(guest_id: StringName, persistent_record: Dictionary) + +const GuestDefinitionType = preload("res://scripts/guest/guest_definition.gd") +const GuestRuntimeType = preload("res://scripts/guest/guest_runtime.gd") + +const SNAPSHOT_VERSION := 2 +const MAX_TRANSITIONS_PER_TICK := 4 +const PATH_FAILURE_LIMIT := 3 +const STUCK_REPLAN_MINUTES := 60 +const DEFAULT_WAIT_HARD_LIMIT := 120 +const DEFAULT_EXIT_THRESHOLD := 15 + +const NEED_IDS: Array[StringName] = [ + &"hunger", + &"sleep", + &"hygiene", + &"medical", + &"leisure", + &"intimacy", +] +const LEGACY_NEED_IDS: Array[StringName] = [ + &"hunger", + &"rest", + &"fun", + &"social", + &"safety", +] + +var current_minute := -1 +var last_error := "" + +var _definitions: Dictionary = {} +var _guests: Dictionary = {} +var _persistent_records: Dictionary = {} +var _decision_rejections: Dictionary = {} +var _diagnostics: Array[Dictionary] = [] +var _sorted_guest_ids_cache: Array[StringName] = [] +var _guest_order_dirty := true +var _pending_spawn_notifications: Dictionary = {} +var _external_mutation_blocked := false + + +func register_definition(definition: Variant) -> bool: + if definition == null or not definition is GuestDefinition: + return _fail("Guest definition has an invalid type.") + var typed_definition: GuestDefinition = definition + if not typed_definition.is_valid(): + return _fail("Guest definition is invalid.") + if _definitions.has(typed_definition.guest_type_id): + return _fail("Duplicate guest definition: %s" % typed_definition.guest_type_id) + _definitions[typed_definition.guest_type_id] = typed_definition + last_error = "" + return true + + +func create_definition(guest_type_id: StringName, options: Dictionary = {}) -> GuestDefinition: + var definition := GuestDefinitionType.new().configure(guest_type_id, options) + return definition if register_definition(definition) else null + + +func get_definition(guest_type_id: StringName) -> GuestDefinition: + return _definitions.get(guest_type_id) as GuestDefinition + + +func get_definitions() -> Array[GuestDefinition]: + var result: Array[GuestDefinition] = [] + for guest_type_id: StringName in _sorted_ids(_definitions.keys()): + result.append(_definitions[guest_type_id]) + return result + + +func register_guest(guest: Variant, emit_notification := true) -> bool: + if _external_mutation_blocked: + return _fail("Guest mutation is blocked while committed notifications are delivered.") + if guest == null or not guest is GuestRuntime: + return _fail("Guest runtime has an invalid type.") + var runtime: GuestRuntime = guest + if not runtime.is_valid(): + return _fail("Guest runtime is invalid.") + if not _definitions.has(runtime.guest_type_id): + return _fail("Guest runtime references an unknown definition.") + if _guests.has(runtime.guest_id) or _persistent_records.has(runtime.guest_id): + return _fail("Duplicate guest id: %s" % runtime.guest_id) + _guests[runtime.guest_id] = runtime + _guest_order_dirty = true + if emit_notification: + guest_spawned.emit(runtime) + else: + _pending_spawn_notifications[runtime.guest_id] = true + last_error = "" + return true + + +func spawn_guest( + guest_id: StringName, + guest_type_id: StringName, + absolute_minute: int, + options: Dictionary = {}, + emit_notification := true +) -> GuestRuntime: + var definition := get_definition(guest_type_id) + if definition == null or absolute_minute < 0: + _fail("A known definition and non-negative spawn minute are required.") + return null + var runtime_options := options.duplicate(true) + if not _has_value(runtime_options, &"wallet_gold"): + runtime_options[&"wallet_gold"] = definition.wealth_min + if not _has_value(runtime_options, &"credit_limit_gold"): + runtime_options[&"credit_limit_gold"] = definition.credit_limit_gold + if not _has_value(runtime_options, &"patience_remaining"): + runtime_options[&"patience_remaining"] = definition.base_patience + if not _has_value(runtime_options, &"state"): + runtime_options[&"state"] = GuestRuntimeType.State.SPAWNING + if not _has_value(runtime_options, &"state_entered_at"): + runtime_options[&"state_entered_at"] = absolute_minute + if not _has_value(runtime_options, &"planned_exit_at"): + runtime_options[&"planned_exit_at"] = absolute_minute + 720 + var runtime := GuestRuntimeType.new().configure(guest_id, guest_type_id, runtime_options) + if not register_guest(runtime, emit_notification): + return null + return runtime + + +func commit_guest_spawned_notification(guest_id: StringName) -> bool: + if not _pending_spawn_notifications.has(guest_id) or not _guests.has(guest_id): + return _fail("Guest has no pending spawn notification: %s" % guest_id) + _pending_spawn_notifications.erase(guest_id) + guest_spawned.emit(_guests[guest_id] as GuestRuntime) + last_error = "" + return true + + +func discard_guest_spawned_notification(guest_id: StringName) -> bool: + if not _pending_spawn_notifications.has(guest_id): + return false + _pending_spawn_notifications.erase(guest_id) + return true + + +func has_pending_guest_spawned_notification(guest_id: StringName) -> bool: + return _pending_spawn_notifications.has(guest_id) + + +func set_external_mutation_blocked(blocked: bool) -> void: + _external_mutation_blocked = blocked + + +func has_guest(guest_id: StringName) -> bool: + return _guests.has(guest_id) + + +func get_guest(guest_id: StringName) -> GuestRuntime: + return _guests.get(guest_id) as GuestRuntime + + +func get_guests() -> Array[GuestRuntime]: + var result: Array[GuestRuntime] = [] + for guest_id: StringName in _ordered_guest_ids(): + result.append(_guests[guest_id]) + return result + + +func guest_count() -> int: + return _guests.size() + + +func get_persistent_record(guest_id: StringName) -> Dictionary: + if not _persistent_records.has(guest_id): + return {} + return (_persistent_records[guest_id] as Dictionary).duplicate(true) + + +func get_diagnostics() -> Array[Dictionary]: + return _diagnostics.duplicate(true) + + +func advance_one_minute( + absolute_minute: int, + context_by_guest: Dictionary = {} +) -> Dictionary: + if absolute_minute < 0 or absolute_minute <= current_minute: + return _error_result("Guest minute must advance monotonically.") + var ordered_guest_ids := _ordered_guest_ids() + # The common no-context tick has no external values to validate or isolate. + # Avoid allocating one duplicated context plus a resolved-context entry per + # Guest while retaining the same deterministic order and report contract. + if context_by_guest.is_empty(): + return _advance_with_shared_need_context( + absolute_minute, + ordered_guest_ids, + {} + ) + var resolved_contexts: Dictionary = {} + var default_context: Dictionary = {} + var raw_default: Variant = _value(context_by_guest, &"default", {}) + if raw_default is Dictionary: + default_context = raw_default + # Production supplies one common default context. Validate it once, then + # share the same read-only Dictionary across the population. Any per-Guest + # override keeps using the all-context preflight below, preserving atomic + # rejection and the malformed-override contract. + if context_by_guest.size() == 1 and _has_value(context_by_guest, &"default"): + # Historically an unused default was not validated when the population + # was empty. Preserve that edge case and the first-Guest error wording. + if not ordered_guest_ids.is_empty(): + var default_error := _need_context_error(default_context) + if not default_error.is_empty(): + return _error_result( + "Invalid Need context for %s: %s" % [ + ordered_guest_ids[0], + default_error, + ] + ) + return _advance_with_shared_need_context( + absolute_minute, + ordered_guest_ids, + default_context + ) + for guest_id: StringName in ordered_guest_ids: + var context := default_context + var raw_context: Variant = null + var has_guest_context := false + if context_by_guest.has(guest_id): + has_guest_context = true + raw_context = context_by_guest[guest_id] + else: + var string_guest_id := String(guest_id) + if context_by_guest.has(string_guest_id): + has_guest_context = true + raw_context = context_by_guest[string_guest_id] + if raw_context is Dictionary: + context = raw_context + elif has_guest_context: + # Preserve the original contract: a malformed per-guest override + # suppresses the default context rather than inheriting it. + context = {} + var context_error := _need_context_error(context) + if not context_error.is_empty(): + return _error_result("Invalid Need context for %s: %s" % [guest_id, context_error]) + resolved_contexts[guest_id] = context.duplicate(true) + + ## All contexts are preflighted before mutating any guest, so a malformed + ## batched update cannot partially advance the population or the minute. + var reports: Array[Dictionary] = [] + for guest_id: StringName in ordered_guest_ids: + var context: Dictionary = resolved_contexts[guest_id] + var guest: GuestRuntime = _guests[guest_id] + var definition: GuestDefinition = _definitions.get(guest.guest_type_id) as GuestDefinition + if definition == null: + reports.append(_error_result("Guest definition is missing.")) + else: + reports.append(_update_needs_runtime(guest, definition, context)) + current_minute = absolute_minute + last_error = "" + return { + &"ok": true, + &"minute": absolute_minute, + &"guest_reports": reports, + } + + +func _advance_with_shared_need_context( + absolute_minute: int, + ordered_guest_ids: Array[StringName], + context: Dictionary +) -> Dictionary: + var reports: Array[Dictionary] = [] + for guest_id: StringName in ordered_guest_ids: + var guest: GuestRuntime = _guests[guest_id] + var definition: GuestDefinition = _definitions.get( + guest.guest_type_id + ) as GuestDefinition + if definition == null: + reports.append(_error_result("Guest definition is missing.")) + else: + reports.append(_update_needs_runtime(guest, definition, context)) + current_minute = absolute_minute + last_error = "" + return { + &"ok": true, + &"minute": absolute_minute, + &"guest_reports": reports, + } + + +func update_needs(guest_id: StringName, context: Dictionary = {}) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + var definition := get_definition(guest.guest_type_id) + if definition == null: + return _error_result("Guest definition is missing.") + var context_error := _need_context_error(context) + if not context_error.is_empty(): + return _error_result("Invalid Need context: %s" % context_error) + last_error = "" + return _update_needs_runtime(guest, definition, context) + + +func _update_needs_runtime( + guest: GuestRuntime, + definition: GuestDefinition, + context: Dictionary +) -> Dictionary: + var before_hunger := int(guest.needs[&"hunger"]) + var before_sleep := int(guest.needs[&"sleep"]) + var before_hygiene := int(guest.needs[&"hygiene"]) + var before_medical := int(guest.needs[&"medical"]) + var before_leisure := int(guest.needs[&"leisure"]) + var before_intimacy := int(guest.needs[&"intimacy"]) + var delta_hunger := int(definition.need_rates[&"hunger"]) + var delta_sleep := int(definition.need_rates[&"sleep"]) + var delta_hygiene := int(definition.need_rates[&"hygiene"]) + var delta_medical := int(definition.need_rates[&"medical"]) + var delta_leisure := int(definition.need_rates[&"leisure"]) + var delta_intimacy := int(definition.need_rates[&"intimacy"]) + + ## State effects are accumulated first; every Need is clamped exactly once. + if guest.state == GuestRuntimeType.State.MOVING \ + or guest.state == GuestRuntimeType.State.REPLANNING: + delta_hunger += 2 + delta_sleep += 2 + elif guest.state == GuestRuntimeType.State.QUEUING: + delta_hunger += 1 + + if context.is_empty(): + if guest.state != GuestRuntimeType.State.RECEIVING_SERVICE: + delta_leisure += 1 + else: + if bool(_value(context, &"work_like_activity", false)): + delta_sleep += 1 + if bool(_value( + context, + &"unstimulated", + guest.state != GuestRuntimeType.State.RECEIVING_SERVICE + )): + delta_leisure += 1 + delta_hygiene += int(_value(context, &"dirt", 0)) + delta_medical += int(_value(context, &"injury", 0)) + delta_medical += int(_value(context, &"illness", 0)) + if definition.adult_verified: + var intimacy_key := &"intimacy_night" \ + if bool(_value(context, &"is_night", false)) \ + else &"intimacy_day" + delta_intimacy += int(_value(context, intimacy_key, 0)) + var raw_effects: Variant = _value(context, &"need_effects", {}) + var normalized_effects: Dictionary = _normalized_partial_need_map(raw_effects) + delta_hunger += int(normalized_effects.get(&"hunger", 0)) + delta_sleep += int(normalized_effects.get(&"sleep", 0)) + delta_hygiene += int(normalized_effects.get(&"hygiene", 0)) + delta_medical += int(normalized_effects.get(&"medical", 0)) + delta_leisure += int(normalized_effects.get(&"leisure", 0)) + delta_intimacy += int(normalized_effects.get(&"intimacy", 0)) + + var after_hunger := clampi(before_hunger + delta_hunger, 0, 100) + var after_sleep := clampi(before_sleep + delta_sleep, 0, 100) + var after_hygiene := clampi(before_hygiene + delta_hygiene, 0, 100) + var after_medical := clampi(before_medical + delta_medical, 0, 100) + var after_leisure := clampi(before_leisure + delta_leisure, 0, 100) + var after_intimacy := clampi(before_intimacy + delta_intimacy, 0, 100) + guest.needs[&"hunger"] = after_hunger + guest.needs[&"sleep"] = after_sleep + guest.needs[&"hygiene"] = after_hygiene + guest.needs[&"medical"] = after_medical + guest.needs[&"leisure"] = after_leisure + guest.needs[&"intimacy"] = after_intimacy + return { + &"ok": true, + &"guest_id": String(guest.guest_id), + &"before": { + "hunger": before_hunger, + "hygiene": before_hygiene, + "intimacy": before_intimacy, + "leisure": before_leisure, + "medical": before_medical, + "sleep": before_sleep, + }, + &"deltas": { + "hunger": delta_hunger, + "hygiene": delta_hygiene, + "intimacy": delta_intimacy, + "leisure": delta_leisure, + "medical": delta_medical, + "sleep": delta_sleep, + }, + &"after": { + "hunger": after_hunger, + "hygiene": after_hygiene, + "intimacy": after_intimacy, + "leisure": after_leisure, + "medical": after_medical, + "sleep": after_sleep, + }, + } + + +## Returns canonical candidate copies in facility_id order. Supported canonical +## fields are documented by the filtering helpers below; aliases allow existing +## FacilityService projections to be adapted without a hard dependency. +func extract_candidates( + guest_id: StringName, + facilities: Array, + context: Dictionary = {} +) -> Array[Dictionary]: + var guest := get_guest(guest_id) + if guest == null: + _fail("Unknown guest: %s" % guest_id) + return [] + var definition := get_definition(guest.guest_type_id) + if definition == null: + _fail("Guest definition is missing.") + return [] + var ordered: Array[Dictionary] = [] + for raw_candidate: Variant in facilities: + if not raw_candidate is Dictionary: + continue + var candidate: Dictionary = raw_candidate.duplicate(true) + var facility_id := _facility_id(candidate) + if facility_id.is_empty(): + continue + candidate[&"facility_id"] = facility_id + ordered.append(candidate) + ordered.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: + return String(a[&"facility_id"]) < String(b[&"facility_id"]) + ) + + var accepted: Array[Dictionary] = [] + var rejected: Array[Dictionary] = [] + for candidate: Dictionary in ordered: + var reason := _candidate_rejection_reason(guest, definition, candidate, context) + if reason.is_empty(): + accepted.append(candidate) + else: + rejected.append({ + &"facility_id": String(candidate[&"facility_id"]), + &"reason": String(reason), + }) + _decision_rejections[guest_id] = rejected + last_error = "" + return accepted + + +func get_last_candidate_rejections(guest_id: StringName) -> Array[Dictionary]: + return (_decision_rejections.get(guest_id, []) as Array).duplicate(true) + + +func evaluate_candidate(guest_id: StringName, candidate: Dictionary) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + var definition := get_definition(guest.guest_type_id) + if definition == null: + return _error_result("Guest definition is missing.") + if not _candidate_need_maps_are_valid(candidate): + return _error_result("Candidate Need map is invalid.") + var need_relief := _need_relief(guest, candidate) + var preference := int(_value( + candidate, + &"preference", + definition.preference_for(_value(candidate, &"facility_tags", _value(candidate, &"tags", []))) + )) + var expected_quality := int(_value( + candidate, + &"expected_quality", + _value(candidate, &"quality", 0) + )) + var novelty := int(_value( + candidate, + &"novelty", + 0 if guest.visited_facility_ids.has(_facility_id(candidate)) else 10 + )) + var group_fit := int(_value(candidate, &"group_fit", 0)) + var price_burden := int(_value(candidate, &"price_burden", _price_burden(guest, candidate))) + var travel_minutes := maxi(int(_value(candidate, &"travel_minutes", 0)), 0) + var wait_minutes := maxi(int(_value( + candidate, + &"wait_minutes", + _value(candidate, &"expected_wait", 0) + )), 0) + var risk := maxi(int(_value(candidate, &"risk", _value(candidate, &"risk_level", 0))), 0) + var default_risk_sensitivity := (100 - definition.risk_tolerance + 24) / 25 + var risk_sensitivity := maxi(int(_value( + candidate, + &"risk_sensitivity", + default_risk_sensitivity + )), 0) + var repetition_penalty := int(_value( + candidate, + &"repetition_penalty", + 10 if guest.visited_facility_ids.has(_facility_id(candidate)) else 0 + )) + var urgency := int(_value(candidate, &"urgency", _candidate_urgency(guest, candidate))) + var utility := ( + need_relief * 8 + + preference * 5 + + expected_quality * 3 + + novelty * 2 + + group_fit * 2 + - price_burden * 5 + - travel_minutes * 2 + - wait_minutes * 3 + - risk * risk_sensitivity + - repetition_penalty + ) + var result := candidate.duplicate(true) + result[&"facility_id"] = _facility_id(candidate) + result[&"utility"] = utility + result[&"urgency"] = urgency + result[&"travel_minutes"] = travel_minutes + result[&"utility_terms"] = { + &"need_relief": need_relief, + &"preference": preference, + &"expected_quality": expected_quality, + &"novelty": novelty, + &"group_fit": group_fit, + &"price_burden": price_burden, + &"travel_minutes": travel_minutes, + &"wait_minutes": wait_minutes, + &"risk": risk, + &"risk_sensitivity": risk_sensitivity, + &"repetition_penalty": repetition_penalty, + } + return result + + +func rank_candidates(guest_id: StringName, candidates: Array) -> Array[Dictionary]: + var ranked: Array[Dictionary] = [] + for raw_candidate: Variant in candidates: + if raw_candidate is Dictionary: + var evaluated := evaluate_candidate(guest_id, raw_candidate) + if bool(evaluated.get(&"ok", true)): + ranked.append(evaluated) + ranked.sort_custom(_candidate_precedes) + return ranked + + +func select_best_candidate( + guest_id: StringName, + candidates: Array, + exit_threshold: int = DEFAULT_EXIT_THRESHOLD +) -> Dictionary: + var ranked := rank_candidates(guest_id, candidates) + if ranked.is_empty() or int(ranked[0][&"utility"]) < exit_threshold: + return {} + return ranked[0].duplicate(true) + + +func decide( + guest_id: StringName, + facilities: Array, + absolute_minute: int, + context: Dictionary = {} +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + var filtered := extract_candidates(guest_id, facilities, context) + var ranked := rank_candidates(guest_id, filtered) + var threshold := int(_value(context, &"exit_threshold", DEFAULT_EXIT_THRESHOLD)) + var reserve_callback: Callable = _value(context, &"reserve_callback", Callable()) + for candidate: Dictionary in ranked: + if int(candidate[&"utility"]) < threshold: + break + if reserve_callback.is_valid(): + var reservation_result: Variant = reserve_callback.call( + guest_id, + candidate[&"facility_id"], + candidate.duplicate(true) + ) + if not _callback_succeeded(reservation_result): + continue + if not _transition_is_available(guest, GuestRuntimeType.State.MOVING, absolute_minute): + return _error_result("Guest cannot enter MOVING in this tick.") + guest.target_facility_id = candidate[&"facility_id"] + guest.path_handle = _copy(_value(candidate, &"path_handle", null)) + _apply_transition(guest, GuestRuntimeType.State.MOVING, absolute_minute, &"FACILITY_SELECTED") + last_error = "" + return { + &"ok": true, + &"action": &"MOVE_TO_FACILITY", + &"candidate": candidate.duplicate(true), + } + if not _transition_is_available(guest, GuestRuntimeType.State.LEAVING, absolute_minute): + return _error_result("Guest cannot enter LEAVING in this tick.") + guest.target_facility_id = &"" + guest.path_handle = null + guest.exit_reason = StringName(_value(context, &"exit_reason", &"NO_SUITABLE_FACILITY")) + _apply_transition(guest, GuestRuntimeType.State.LEAVING, absolute_minute, guest.exit_reason) + last_error = "" + return {&"ok": true, &"action": &"LEAVE", &"candidate": {}} + + +func transition_guest( + guest_id: StringName, + new_state: int, + absolute_minute: int, + reason: StringName = &"" +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + if new_state < GuestRuntimeType.State.SPAWNING or new_state > GuestRuntimeType.State.INCAPACITATED: + return _error_result("Unknown guest state.") + if guest.state == new_state: + return {&"ok": true, &"changed": false, &"state": new_state} + if not _is_allowed_transition(guest.state, new_state): + return _error_result( + "Invalid guest transition: %s -> %s" % [ + GuestRuntimeType.State.keys()[guest.state], + GuestRuntimeType.State.keys()[new_state], + ] + ) + if not _transition_is_available(guest, new_state, absolute_minute): + _force_loop_guard(guest, absolute_minute) + return { + &"ok": false, + &"changed": true, + &"loop_guard": true, + &"state": guest.state, + } + _apply_transition(guest, new_state, absolute_minute, reason) + last_error = "" + return {&"ok": true, &"changed": true, &"state": new_state} + + +func enter_queue( + guest_id: StringName, + facility_id: StringName, + request_id: StringName, + absolute_minute: int, + initial_patience: int = -1, + enqueue_callback: Callable = Callable() +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null or facility_id.is_empty() or request_id.is_empty(): + return _error_result("Guest, facility and request are required for queue entry.") + if not _transition_is_available(guest, GuestRuntimeType.State.QUEUING, absolute_minute): + return _error_result("Guest cannot enter QUEUING in this tick.") + if enqueue_callback.is_valid(): + var external_result: Variant = enqueue_callback.call(guest_id, facility_id, request_id) + if not _callback_succeeded(external_result): + return _error_result("Facility queue rejected the guest.") + var definition := get_definition(guest.guest_type_id) + var resolved_patience := initial_patience + if resolved_patience < 0: + resolved_patience = definition.base_patience if definition != null else 0 + guest.target_facility_id = facility_id + guest.queue_request_id = request_id + guest.patience_remaining = maxi(resolved_patience, 0) + _apply_transition(guest, GuestRuntimeType.State.QUEUING, absolute_minute, &"QUEUE_ENTERED") + last_error = "" + return {&"ok": true, &"patience_remaining": guest.patience_remaining} + + +func tick_queue_patience( + guest_id: StringName, + absolute_minute: int, + recovery: int = 0, + leave_queue_callback: Callable = Callable() +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null or guest.state != GuestRuntimeType.State.QUEUING: + return _error_result("Guest is not in a queue.") + var next_patience := maxi(guest.patience_remaining - 1 + recovery, 0) + if next_patience > 0: + guest.patience_remaining = next_patience + last_error = "" + return {&"ok": true, &"abandoned": false, &"patience_remaining": next_patience} + ## Preflight every internal mutation before invoking the external queue owner. + if not _transition_is_available(guest, GuestRuntimeType.State.ABANDONING, absolute_minute): + return _error_result("Guest cannot atomically abandon the queue in this tick.") + var facility_id := guest.target_facility_id + var request_id := guest.queue_request_id + if leave_queue_callback.is_valid(): + var external_result: Variant = leave_queue_callback.call( + guest_id, + facility_id, + request_id + ) + if not _callback_succeeded(external_result): + return _error_result("Facility queue removal failed; guest state was not changed.") + guest.patience_remaining = 0 + guest.target_facility_id = &"" + guest.queue_request_id = &"" + guest.path_handle = null + _apply_transition(guest, GuestRuntimeType.State.ABANDONING, absolute_minute, &"PATIENCE_EXHAUSTED") + guest_queue_abandoned.emit(guest_id, facility_id, request_id) + last_error = "" + return {&"ok": true, &"abandoned": true, &"patience_remaining": 0} + + +func begin_service( + guest_id: StringName, + facility_id: StringName, + service_id: StringName, + absolute_minute: int +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null or facility_id.is_empty() or service_id.is_empty(): + return _error_result("Guest, facility and service are required.") + if not _transition_is_available(guest, GuestRuntimeType.State.RECEIVING_SERVICE, absolute_minute): + return _error_result("Guest cannot begin service in this tick.") + guest.target_facility_id = facility_id + guest.current_service_id = service_id + guest.queue_request_id = &"" + _apply_transition(guest, GuestRuntimeType.State.RECEIVING_SERVICE, absolute_minute, &"SERVICE_STARTED") + last_error = "" + return {&"ok": true, &"service_id": service_id} + + +func interrupt_service( + guest_id: StringName, + service_id: StringName, + absolute_minute: int, + interrupt_callback: Callable = Callable() +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null or service_id.is_empty(): + return _error_result("Guest and service are required.") + if guest.interrupted_service_ids.has(service_id): + return {&"ok": true, &"replayed": true, &"service_id": service_id} + if guest.current_service_id != service_id: + return _error_result("Guest is not receiving the specified service.") + if not _transition_is_available(guest, GuestRuntimeType.State.INTERRUPTED, absolute_minute): + return _error_result("Guest cannot enter INTERRUPTED in this tick.") + if interrupt_callback.is_valid(): + var external_result: Variant = interrupt_callback.call(guest_id, service_id) + if not _callback_succeeded(external_result): + return _error_result("Service interruption callback failed.") + guest.interrupted_service_ids.append(service_id) + guest.interrupted_service_ids.sort_custom( + func(a: StringName, b: StringName) -> bool: return String(a) < String(b) + ) + guest.current_service_id = &"" + _apply_transition(guest, GuestRuntimeType.State.INTERRUPTED, absolute_minute, &"FACILITY_STOPPED") + last_error = "" + return {&"ok": true, &"replayed": false, &"service_id": service_id} + + +func complete_service( + guest_id: StringName, + service_id: StringName, + absolute_minute: int, + satisfaction_sample: int, + need_effects: Dictionary = {} +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null or guest.current_service_id != service_id: + return _error_result("Guest is not receiving the specified service.") + var normalized_effects: Variant = _normalized_partial_need_map(need_effects) + if not normalized_effects is Dictionary: + return _error_result("Service Need effects must use canonical integer Need IDs.") + if not _transition_is_available(guest, GuestRuntimeType.State.DECIDING, absolute_minute): + return _error_result("Guest cannot complete service in this tick.") + var sample := clampi(satisfaction_sample, 0, 100) + guest.satisfaction_total += sample + guest.satisfaction_samples += 1 + for need_id: StringName in NEED_IDS: + var effect := int(normalized_effects.get(need_id, 0)) + guest.needs[need_id] = clampi(int(guest.needs[need_id]) + effect, 0, 100) + if not guest.target_facility_id.is_empty() \ + and not guest.visited_facility_ids.has(guest.target_facility_id): + guest.visited_facility_ids.append(guest.target_facility_id) + guest.visited_facility_ids.sort_custom( + func(a: StringName, b: StringName) -> bool: return String(a) < String(b) + ) + guest.current_service_id = &"" + guest.queue_request_id = &"" + guest.target_facility_id = &"" + _apply_transition(guest, GuestRuntimeType.State.DECIDING, absolute_minute, &"SERVICE_COMPLETED") + last_error = "" + return {&"ok": true, &"satisfaction_sample": sample} + + +func record_path_failure( + guest_id: StringName, + absolute_minute: int, + recovery_targets: Array = [], + reachability_callback: Callable = Callable() +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + guest.path_failure_count += 1 + if guest.path_failure_count < PATH_FAILURE_LIMIT: + if guest.state != GuestRuntimeType.State.REPLANNING: + var transition := transition_guest( + guest_id, + GuestRuntimeType.State.REPLANNING, + absolute_minute, + &"PATH_INVALID" + ) + if not bool(transition.get(&"ok", false)): + return transition + last_error = "" + return { + &"ok": true, + &"recovery_started": false, + &"failure_count": guest.path_failure_count, + } + + var selected := _select_recovery_target(guest, recovery_targets, reachability_callback) + if selected.is_empty(): + ## A virtual emergency egress is preferable to leaving an entity stranded. + selected = { + &"kind": &"emergency_despawn", + &"cell": guest.current_cell, + &"path_handle": {&"virtual_evacuation": true}, + } + if not _transition_is_available(guest, GuestRuntimeType.State.LEAVING, absolute_minute): + return _error_result("Guest cannot enter safe recovery in this tick.") + guest.recovery_kind = StringName(_value(selected, &"kind", &"emergency_exit")) + guest.recovery_target_cell = _vector3i(_value(selected, &"cell", guest.current_cell)) + guest.path_handle = _copy(_value(selected, &"path_handle", { + &"recovery": true, + &"target_cell": guest.recovery_target_cell, + })) + guest.target_facility_id = &"" + guest.queue_request_id = &"" + guest.current_service_id = &"" + guest.exit_reason = &"PATH_RECOVERY" + _apply_transition(guest, GuestRuntimeType.State.LEAVING, absolute_minute, &"PATH_FAILURE_LIMIT") + _diagnostics.append({ + &"code": &"GUEST_PATH_RECOVERY", + &"guest_id": String(guest_id), + &"minute": absolute_minute, + &"failure_count": guest.path_failure_count, + &"recovery_kind": String(guest.recovery_kind), + &"target_cell": _cell_to_array(guest.recovery_target_cell), + }) + guest_recovery_started.emit(guest_id, guest.recovery_kind, guest.recovery_target_cell) + last_error = "" + return { + &"ok": true, + &"recovery_started": true, + &"failure_count": guest.path_failure_count, + &"recovery_kind": guest.recovery_kind, + &"target_cell": guest.recovery_target_cell, + } + + +func record_path_success(guest_id: StringName, cell: Vector3i, absolute_minute: int) -> bool: + var guest := get_guest(guest_id) + if guest == null or absolute_minute < 0: + return _fail("A valid guest and minute are required.") + guest.current_cell = cell + guest.last_progress_cell = cell + guest.last_progress_at = absolute_minute + guest.path_failure_count = 0 + guest.recovery_kind = &"" + last_error = "" + return true + + +func note_movement( + guest_id: StringName, + cell: Vector3i, + absolute_minute: int, + recovery_targets: Array = [], + reachability_callback: Callable = Callable() +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + if cell != guest.last_progress_cell: + record_path_success(guest_id, cell, absolute_minute) + return {&"ok": true, &"progressed": true, &"replanned": false} + guest.current_cell = cell + if absolute_minute - guest.last_progress_at < STUCK_REPLAN_MINUTES: + return {&"ok": true, &"progressed": false, &"replanned": false} + guest.last_progress_at = absolute_minute + var result := record_path_failure( + guest_id, + absolute_minute, + recovery_targets, + reachability_callback + ) + result[&"progressed"] = false + result[&"replanned"] = true + return result + + +func begin_leaving( + guest_id: StringName, + absolute_minute: int, + reason: StringName +) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + if not _transition_is_available(guest, GuestRuntimeType.State.LEAVING, absolute_minute): + return _error_result("Guest cannot begin leaving in this tick.") + guest.target_facility_id = &"" + guest.queue_request_id = &"" + guest.current_service_id = &"" + guest.exit_reason = reason + _apply_transition(guest, GuestRuntimeType.State.LEAVING, absolute_minute, reason) + last_error = "" + return {&"ok": true} + + +func despawn_guest(guest_id: StringName, absolute_minute: int) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return _error_result("Unknown guest: %s" % guest_id) + if guest.state != GuestRuntimeType.State.LEAVING \ + and guest.state != GuestRuntimeType.State.DESPAWNING: + return _error_result("Only a leaving guest can despawn.") + if guest.state != GuestRuntimeType.State.DESPAWNING: + if not _transition_is_available(guest, GuestRuntimeType.State.DESPAWNING, absolute_minute): + return _error_result("Guest cannot enter DESPAWNING in this tick.") + _apply_transition(guest, GuestRuntimeType.State.DESPAWNING, absolute_minute, &"EXIT_REACHED") + var record := { + &"guest_id": String(guest.guest_id), + &"guest_type_id": String(guest.guest_type_id), + &"debt_gold": guest.debt_gold, + &"unresolved_incident_ids": _names_to_strings(guest.unresolved_incident_ids), + &"outstanding_reservation_ids": _names_to_strings(guest.outstanding_reservation_ids), + } + _persistent_records[guest_id] = record + _guests.erase(guest_id) + _pending_spawn_notifications.erase(guest_id) + _guest_order_dirty = true + _decision_rejections.erase(guest_id) + guest_despawned.emit(guest_id, record.duplicate(true)) + last_error = "" + return {&"ok": true, &"persistent_record": record.duplicate(true)} + + +func create_snapshot() -> Dictionary: + var saved_definitions: Array[Dictionary] = [] + for guest_type_id: StringName in _sorted_ids(_definitions.keys()): + saved_definitions.append((_definitions[guest_type_id] as GuestDefinition).create_snapshot()) + var saved_guests: Array[Dictionary] = [] + for guest_id: StringName in _ordered_guest_ids(): + saved_guests.append((_guests[guest_id] as GuestRuntime).create_snapshot()) + var saved_records: Array[Dictionary] = [] + for guest_id: StringName in _sorted_ids(_persistent_records.keys()): + saved_records.append((_persistent_records[guest_id] as Dictionary).duplicate(true)) + return { + &"version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"definitions": saved_definitions, + &"guests": saved_guests, + &"persistent_records": saved_records, + &"diagnostics": _diagnostics.duplicate(true), + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + var raw_version: Variant = snapshot.get(&"version", snapshot.get("version", null)) + if not raw_version is int or int(raw_version) not in [1, SNAPSHOT_VERSION]: + return _fail("Unsupported GuestService snapshot version.") + var prepared: Variant = snapshot.duplicate(true) + if int(raw_version) == 1: + prepared = _migrate_v1_snapshot(snapshot) + if not prepared is Dictionary: + return _fail("GuestService v1 Need schema is invalid or mixed.") + var candidate: Dictionary = prepared + if not candidate.get(&"definitions", null) is Array \ + or not candidate.get(&"guests", null) is Array \ + or not candidate.get(&"persistent_records", null) is Array \ + or not candidate.get(&"diagnostics", null) is Array: + return _fail("GuestService snapshot arrays are missing.") + var next_minute := int(candidate.get(&"current_minute", -2)) + if next_minute < -1: + return _fail("GuestService snapshot minute is invalid.") + + ## Parse into temporary containers. Live state is replaced only after every + ## cross-reference and invariant has been validated. + var next_definitions: Dictionary = {} + for raw_definition: Variant in candidate[&"definitions"]: + if not raw_definition is Dictionary: + return _fail("Guest definition snapshot has an invalid type.") + var definition := GuestDefinitionType.from_snapshot(raw_definition) + if definition == null or next_definitions.has(definition.guest_type_id): + return _fail("Guest definition snapshot is invalid or duplicated.") + next_definitions[definition.guest_type_id] = definition + + var next_guests: Dictionary = {} + for raw_guest: Variant in candidate[&"guests"]: + if not raw_guest is Dictionary: + return _fail("Guest runtime snapshot has an invalid type.") + var guest := GuestRuntimeType.from_snapshot(raw_guest) + if guest == null or next_guests.has(guest.guest_id): + return _fail("Guest runtime snapshot is invalid or duplicated.") + if not next_definitions.has(guest.guest_type_id): + return _fail("Guest runtime snapshot references an unknown definition.") + next_guests[guest.guest_id] = guest + + var next_records: Dictionary = {} + for raw_record: Variant in candidate[&"persistent_records"]: + if not raw_record is Dictionary or not _persistent_record_is_valid(raw_record): + return _fail("Persistent guest record is invalid.") + var record_id := StringName(raw_record.get(&"guest_id", "")) + if next_records.has(record_id) or next_guests.has(record_id): + return _fail("Persistent guest id is duplicated or still active.") + next_records[record_id] = raw_record.duplicate(true) + + var next_diagnostics: Array[Dictionary] = [] + for raw_diagnostic: Variant in candidate[&"diagnostics"]: + if not raw_diagnostic is Dictionary: + return _fail("Guest diagnostic snapshot has an invalid type.") + next_diagnostics.append(raw_diagnostic.duplicate(true)) + + _definitions = next_definitions + _guests = next_guests + _guest_order_dirty = true + _persistent_records = next_records + _decision_rejections = {} + _diagnostics = next_diagnostics + _pending_spawn_notifications.clear() + _external_mutation_blocked = false + current_minute = next_minute + last_error = "" + return true + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + for guest_type_id: StringName in _sorted_ids(_definitions.keys()): + var definition: GuestDefinition = _definitions[guest_type_id] + if not definition.is_valid() or definition.guest_type_id != guest_type_id: + errors.append("Invalid definition: %s" % guest_type_id) + for guest_id: StringName in _sorted_ids(_guests.keys()): + var guest: GuestRuntime = _guests[guest_id] + if not guest.is_valid() or guest.guest_id != guest_id: + errors.append("Invalid runtime: %s" % guest_id) + elif not _definitions.has(guest.guest_type_id): + errors.append("Unknown guest type for: %s" % guest_id) + if _persistent_records.has(guest_id): + errors.append("Guest is both active and persisted: %s" % guest_id) + for guest_id: StringName in _sorted_ids(_persistent_records.keys()): + if not _persistent_record_is_valid(_persistent_records[guest_id]): + errors.append("Invalid persistent record: %s" % guest_id) + return {&"valid": errors.is_empty(), &"errors": errors} + + +func _migrate_v1_snapshot(snapshot: Dictionary) -> Variant: + var definitions: Variant = snapshot.get(&"definitions", snapshot.get("definitions", null)) + var guests: Variant = snapshot.get(&"guests", snapshot.get("guests", null)) + if not definitions is Array or not guests is Array: + return null + var migrated := snapshot.duplicate(true) + for index: int in range(definitions.size()): + var raw_definition: Variant = definitions[index] + if not raw_definition is Dictionary: + return null + var translated_rates: Variant = _translate_v1_need_map( + _value(raw_definition, &"need_rates", null) + ) + if not translated_rates is Dictionary: + return null + migrated[&"definitions"][index][&"need_rates"] = translated_rates + for index: int in range(guests.size()): + var raw_guest: Variant = guests[index] + if not raw_guest is Dictionary: + return null + var translated_needs: Variant = _translate_v1_need_map( + _value(raw_guest, &"needs", null) + ) + if not translated_needs is Dictionary: + return null + migrated[&"guests"][index][&"needs"] = translated_needs + migrated[&"version"] = SNAPSHOT_VERSION + return migrated + + +static func _translate_v1_need_map(source: Variant) -> Variant: + if not source is Dictionary or source.size() != LEGACY_NEED_IDS.size(): + return null + var legacy: Dictionary = {} + for raw_key: Variant in source: + if not raw_key is String and not raw_key is StringName: + return null + var legacy_id := StringName(raw_key) + if not LEGACY_NEED_IDS.has(legacy_id) or legacy.has(legacy_id): + return null + var raw_value: Variant = source[raw_key] + if not raw_value is int: + return null + legacy[legacy_id] = raw_value + if legacy.size() != LEGACY_NEED_IDS.size(): + return null + return { + &"hunger": legacy[&"hunger"], + &"sleep": legacy[&"rest"], + &"hygiene": 0, + &"medical": legacy[&"safety"], + &"leisure": legacy[&"fun"], + &"intimacy": legacy[&"social"], + } + + +func _need_context_error(context: Dictionary) -> String: + for field_id: StringName in [ + &"dirt", + &"injury", + &"illness", + &"intimacy_day", + &"intimacy_night", + ]: + if not _has_value(context, field_id): + continue + var raw_value: Variant = _value(context, field_id, null) + if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100: + return "%s must be an integer from 0 through 100." % field_id + for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated"]: + if _has_value(context, field_id) and not _value(context, field_id, null) is bool: + return "%s must be boolean." % field_id + if _has_value(context, &"need_effects"): + var effects: Variant = _normalized_partial_need_map( + _value(context, &"need_effects", null) + ) + if not effects is Dictionary: + return "need_effects must contain only canonical integer Need IDs." + return "" + + +static func _normalized_partial_need_map(source: Variant) -> Variant: + if not source is Dictionary or source.size() > NEED_IDS.size(): + return null + var result: Dictionary = {} + for raw_key: Variant in source: + if not raw_key is String and not raw_key is StringName: + return null + var need_id := StringName(raw_key) + if not NEED_IDS.has(need_id) or result.has(need_id): + return null + var raw_value: Variant = source[raw_key] + if not raw_value is int: + return null + result[need_id] = raw_value + return result + + +static func _candidate_need_maps_are_valid(candidate: Dictionary) -> bool: + for field_id: StringName in [&"need_relief", &"need_effects"]: + if not _has_value(candidate, field_id): + continue + var raw_value: Variant = _value(candidate, field_id, null) + if field_id == &"need_relief" and raw_value is int: + continue + if not _normalized_partial_need_map(raw_value) is Dictionary: + return false + return true + + +func _candidate_rejection_reason( + guest: GuestRuntime, + definition: GuestDefinition, + candidate: Dictionary, + context: Dictionary +) -> StringName: + if not _candidate_need_maps_are_valid(candidate): + return &"INVALID_NEED_MAP" + var adult_only := bool(_value( + candidate, + &"adult_only", + _value(candidate, &"requires_adult_verified", false) + )) + if adult_only: + if not definition.adult_verified: + return &"ADULT_NOT_VERIFIED" + if not bool(_value(context, &"adult_content_enabled", true)): + return &"CONTENT_FILTERED" + var required_tags := _name_array(_value(candidate, &"required_guest_tags", [])) + for tag: StringName in required_tags: + if not definition.tags.has(tag): + return &"MISSING_PERMISSION" + var forbidden_tags := _name_array(_value(candidate, &"forbidden_guest_tags", [])) + for tag: StringName in forbidden_tags: + if definition.tags.has(tag): + return &"FORBIDDEN_GUEST_TAG" + var allowed_types := _name_array(_value(candidate, &"allowed_guest_types", [])) + if not allowed_types.is_empty() and not allowed_types.has(guest.guest_type_id): + return &"GUEST_TYPE_NOT_ALLOWED" + if not bool(_value(candidate, &"is_open", _value(candidate, &"open", true))) \ + and not bool(_value(candidate, &"open_at_arrival", false)): + return &"FACILITY_CLOSED" + if not _candidate_is_reachable(guest, candidate, context): + return &"UNREACHABLE" + var travel_minutes := maxi(int(_value(candidate, &"travel_minutes", 0)), 0) + var decision_minute := int(_value(context, &"absolute_minute", current_minute)) + if decision_minute >= 0 and decision_minute + travel_minutes > guest.planned_exit_at: + return &"ETA_AFTER_EXIT" + if not _candidate_is_affordable(guest, definition, candidate): + return &"CANNOT_PAY" + var wait_minutes := maxi(int(_value( + candidate, + &"wait_minutes", + _value(candidate, &"expected_wait", 0) + )), 0) + var hard_limit := maxi(int(_value( + candidate, + &"wait_hard_limit", + _value(context, &"wait_hard_limit", DEFAULT_WAIT_HARD_LIMIT) + )), 0) + if wait_minutes >= hard_limit: + return &"WAIT_HARD_LIMIT" + var risk_level := clampi(int(_value(candidate, &"risk_level", _value(candidate, &"risk", 0))), 0, 100) + if risk_level > definition.risk_tolerance: + return &"RISK_REFUSED" + var illegal_level := clampi(int(_value(candidate, &"illegal_level", 0)), 0, 100) + if illegal_level > 100 - definition.legal_sensitivity \ + and not bool(_value(candidate, &"legal_permission_override", false)): + return &"ILLEGALITY_REFUSED" + var quality := clampi(int(_value(candidate, &"expected_quality", _value(candidate, &"quality", 0))), 0, 100) + var minimum_quality := clampi(int(_value(candidate, &"minimum_quality", definition.minimum_quality)), 0, 100) + if quality < minimum_quality: + return &"QUALITY_TOO_LOW" + return &"" + + +func _candidate_is_reachable( + guest: GuestRuntime, + candidate: Dictionary, + context: Dictionary +) -> bool: + var callback: Callable = _value(context, &"reachability_callback", Callable()) + if callback.is_valid(): + return _callback_succeeded(callback.call(guest, candidate.duplicate(true), context)) + return bool(_value(candidate, &"reachable", true)) + + +func _candidate_is_affordable( + guest: GuestRuntime, + definition: GuestDefinition, + candidate: Dictionary +) -> bool: + var price := _candidate_price(candidate) + var cash_available := guest.wallet_gold + var respect_reserve := bool(_value( + candidate, + &"respect_survival_reserve", + _candidate_is_discretionary(candidate) + )) + if respect_reserve: + cash_available = maxi(cash_available - _survival_reserve(guest, definition), 0) + if price <= cash_available: + return true + if not bool(_value(candidate, &"credit_allowed", _value(candidate, &"allow_credit", false))): + return false + var remaining_credit := maxi(guest.credit_limit_gold - guest.debt_gold, 0) + return price - cash_available <= remaining_credit + + +func _candidate_is_discretionary(candidate: Dictionary) -> bool: + if bool(_value(candidate, &"discretionary", false)): + return true + var category := StringName(_value(candidate, &"service_category", &"")) + if category in [&"leisure", &"entertainment", &"gambling", &"intimacy", &"tourism"]: + return true + var facility_tags := _name_array(_value( + candidate, + &"facility_tags", + _value(candidate, &"tags", []) + )) + for tag: StringName in [&"leisure", &"entertainment", &"gambling", &"adult", &"intimacy"]: + if facility_tags.has(tag): + return true + return false + + +func _survival_reserve(guest: GuestRuntime, definition: GuestDefinition) -> int: + if guest.max_addiction() >= 75: + return 0 + if definition.tags.has(&"spendthrift"): + return guest.wallet_gold / 10 + if definition.tags.has(&"frugal"): + return guest.wallet_gold / 2 + return guest.wallet_gold * 3 / 10 + + +func _candidate_price(candidate: Dictionary) -> int: + return maxi(int(_value( + candidate, + &"estimated_total_gold", + _value(candidate, &"price_gold", _value(candidate, &"quoted_price", 0)) + )), 0) + + +func _price_burden(guest: GuestRuntime, candidate: Dictionary) -> int: + var price := _candidate_price(candidate) + var purchasing_power := maxi(guest.wallet_gold + maxi(guest.credit_limit_gold - guest.debt_gold, 0), 1) + return clampi((price * 100 + purchasing_power - 1) / purchasing_power, 0, 100) + + +func _need_relief(guest: GuestRuntime, candidate: Dictionary) -> int: + var raw_relief: Variant = _value(candidate, &"need_relief", 0) + if raw_relief is int: + return maxi(int(raw_relief), 0) + if not raw_relief is Dictionary: + return 0 + var normalized: Dictionary = _normalized_partial_need_map(raw_relief) + var best := 0 + for need_id: StringName in NEED_IDS: + var relief := maxi(int(normalized.get(need_id, 0)), 0) + best = maxi(best, relief * int(guest.needs[need_id]) / 100) + return best + + +func _candidate_urgency(guest: GuestRuntime, candidate: Dictionary) -> int: + var raw_relief: Variant = _value(candidate, &"need_relief", {}) + if not raw_relief is Dictionary: + return 0 + var normalized: Dictionary = _normalized_partial_need_map(raw_relief) + var result := 0 + for need_id: StringName in NEED_IDS: + if int(normalized.get(need_id, 0)) > 0: + result = maxi(result, int(guest.needs[need_id])) + return result + + +func _candidate_precedes(a: Dictionary, b: Dictionary) -> bool: + var a_utility := int(a[&"utility"]) + var b_utility := int(b[&"utility"]) + if a_utility != b_utility: + return a_utility > b_utility + var a_urgency := int(a[&"urgency"]) + var b_urgency := int(b[&"urgency"]) + if a_urgency != b_urgency: + return a_urgency > b_urgency + var a_travel := int(a[&"travel_minutes"]) + var b_travel := int(b[&"travel_minutes"]) + if a_travel != b_travel: + return a_travel < b_travel + return String(a[&"facility_id"]) < String(b[&"facility_id"]) + + +func _select_recovery_target( + guest: GuestRuntime, + targets: Array, + reachability_callback: Callable +) -> Dictionary: + var ordered: Array[Dictionary] = [] + for raw_target: Variant in targets: + if raw_target is Dictionary: + var target: Dictionary = raw_target.duplicate(true) + target[&"kind"] = StringName(_value(target, &"kind", &"emergency_exit")) + target[&"cell"] = _vector3i(_value(target, &"cell", guest.current_cell)) + ordered.append(target) + ordered.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: + var a_priority := _recovery_priority(StringName(a[&"kind"])) + var b_priority := _recovery_priority(StringName(b[&"kind"])) + if a_priority != b_priority: + return a_priority < b_priority + return _cell_precedes(a[&"cell"], b[&"cell"]) + ) + for target: Dictionary in ordered: + var reachable := bool(_value(target, &"reachable", true)) + if reachability_callback.is_valid(): + reachable = _callback_succeeded(reachability_callback.call( + guest.current_cell, + target[&"cell"], + guest.guest_id, + target.duplicate(true) + )) + if reachable: + return target + return {} + + +func _recovery_priority(kind: StringName) -> int: + match kind: + &"guidance", &"guidance_point": + return 0 + &"entrance", &"main_entrance": + return 1 + &"emergency_exit", &"emergency_egress": + return 2 + _: + return 3 + + +func _transition_is_available(guest: GuestRuntime, new_state: int, minute: int) -> bool: + if minute < 0 or not _is_allowed_transition(guest.state, new_state): + return false + var count := guest.transitions_this_tick if guest.transition_tick == minute else 0 + return count < MAX_TRANSITIONS_PER_TICK + + +func _apply_transition( + guest: GuestRuntime, + new_state: int, + absolute_minute: int, + reason: StringName +) -> void: + var old_state := guest.state + if guest.transition_tick != absolute_minute: + guest.transition_tick = absolute_minute + guest.transitions_this_tick = 0 + guest.transitions_this_tick += 1 + guest.state = new_state as GuestRuntime.State + guest.state_entered_at = absolute_minute + guest_state_changed.emit(guest.guest_id, old_state, new_state, reason) + + +func _force_loop_guard(guest: GuestRuntime, absolute_minute: int) -> void: + var old_state := guest.state + guest.state = GuestRuntimeType.State.DECIDING + guest.state_entered_at = absolute_minute + guest.transition_tick = absolute_minute + guest.transitions_this_tick = MAX_TRANSITIONS_PER_TICK + guest.target_facility_id = &"" + guest.queue_request_id = &"" + guest.current_service_id = &"" + guest.path_handle = null + _diagnostics.append({ + &"code": &"AI_LOOP_GUARD", + &"guest_id": String(guest.guest_id), + &"minute": absolute_minute, + }) + guest_state_changed.emit( + guest.guest_id, + old_state, + GuestRuntimeType.State.DECIDING, + &"AI_LOOP_GUARD" + ) + + +func _is_allowed_transition(from_state: int, to_state: int) -> bool: + if from_state == to_state: + return true + if to_state in [ + GuestRuntimeType.State.LEAVING, + GuestRuntimeType.State.FLEEING, + GuestRuntimeType.State.INCAPACITATED, + ]: + return from_state != GuestRuntimeType.State.DESPAWNING + match from_state: + GuestRuntimeType.State.SPAWNING: + return to_state in [GuestRuntimeType.State.ENTERING, GuestRuntimeType.State.DECIDING] + GuestRuntimeType.State.ENTERING: + return to_state in [GuestRuntimeType.State.DECIDING, GuestRuntimeType.State.MOVING] + GuestRuntimeType.State.DECIDING: + return to_state in [GuestRuntimeType.State.MOVING, GuestRuntimeType.State.QUEUING] + GuestRuntimeType.State.MOVING: + return to_state in [ + GuestRuntimeType.State.QUEUING, + GuestRuntimeType.State.RECEIVING_SERVICE, + GuestRuntimeType.State.DECIDING, + GuestRuntimeType.State.REPLANNING, + ] + GuestRuntimeType.State.QUEUING: + return to_state in [ + GuestRuntimeType.State.RECEIVING_SERVICE, + GuestRuntimeType.State.ABANDONING, + GuestRuntimeType.State.DECIDING, + ] + GuestRuntimeType.State.RECEIVING_SERVICE: + return to_state in [GuestRuntimeType.State.DECIDING, GuestRuntimeType.State.INTERRUPTED] + GuestRuntimeType.State.REPLANNING: + return to_state in [GuestRuntimeType.State.MOVING, GuestRuntimeType.State.DECIDING] + GuestRuntimeType.State.ABANDONING, GuestRuntimeType.State.INTERRUPTED: + return to_state == GuestRuntimeType.State.DECIDING + GuestRuntimeType.State.FLEEING: + return to_state in [GuestRuntimeType.State.LEAVING, GuestRuntimeType.State.DESPAWNING] + GuestRuntimeType.State.LEAVING: + return to_state == GuestRuntimeType.State.DESPAWNING + _: + return false + + +static func _callback_succeeded(result: Variant) -> bool: + if result == null: + return true + if result is bool: + return result + if result is Dictionary: + return bool(result.get(&"ok", result.get("ok", result.get(&"success", result.get("success", false))))) + if result is Object and result.has_method("is_success"): + return bool(result.call("is_success")) + return false + + +static func _persistent_record_is_valid(record: Dictionary) -> bool: + if StringName(record.get(&"guest_id", "")).is_empty(): + return false + if StringName(record.get(&"guest_type_id", "")).is_empty(): + return false + if int(record.get(&"debt_gold", -1)) < 0: + return false + return record.get(&"unresolved_incident_ids", null) is Array \ + and record.get(&"outstanding_reservation_ids", null) is Array + + +static func _facility_id(candidate: Dictionary) -> StringName: + return StringName(_value( + candidate, + &"facility_id", + _value(candidate, &"instance_id", &"") + )) + + +static func _name_array(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for raw_value: Variant in source: + var value := StringName(raw_value) + if not value.is_empty() and not result.has(value): + result.append(value) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _sorted_ids(source: Array) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_value: Variant in source: + result.append(StringName(raw_value)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +func _ordered_guest_ids() -> Array[StringName]: + if _guest_order_dirty: + _sorted_guest_ids_cache = _sorted_ids(_guests.keys()) + _guest_order_dirty = false + return _sorted_guest_ids_cache + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + var string_key := String(key) + return source[string_key] if source.has(string_key) else fallback + + +static func _has_value(source: Dictionary, key: StringName) -> bool: + return source.has(key) or source.has(String(key)) + + +static func _need_report(values: Dictionary) -> Dictionary: + # Matches _string_key_dictionary's canonical lexical key order without + # allocating and sorting a six-key array for every guest and minute. + return { + "hunger": int(values[&"hunger"]), + "hygiene": int(values[&"hygiene"]), + "intimacy": int(values[&"intimacy"]), + "leisure": int(values[&"leisure"]), + "medical": int(values[&"medical"]), + "sleep": int(values[&"sleep"]), + } + + +static func _copy(value: Variant) -> Variant: + if value is Array or value is Dictionary: + return value.duplicate(true) + return value + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() == 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + return Vector3i.ZERO + + +static func _cell_to_array(cell: Vector3i) -> Array[int]: + return [cell.x, cell.y, cell.z] + + +static func _cell_precedes(a: Vector3i, b: Vector3i) -> bool: + if a.z != b.z: + return a.z < b.z + if a.y != b.y: + return a.y < b.y + return a.x < b.x + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_key: Variant in keys: + result[String(raw_key)] = source[raw_key] + return result + + +func _fail(message: String) -> bool: + last_error = message + return false + + +func _error_result(message: String) -> Dictionary: + last_error = message + return {&"ok": false, &"error": message} diff --git a/scripts/guest/guest_service.gd.uid b/scripts/guest/guest_service.gd.uid new file mode 100644 index 0000000..a513ea1 --- /dev/null +++ b/scripts/guest/guest_service.gd.uid @@ -0,0 +1 @@ +uid://bms66msnt4qwy diff --git a/scripts/inventory/inventory_reservation.gd b/scripts/inventory/inventory_reservation.gd new file mode 100644 index 0000000..de20d80 --- /dev/null +++ b/scripts/inventory/inventory_reservation.gd @@ -0,0 +1,125 @@ +class_name InventoryReservation +extends RefCounted + +enum State { + ACTIVE, + CONSUMED, + RELEASED, + EXPIRED, +} + +var reservation_id: StringName = &"" +var stack_id: StringName = &"" +var owner_inventory_id: StringName = &"" +var item_id: StringName = &"" +var quantity := 0 +var requester_id: StringName = &"" +var purpose: StringName = &"" +var created_at := 0 +var expires_at := -1 +var state: State = State.ACTIVE +var terminal_at := -1 + + +func configure( + p_reservation_id: StringName, + p_stack_id: StringName, + p_owner_inventory_id: StringName, + p_item_id: StringName, + p_quantity: int, + p_requester_id: StringName, + p_purpose: StringName, + p_created_at: int, + p_expires_at: int = -1 +) -> InventoryReservation: + reservation_id = p_reservation_id + stack_id = p_stack_id + owner_inventory_id = p_owner_inventory_id + item_id = p_item_id + quantity = maxi(p_quantity, 0) + requester_id = p_requester_id + purpose = p_purpose + created_at = maxi(p_created_at, 0) + expires_at = p_expires_at + return self + + +func is_active() -> bool: + return state == State.ACTIVE + + +func is_expired(absolute_minute: int) -> bool: + return expires_at >= 0 and absolute_minute >= expires_at + + +func mark_consumed(absolute_minute: int) -> bool: + if state != State.ACTIVE: + return false + state = State.CONSUMED + terminal_at = absolute_minute + return true + + +func mark_released(absolute_minute: int) -> bool: + if state != State.ACTIVE: + return false + state = State.RELEASED + terminal_at = absolute_minute + return true + + +func mark_expired(absolute_minute: int) -> bool: + if state != State.ACTIVE: + return false + state = State.EXPIRED + terminal_at = absolute_minute + return true + + +func create_snapshot() -> Dictionary: + return { + &"reservation_id": String(reservation_id), + &"stack_id": String(stack_id), + &"owner_inventory_id": String(owner_inventory_id), + &"item_id": String(item_id), + &"quantity": quantity, + &"requester_id": String(requester_id), + &"purpose": String(purpose), + &"created_at": created_at, + &"expires_at": expires_at, + &"state": State.keys()[state], + &"terminal_at": terminal_at, + } + + +static func from_snapshot(snapshot: Dictionary) -> InventoryReservation: + if not _is_valid_snapshot(snapshot): + return null + var state_index := State.keys().find(String(snapshot.get(&"state", "ACTIVE"))) + if state_index < 0: + return null + var reservation := InventoryReservation.new().configure( + StringName(snapshot.get(&"reservation_id", "")), + StringName(snapshot.get(&"stack_id", "")), + StringName(snapshot.get(&"owner_inventory_id", "")), + StringName(snapshot.get(&"item_id", "")), + int(snapshot.get(&"quantity", 0)), + StringName(snapshot.get(&"requester_id", "")), + StringName(snapshot.get(&"purpose", "")), + int(snapshot.get(&"created_at", 0)), + int(snapshot.get(&"expires_at", -1)) + ) + reservation.state = state_index as State + reservation.terminal_at = int(snapshot.get(&"terminal_at", -1)) + return reservation + + +static func _is_valid_snapshot(snapshot: Dictionary) -> bool: + return ( + not String(snapshot.get(&"reservation_id", "")).is_empty() + and not String(snapshot.get(&"stack_id", "")).is_empty() + and not String(snapshot.get(&"owner_inventory_id", "")).is_empty() + and not String(snapshot.get(&"item_id", "")).is_empty() + and int(snapshot.get(&"quantity", 0)) > 0 + and int(snapshot.get(&"created_at", -1)) >= 0 + ) diff --git a/scripts/inventory/inventory_reservation.gd.uid b/scripts/inventory/inventory_reservation.gd.uid new file mode 100644 index 0000000..7878c7c --- /dev/null +++ b/scripts/inventory/inventory_reservation.gd.uid @@ -0,0 +1 @@ +uid://ci4av5wp5tmid diff --git a/scripts/inventory/inventory_service.gd b/scripts/inventory/inventory_service.gd new file mode 100644 index 0000000..b3ab5c9 --- /dev/null +++ b/scripts/inventory/inventory_service.gd @@ -0,0 +1,1559 @@ +class_name InventoryService +extends RefCounted + +const InventoryReservationType = preload("res://scripts/inventory/inventory_reservation.gd") + +const SNAPSHOT_VERSION := 2 +const LEGACY_SNAPSHOT_VERSION := 1 +const DEFAULT_QUALITY := 100 +const DEFAULT_UNIT_WEIGHT := 1 + +var current_minute := 0 +var last_restore_error := "" +var last_repair_report: Dictionary = {} + +var _inventories: Dictionary = {} +## Derived O(1) stack location index. It is rebuilt after restore and repaired +## lazily if diagnostic code mutates the public test seam `_inventories`. +var _stack_owner_by_id: Dictionary = {} +var _reservations: Dictionary = {} +## Persisted tombstones keep generated and explicit ids single-use even after +## their live stack/reservation has been removed. +var _known_stack_ids: Dictionary = {} +var _known_reservation_ids: Dictionary = {} +var _idempotency_results: Dictionary = {} +var _next_stack_sequence := 1 +var _next_reservation_sequence := 1 +var _atomic_batch_depth := 0 + + +func add_stack( + owner_inventory_id: StringName, + item_id: StringName, + quantity: int, + quality: int = DEFAULT_QUALITY, + expires_at: int = -1, + created_at: int = -1, + unit_weight: int = DEFAULT_UNIT_WEIGHT, + stack_id: StringName = &"" +) -> Dictionary: + if owner_inventory_id == &"" or item_id == &"": + return _failure(&"INVALID_ID", "Inventory and item ids are required.") + if quantity <= 0 or unit_weight <= 0: + return _failure(&"INVALID_QUANTITY", "Quantity and unit weight must be positive.") + var effective_stack_id := stack_id + if effective_stack_id == &"": + effective_stack_id = _next_stack_id() + elif _known_stack_ids.has(effective_stack_id) or bool(_find_stack(effective_stack_id)[&"found"]): + return _failure(&"DUPLICATE_STACK", "Stack id has already been used.") + var stack := _make_stack( + effective_stack_id, + owner_inventory_id, + item_id, + quantity, + quality, + -1 if expires_at < 0 else expires_at, + current_minute if created_at < 0 else created_at, + unit_weight + ) + _insert_stack(stack) + return _success({&"stack_id": effective_stack_id, &"quantity": quantity}) + + +func reserve_item( + owner_inventory_id: StringName, + item_id: StringName, + quantity: int, + requester_id: StringName, + purpose: StringName, + created_at: int = -1, + expires_at: int = -1, + operation_id: StringName = &"" +) -> Dictionary: + var minute := current_minute if created_at < 0 else created_at + var normalized_expires_at := -1 if expires_at < 0 else expires_at + var fingerprint := _canonical_fingerprint([ + owner_inventory_id, + item_id, + quantity, + requester_id, + purpose, + minute, + normalized_expires_at, + ]) + var replay := _get_replay(&"reserve", operation_id, fingerprint) + if not replay.is_empty(): + return replay + if owner_inventory_id == &"" or item_id == &"" or requester_id == &"": + return _remember_failure( + &"reserve", operation_id, fingerprint, &"INVALID_ID", "Inventory, item, and requester ids are required." + ) + if quantity <= 0: + return _remember_failure( + &"reserve", operation_id, fingerprint, &"INVALID_QUANTITY", "Reservation quantity must be positive." + ) + var candidates := _available_stacks(owner_inventory_id, item_id, minute) + var total_available := 0 + for stack: Dictionary in candidates: + total_available += _available_in_stack(stack) + if total_available < quantity: + return _remember_failure( + &"reserve", + operation_id, + fingerprint, + &"INSUFFICIENT_AVAILABLE", + "Available stock is below the requested quantity.", + {&"requested": quantity, &"available": total_available} + ) + + var remaining := quantity + var allocations: Array[Dictionary] = [] + for stack: Dictionary in candidates: + if remaining <= 0: + break + var allocated := mini(remaining, _available_in_stack(stack)) + if allocated > 0: + allocations.append({&"stack_id": stack[&"stack_id"], &"quantity": allocated}) + remaining -= allocated + + var reservation_ids: Array[StringName] = [] + var stack_ids: Array[StringName] = [] + for allocation: Dictionary in allocations: + var stack_id: StringName = allocation[&"stack_id"] + var location := _find_stack(stack_id) + var stack: Dictionary = location[&"stack"] + var allocated := int(allocation[&"quantity"]) + stack[&"reserved_quantity"] = int(stack[&"reserved_quantity"]) + allocated + _set_stack(location[&"owner_inventory_id"], stack) + var reservation_id := _next_reservation_id() + var reservation := InventoryReservationType.new().configure( + reservation_id, + stack_id, + owner_inventory_id, + item_id, + allocated, + requester_id, + purpose, + minute, + normalized_expires_at + ) + _reservations[reservation_id] = reservation + reservation_ids.append(reservation_id) + stack_ids.append(stack_id) + + var result := _success({ + &"reservation_ids": reservation_ids, + &"stack_ids": stack_ids, + &"quantity": quantity, + }) + return _remember(&"reserve", operation_id, result, fingerprint) + + +func reserve( + owner_inventory_id: StringName, + item_id: StringName, + quantity: int, + requester_id: StringName, + purpose: StringName, + created_at: int = -1, + expires_at: int = -1, + operation_id: StringName = &"" +) -> Dictionary: + return reserve_item( + owner_inventory_id, + item_id, + quantity, + requester_id, + purpose, + created_at, + expires_at, + operation_id + ) + + +func consume_reservation( + reservation_id: StringName, + operation_id: StringName = &"", + absolute_minute: int = -1 +) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var fingerprint := _canonical_fingerprint([reservation_id, minute]) + var replay := _get_replay(&"consume", operation_id, fingerprint) + if not replay.is_empty(): + return replay + if not _reservations.has(reservation_id): + return _remember_failure( + &"consume", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist." + ) + var reservation: InventoryReservation = _reservations[reservation_id] + if reservation.state == InventoryReservationType.State.CONSUMED: + return _remember(&"consume", operation_id, _success({ + &"reservation_id": reservation_id, + &"quantity": 0, + &"replay": true, + &"code": &"ALREADY_CONSUMED", + }), fingerprint) + if not reservation.is_active(): + return _remember_failure( + &"consume", operation_id, fingerprint, &"RESERVATION_CLOSED", "Reservation is no longer active." + ) + var validation := _validate_active_reservation(reservation, minute) + if not bool(validation[&"ok"]): + return _remember(&"consume", operation_id, validation, fingerprint) + var location: Dictionary = validation[&"location"] + var stack: Dictionary = location[&"stack"] + stack[&"quantity"] = int(stack[&"quantity"]) - reservation.quantity + stack[&"reserved_quantity"] = int(stack[&"reserved_quantity"]) - reservation.quantity + reservation.mark_consumed(minute) + _set_or_remove_stack(location[&"owner_inventory_id"], stack) + var result := _success({ + &"reservation_id": reservation_id, + &"stack_id": reservation.stack_id, + &"quantity": reservation.quantity, + }) + return _remember(&"consume", operation_id, result, fingerprint) + + +func consume_reservations( + reservation_ids: Array[StringName], + operation_id: StringName = &"", + absolute_minute: int = -1 +) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var fingerprint_ids := _sorted_names(reservation_ids) + var fingerprint := _canonical_fingerprint([fingerprint_ids, minute]) + var replay := _get_replay(&"consume_many", operation_id, fingerprint) + if not replay.is_empty(): + return replay + var unique_ids := _unique_sorted_names(reservation_ids) + if unique_ids.is_empty() or unique_ids.size() != reservation_ids.size(): + return _remember_failure( + &"consume_many", operation_id, fingerprint, &"INVALID_RESERVATIONS", "Reservation ids must be unique and non-empty." + ) + for reservation_id: StringName in unique_ids: + if not _reservations.has(reservation_id): + return _remember_failure( + &"consume_many", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist." + ) + var validation := _validate_active_reservation(_reservations[reservation_id], minute) + if not bool(validation[&"ok"]): + return _remember(&"consume_many", operation_id, validation, fingerprint) + + var capture := create_snapshot() + var consumed := 0 + for reservation_id: StringName in unique_ids: + var result := consume_reservation(reservation_id, &"", minute) + if not bool(result[&"ok"]): + restore_snapshot(capture) + return _remember_failure( + &"consume_many", + operation_id, + fingerprint, + &"ATOMIC_APPLY_FAILED", + "Reservation group consumption was rolled back.", + {&"cause": result} + ) + consumed += int(result.get(&"quantity", 0)) + var final_result := _success({ + &"reservation_ids": unique_ids, + &"quantity": consumed, + }) + return _remember(&"consume_many", operation_id, final_result, fingerprint) + + +func release_reservation( + reservation_id: StringName, + operation_id: StringName = &"", + absolute_minute: int = -1 +) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var fingerprint := _canonical_fingerprint([reservation_id, minute]) + var replay := _get_replay(&"release", operation_id, fingerprint) + if not replay.is_empty(): + return replay + if not _reservations.has(reservation_id): + return _remember_failure( + &"release", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist." + ) + var reservation: InventoryReservation = _reservations[reservation_id] + if reservation.state == InventoryReservationType.State.RELEASED or reservation.state == InventoryReservationType.State.EXPIRED: + return _remember(&"release", operation_id, _success({ + &"reservation_id": reservation_id, + &"quantity": 0, + &"replay": true, + &"code": &"ALREADY_RELEASED", + }), fingerprint) + if not reservation.is_active(): + return _remember_failure( + &"release", operation_id, fingerprint, &"RESERVATION_CLOSED", "Consumed stock cannot be released." + ) + var location := _find_stack(reservation.stack_id) + if not bool(location[&"found"]): + reservation.mark_released(minute) + var orphan_result := _success({ + &"reservation_id": reservation_id, + &"quantity": 0, + &"orphan_repaired": true, + }) + return _remember(&"release", operation_id, orphan_result, fingerprint) + var stack: Dictionary = location[&"stack"] + stack[&"reserved_quantity"] = maxi( + int(stack[&"reserved_quantity"]) - reservation.quantity, + 0 + ) + reservation.mark_released(minute) + _set_or_remove_stack(location[&"owner_inventory_id"], stack) + var result := _success({ + &"reservation_id": reservation_id, + &"quantity": reservation.quantity, + }) + return _remember(&"release", operation_id, result, fingerprint) + + +func transfer_available( + source_inventory_id: StringName, + destination_inventory_id: StringName, + item_id: StringName, + quantity: int, + operation_id: StringName = &"", + absolute_minute: int = -1 +) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var fingerprint := _transfer_fingerprint( + source_inventory_id, + destination_inventory_id, + item_id, + quantity, + minute + ) + var replay := _get_replay(&"transfer", operation_id, fingerprint) + if not replay.is_empty(): + return replay + if source_inventory_id == &"" or destination_inventory_id == &"" or item_id == &"": + return _remember_failure(&"transfer", operation_id, fingerprint, &"INVALID_ID", "Transfer ids are required.") + if source_inventory_id == destination_inventory_id: + return _remember_failure( + &"transfer", operation_id, fingerprint, &"SAME_INVENTORY", "Source and destination must differ." + ) + if quantity <= 0: + return _remember_failure( + &"transfer", operation_id, fingerprint, &"INVALID_QUANTITY", "Transfer quantity must be positive." + ) + var candidates := _available_stacks(source_inventory_id, item_id, minute) + var available := 0 + for stack: Dictionary in candidates: + available += _available_in_stack(stack) + if available < quantity: + return _remember_failure( + &"transfer", + operation_id, + fingerprint, + &"INSUFFICIENT_AVAILABLE", + "Available stock is below the transfer quantity.", + {&"requested": quantity, &"available": available} + ) + + var capture := {} if _atomic_batch_depth > 0 else create_snapshot() + var remaining := quantity + var destination_stack_ids: Array[StringName] = [] + for candidate: Dictionary in candidates: + if remaining <= 0: + break + var moved := mini(remaining, _available_in_stack(candidate)) + if moved <= 0: + continue + var location := _find_stack(candidate[&"stack_id"]) + if not bool(location[&"found"]): + _restore_standalone_capture(capture) + return _remember_failure( + &"transfer", operation_id, fingerprint, &"ATOMIC_APPLY_FAILED", "Transfer was rolled back." + ) + var source_stack: Dictionary = location[&"stack"] + source_stack[&"quantity"] = int(source_stack[&"quantity"]) - moved + _set_or_remove_stack(source_inventory_id, source_stack) + var destination_stack_id := _next_stack_id() + _insert_stack(_make_stack( + destination_stack_id, + destination_inventory_id, + item_id, + moved, + int(candidate[&"quality"]), + int(candidate[&"expires_at"]), + int(candidate[&"created_at"]), + int(candidate[&"unit_weight"]) + )) + destination_stack_ids.append(destination_stack_id) + remaining -= moved + if remaining != 0 or (_atomic_batch_depth == 0 and not validate_invariants()[&"ok"]): + _restore_standalone_capture(capture) + return _remember_failure( + &"transfer", operation_id, fingerprint, &"ATOMIC_APPLY_FAILED", "Transfer invariant failed and was rolled back." + ) + var result := _success({ + &"source_inventory_id": source_inventory_id, + &"destination_inventory_id": destination_inventory_id, + &"item_id": item_id, + &"quantity": quantity, + &"destination_stack_ids": destination_stack_ids, + }) + return _remember(&"transfer", operation_id, result, fingerprint) + + +func transfer( + source_inventory_id: StringName, + destination_inventory_id: StringName, + item_id: StringName, + quantity: int, + operation_id: StringName = &"", + absolute_minute: int = -1 +) -> Dictionary: + return transfer_available( + source_inventory_id, + destination_inventory_id, + item_id, + quantity, + operation_id, + absolute_minute + ) + + +## Applies the same ordered transfers as repeated transfer_available calls, but +## captures and validates state once for the whole atomic group. This keeps a +## large logistics wave deterministic without doing a full-world snapshot for +## every item line. +func transfer_available_batch( + transfers: Array[Dictionary], + absolute_minute: int = -1 +) -> Dictionary: + if transfers.is_empty() or _atomic_batch_depth != 0: + return _failure(&"INVALID_BATCH", "A non-empty top-level transfer batch is required.") + var minute := current_minute if absolute_minute < 0 else absolute_minute + var preflight := _preflight_available_batch(transfers, minute) + if not bool(preflight[&"ok"]): + return preflight + var capture := create_snapshot() + var results: Array[Dictionary] = [] + _atomic_batch_depth = 1 + for index: int in range(transfers.size()): + var entry: Dictionary = transfers[index] + var result := transfer_available( + StringName(entry.get(&"source_inventory_id", entry.get("source_inventory_id", ""))), + StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))), + StringName(entry.get(&"item_id", entry.get("item_id", ""))), + int(entry.get(&"quantity", entry.get("quantity", 0))), + StringName(entry.get(&"operation_id", entry.get("operation_id", ""))), + minute + ) + if not bool(result.get(&"ok", false)): + _atomic_batch_depth = 0 + restore_snapshot(capture) + return _failure( + &"ATOMIC_BATCH_FAILED", + "Available-stock transfer batch was rolled back.", + {&"failed_index": index, &"cause": result} + ) + results.append(result) + _atomic_batch_depth = 0 + if not validate_invariants()[&"ok"]: + restore_snapshot(capture) + return _failure( + &"ATOMIC_BATCH_FAILED", + "Available-stock transfer batch invariant failed and was rolled back." + ) + return _success({&"results": results, &"transfer_count": results.size()}) + + +func transfer_reserved( + reservation_ids: Array[StringName], + destination_inventory_id: StringName, + operation_id: StringName = &"", + absolute_minute: int = -1 +) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var fingerprint_ids := _sorted_names(reservation_ids) + var fingerprint := _reserved_transfer_fingerprint( + fingerprint_ids, + destination_inventory_id, + minute + ) + var replay := _get_replay(&"transfer_reserved", operation_id, fingerprint) + if not replay.is_empty(): + return replay + var ids := _unique_sorted_names(reservation_ids) + if ids.is_empty() or ids.size() != reservation_ids.size() or destination_inventory_id == &"": + return _remember_failure( + &"transfer_reserved", + operation_id, + fingerprint, + &"INVALID_ARGUMENT", + "Reservation ids must be unique and destination is required." + ) + var source_inventory_id: StringName = &"" + for reservation_id: StringName in ids: + if not _reservations.has(reservation_id): + return _remember_failure( + &"transfer_reserved", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist." + ) + var reservation: InventoryReservation = _reservations[reservation_id] + var validation := _validate_active_reservation(reservation, minute) + if not bool(validation[&"ok"]): + return _remember(&"transfer_reserved", operation_id, validation, fingerprint) + if source_inventory_id == &"": + source_inventory_id = reservation.owner_inventory_id + elif source_inventory_id != reservation.owner_inventory_id: + return _remember_failure( + &"transfer_reserved", operation_id, fingerprint, &"MULTIPLE_SOURCES", "One transfer cannot mix source inventories." + ) + if source_inventory_id == destination_inventory_id: + return _remember_failure( + &"transfer_reserved", operation_id, fingerprint, &"SAME_INVENTORY", "Source and destination must differ." + ) + + var capture := {} if _atomic_batch_depth > 0 else create_snapshot() + var moved_quantity := 0 + var moved_weight := 0 + var destination_stack_ids: Array[StringName] = [] + for reservation_id: StringName in ids: + var reservation: InventoryReservation = _reservations[reservation_id] + var location := _find_stack(reservation.stack_id) + if not bool(location[&"found"]): + _restore_standalone_capture(capture) + return _remember_failure( + &"transfer_reserved", operation_id, fingerprint, &"ATOMIC_APPLY_FAILED", "Transfer was rolled back." + ) + var source_stack: Dictionary = location[&"stack"] + source_stack[&"quantity"] = int(source_stack[&"quantity"]) - reservation.quantity + source_stack[&"reserved_quantity"] = int(source_stack[&"reserved_quantity"]) - reservation.quantity + _set_or_remove_stack(source_inventory_id, source_stack) + reservation.mark_consumed(minute) + var destination_stack_id := _next_stack_id() + _insert_stack(_make_stack( + destination_stack_id, + destination_inventory_id, + reservation.item_id, + reservation.quantity, + int(source_stack[&"quality"]), + int(source_stack[&"expires_at"]), + int(source_stack[&"created_at"]), + int(source_stack[&"unit_weight"]) + )) + destination_stack_ids.append(destination_stack_id) + moved_quantity += reservation.quantity + moved_weight += reservation.quantity * int(source_stack[&"unit_weight"]) + if _atomic_batch_depth == 0 and not validate_invariants()[&"ok"]: + _restore_standalone_capture(capture) + return _remember_failure( + &"transfer_reserved", + operation_id, + fingerprint, + &"ATOMIC_APPLY_FAILED", + "Transfer invariant failed and was rolled back." + ) + var result := _success({ + &"source_inventory_id": source_inventory_id, + &"destination_inventory_id": destination_inventory_id, + &"reservation_ids": ids, + &"destination_stack_ids": destination_stack_ids, + &"quantity": moved_quantity, + &"weight": moved_weight, + }) + return _remember(&"transfer_reserved", operation_id, result, fingerprint) + + +## Atomic ordered counterpart to repeated transfer_reserved calls. Individual +## operation ids retain their normal replay semantics and snapshots remain +## byte-structure equivalent to the sequential path. +func transfer_reserved_batch( + transfers: Array[Dictionary], + absolute_minute: int = -1 +) -> Dictionary: + if transfers.is_empty() or _atomic_batch_depth != 0: + return _failure(&"INVALID_BATCH", "A non-empty top-level reserved transfer batch is required.") + var minute := current_minute if absolute_minute < 0 else absolute_minute + var preflight := _preflight_reserved_batch(transfers, minute) + if not bool(preflight[&"ok"]): + return preflight + var capture := create_snapshot() + var results: Array[Dictionary] = [] + _atomic_batch_depth = 1 + for index: int in range(transfers.size()): + var entry: Dictionary = transfers[index] + var reservation_ids: Array[StringName] = [] + var raw_ids: Variant = entry.get(&"reservation_ids", entry.get("reservation_ids", [])) + if raw_ids is Array: + for raw_id: Variant in raw_ids: + reservation_ids.append(StringName(raw_id)) + var result := transfer_reserved( + reservation_ids, + StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))), + StringName(entry.get(&"operation_id", entry.get("operation_id", ""))), + minute + ) + if not bool(result.get(&"ok", false)): + _atomic_batch_depth = 0 + restore_snapshot(capture) + return _failure( + &"ATOMIC_BATCH_FAILED", + "Reserved-stock transfer batch was rolled back.", + {&"failed_index": index, &"cause": result} + ) + results.append(result) + _atomic_batch_depth = 0 + if not validate_invariants()[&"ok"]: + restore_snapshot(capture) + return _failure( + &"ATOMIC_BATCH_FAILED", + "Reserved-stock transfer batch invariant failed and was rolled back." + ) + return _success({&"results": results, &"transfer_count": results.size()}) + + +func process_expired(absolute_minute: int, waste_inventory_id: StringName = &"") -> Dictionary: + current_minute = maxi(current_minute, absolute_minute) + var expired_reservations: Array[StringName] = [] + for reservation_id: StringName in _sorted_names(_reservations.keys()): + var reservation: InventoryReservation = _reservations[reservation_id] + if not reservation.is_active() or not reservation.is_expired(absolute_minute): + continue + var location := _find_stack(reservation.stack_id) + if bool(location[&"found"]): + var stack: Dictionary = location[&"stack"] + stack[&"reserved_quantity"] = maxi( + int(stack[&"reserved_quantity"]) - reservation.quantity, + 0 + ) + _set_or_remove_stack(location[&"owner_inventory_id"], stack) + reservation.mark_expired(absolute_minute) + expired_reservations.append(reservation_id) + + var discarded: Array[Dictionary] = [] + var waste_lines: Array[Dictionary] = [] + for owner_inventory_id: StringName in _sorted_names(_inventories.keys()): + var stack_ids := _sorted_names((_inventories[owner_inventory_id] as Dictionary).keys()) + for stack_id: StringName in stack_ids: + var location := _find_stack(stack_id) + if not bool(location[&"found"]): + continue + var stack: Dictionary = location[&"stack"] + if not _stack_is_expired(stack, absolute_minute): + continue + var disposable := _available_in_stack(stack) + if disposable <= 0: + continue + stack[&"quantity"] = int(stack[&"quantity"]) - disposable + _set_or_remove_stack(owner_inventory_id, stack) + var line := { + &"owner_inventory_id": owner_inventory_id, + &"stack_id": stack_id, + &"item_id": stack[&"item_id"], + &"quantity": disposable, + } + discarded.append(line) + if waste_inventory_id != &"": + waste_lines.append(line) + for line: Dictionary in waste_lines: + _insert_stack(_make_stack( + _next_stack_id(), + waste_inventory_id, + StringName("waste/%s" % String(line[&"item_id"])), + int(line[&"quantity"]), + 0, + -1, + absolute_minute, + DEFAULT_UNIT_WEIGHT + )) + return { + &"ok": true, + &"expired_reservation_ids": expired_reservations, + &"discarded": discarded, + &"discarded_quantity": _sum_lines(discarded), + } + + +func is_stack_expired(stack_id: StringName, absolute_minute: int = -1) -> bool: + var location := _find_stack(stack_id) + if not bool(location[&"found"]): + return false + return _stack_is_expired( + location[&"stack"], + current_minute if absolute_minute < 0 else absolute_minute + ) + + +func available_quantity( + owner_inventory_id: StringName, + item_id: StringName, + absolute_minute: int = -1 +) -> int: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var total := 0 + for stack: Dictionary in _available_stacks(owner_inventory_id, item_id, minute): + total += _available_in_stack(stack) + return total + + +func total_quantity(owner_inventory_id: StringName, item_id: StringName) -> int: + var total := 0 + if not _inventories.has(owner_inventory_id): + return total + for stack: Dictionary in (_inventories[owner_inventory_id] as Dictionary).values(): + if stack[&"item_id"] == item_id: + total += int(stack[&"quantity"]) + return total + + +func get_stack(stack_id: StringName) -> Dictionary: + var location := _find_stack(stack_id) + if not bool(location[&"found"]): + return {} + return (location[&"stack"] as Dictionary).duplicate(true) + + +func get_stacks(owner_inventory_id: StringName = &"") -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var owners := _sorted_names(_inventories.keys()) if owner_inventory_id == &"" else [owner_inventory_id] + for owner: StringName in owners: + if not _inventories.has(owner): + continue + for stack_id: StringName in _sorted_names((_inventories[owner] as Dictionary).keys()): + result.append(((_inventories[owner] as Dictionary)[stack_id] as Dictionary).duplicate(true)) + return result + + +func get_reservation(reservation_id: StringName) -> InventoryReservation: + return _reservations.get(reservation_id) as InventoryReservation + + +func get_reservations(active_only := false) -> Array[InventoryReservation]: + var result: Array[InventoryReservation] = [] + for reservation_id: StringName in _sorted_names(_reservations.keys()): + var reservation: InventoryReservation = _reservations[reservation_id] + if not active_only or reservation.is_active(): + result.append(reservation) + return result + + +func audit_and_repair_reservations(absolute_minute: int = -1) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var report := { + &"ok": true, + &"scanned_reservations": _reservations.size(), + &"orphan_count": 0, + &"capacity_violation_count": 0, + &"released_reservation_ids": [], + &"corrected_stacks": [], + } + var reservations_by_stack: Dictionary = {} + for reservation_id: StringName in _sorted_names(_reservations.keys()): + var reservation: InventoryReservation = _reservations[reservation_id] + if not reservation.is_active(): + continue + var location := _find_stack(reservation.stack_id) + var orphan: bool = ( + not bool(location[&"found"]) + or location[&"owner_inventory_id"] != reservation.owner_inventory_id + or (bool(location[&"found"]) and location[&"stack"][&"item_id"] != reservation.item_id) + or reservation.quantity <= 0 + ) + if orphan: + reservation.mark_released(minute) + report[&"orphan_count"] = int(report[&"orphan_count"]) + 1 + report[&"released_reservation_ids"].append(reservation_id) + continue + if not reservations_by_stack.has(reservation.stack_id): + reservations_by_stack[reservation.stack_id] = [] + (reservations_by_stack[reservation.stack_id] as Array).append(reservation_id) + + for stack_id: StringName in _sorted_names(reservations_by_stack.keys()): + var location := _find_stack(stack_id) + if not bool(location[&"found"]): + continue + var stack: Dictionary = location[&"stack"] + var reservation_ids: Array = reservations_by_stack[stack_id] + var expected := 0 + for reservation_id: StringName in reservation_ids: + expected += (_reservations[reservation_id] as InventoryReservation).quantity + if expected > int(stack[&"quantity"]): + reservation_ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) > String(b)) + for reservation_id: StringName in reservation_ids: + if expected <= int(stack[&"quantity"]): + break + var reservation: InventoryReservation = _reservations[reservation_id] + expected -= reservation.quantity + reservation.mark_released(minute) + report[&"capacity_violation_count"] = int(report[&"capacity_violation_count"]) + 1 + report[&"released_reservation_ids"].append(reservation_id) + + var expected_by_stack: Dictionary = {} + for reservation_id: StringName in _sorted_names(_reservations.keys()): + var reservation: InventoryReservation = _reservations[reservation_id] + if reservation.is_active(): + expected_by_stack[reservation.stack_id] = int(expected_by_stack.get(reservation.stack_id, 0)) + reservation.quantity + for owner_inventory_id: StringName in _sorted_names(_inventories.keys()): + for stack_id: StringName in _sorted_names((_inventories[owner_inventory_id] as Dictionary).keys()): + var stack: Dictionary = (_inventories[owner_inventory_id] as Dictionary)[stack_id] + var expected := int(expected_by_stack.get(stack_id, 0)) + if int(stack[&"reserved_quantity"]) == expected: + continue + report[&"corrected_stacks"].append({ + &"stack_id": stack_id, + &"before": int(stack[&"reserved_quantity"]), + &"after": expected, + }) + stack[&"reserved_quantity"] = expected + _set_stack(owner_inventory_id, stack) + last_repair_report = report.duplicate(true) + return report + + +func repair_orphan_reservations(absolute_minute: int = -1) -> Dictionary: + return audit_and_repair_reservations(absolute_minute) + + +func validate_invariants() -> Dictionary: + var error := _validate_state(_inventories, _reservations) + return { + &"ok": error.is_empty(), + &"error": error, + } + + +func create_snapshot() -> Dictionary: + var inventories: Array[Dictionary] = [] + for owner_inventory_id: StringName in _sorted_names(_inventories.keys()): + var stacks: Array[Dictionary] = [] + for stack_id: StringName in _sorted_names((_inventories[owner_inventory_id] as Dictionary).keys()): + var stack: Dictionary = (_inventories[owner_inventory_id] as Dictionary)[stack_id] + stacks.append(_serialize_stack(stack)) + inventories.append({ + &"owner_inventory_id": String(owner_inventory_id), + &"stacks": stacks, + }) + var reservations: Array[Dictionary] = [] + for reservation_id: StringName in _sorted_names(_reservations.keys()): + reservations.append((_reservations[reservation_id] as InventoryReservation).create_snapshot()) + var idempotency_results: Array[Dictionary] = [] + var operation_keys := _idempotency_results.keys() + operation_keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for operation_key: Variant in operation_keys: + var record: Dictionary = _idempotency_results[operation_key] + var serialized_record := { + &"operation_id": String(operation_key), + &"action": String(record[&"action"]), + &"input_fingerprint": String(record[&"input_fingerprint"]), + &"result": (record[&"result"] as Dictionary).duplicate(true), + } + if bool(record.get(&"legacy_unbound", false)): + serialized_record[&"legacy_unbound"] = true + idempotency_results.append(serialized_record) + return { + &"version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"next_stack_sequence": _next_stack_sequence, + &"next_reservation_sequence": _next_reservation_sequence, + &"known_stack_ids": _string_array(_sorted_names(_known_stack_ids.keys())), + &"known_reservation_ids": _string_array(_sorted_names(_known_reservation_ids.keys())), + &"inventories": inventories, + &"reservations": reservations, + &"idempotency_results": idempotency_results, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + last_restore_error = "" + var version := int(snapshot.get(&"version", -1)) + if version != SNAPSHOT_VERSION and version != LEGACY_SNAPSHOT_VERSION: + last_restore_error = "Unsupported inventory snapshot version." + return false + var next_current_minute := int(snapshot.get(&"current_minute", -1)) + if next_current_minute < 0: + last_restore_error = "Inventory snapshot current minute is invalid." + return false + if not snapshot.get(&"inventories", null) is Array or not snapshot.get(&"reservations", null) is Array: + last_restore_error = "Inventory snapshot collections are malformed." + return false + + var next_inventories: Dictionary = {} + var live_stack_ids: Dictionary = {} + for raw_inventory: Variant in snapshot[&"inventories"]: + if not raw_inventory is Dictionary: + last_restore_error = "Inventory entry must be a Dictionary." + return false + var owner_inventory_id := StringName(raw_inventory.get(&"owner_inventory_id", "")) + var raw_stacks: Variant = raw_inventory.get(&"stacks", null) + if owner_inventory_id == &"" or not raw_stacks is Array or next_inventories.has(owner_inventory_id): + last_restore_error = "Inventory owner entry is invalid or duplicated." + return false + var stacks: Dictionary = {} + for raw_stack: Variant in raw_stacks: + var stack := _parse_stack(raw_stack, owner_inventory_id) + if stack.is_empty(): + last_restore_error = "Inventory stack entry is invalid." + return false + var stack_id: StringName = stack[&"stack_id"] + if live_stack_ids.has(stack_id): + last_restore_error = "Inventory stack id is duplicated." + return false + live_stack_ids[stack_id] = true + stacks[stack_id] = stack + next_inventories[owner_inventory_id] = stacks + + var next_reservations: Dictionary = {} + var live_reservation_ids: Dictionary = {} + for raw_reservation: Variant in snapshot[&"reservations"]: + if not raw_reservation is Dictionary: + last_restore_error = "Reservation entry must be a Dictionary." + return false + if not _reservation_timestamps_are_valid(raw_reservation): + last_restore_error = "Reservation timestamps are invalid." + return false + var reservation := InventoryReservationType.from_snapshot(raw_reservation) + if reservation == null or next_reservations.has(reservation.reservation_id): + last_restore_error = "Reservation entry is invalid or duplicated." + return false + next_reservations[reservation.reservation_id] = reservation + live_reservation_ids[reservation.reservation_id] = true + + var next_known_stack_ids: Dictionary = {} + var next_known_reservation_ids: Dictionary = {} + if version == SNAPSHOT_VERSION: + var raw_known_stack_ids: Variant = snapshot.get(&"known_stack_ids", null) + var raw_known_reservation_ids: Variant = snapshot.get(&"known_reservation_ids", null) + if not raw_known_stack_ids is Array or not raw_known_reservation_ids is Array: + last_restore_error = "Inventory known-id collections are malformed." + return false + for raw_id: Variant in raw_known_stack_ids: + var known_id := StringName(raw_id) + if known_id == &"" or next_known_stack_ids.has(known_id): + last_restore_error = "Known stack id is invalid or duplicated." + return false + next_known_stack_ids[known_id] = true + for raw_id: Variant in raw_known_reservation_ids: + var known_id := StringName(raw_id) + if known_id == &"" or next_known_reservation_ids.has(known_id): + last_restore_error = "Known reservation id is invalid or duplicated." + return false + next_known_reservation_ids[known_id] = true + else: + next_known_stack_ids = live_stack_ids.duplicate(true) + next_known_reservation_ids = live_reservation_ids.duplicate(true) + for stack_id: Variant in live_stack_ids: + if not next_known_stack_ids.has(stack_id): + last_restore_error = "Live stack id is missing from known-id history." + return false + for reservation_id: Variant in live_reservation_ids: + if not next_known_reservation_ids.has(reservation_id): + last_restore_error = "Live reservation id is missing from known-id history." + return false + + var invariant_error := _validate_state(next_inventories, next_reservations) + if not invariant_error.is_empty(): + last_restore_error = invariant_error + return false + + var next_idempotency: Dictionary = {} + var raw_results: Variant = snapshot.get(&"idempotency_results", []) + if not raw_results is Array: + last_restore_error = "Idempotency result collection is malformed." + return false + for raw_result: Variant in raw_results: + if not raw_result is Dictionary or not raw_result.get(&"result", null) is Dictionary: + last_restore_error = "Idempotency result entry is malformed." + return false + var operation_id := StringName(raw_result.get(&"operation_id", "")) + var action := StringName(raw_result.get(&"action", "")) + var input_fingerprint := String(raw_result.get(&"input_fingerprint", "")) + var legacy_unbound := bool(raw_result.get(&"legacy_unbound", false)) + if version == LEGACY_SNAPSHOT_VERSION: + var legacy_key := String(raw_result.get(&"operation_key", "")) + var separator := legacy_key.find("|") + if separator <= 0 or separator >= legacy_key.length() - 1: + last_restore_error = "Legacy idempotency result key is invalid." + return false + action = StringName(legacy_key.substr(0, separator)) + operation_id = StringName(legacy_key.substr(separator + 1)) + input_fingerprint = "" + legacy_unbound = true + if ( + operation_id == &"" + or action == &"" + or (input_fingerprint.is_empty() and not legacy_unbound) + ): + last_restore_error = "Idempotency result key or binding is invalid or duplicated." + return false + if next_idempotency.has(operation_id): + if version != LEGACY_SNAPSHOT_VERSION: + last_restore_error = "Idempotency operation id is duplicated." + return false + # V1 namespaced ids by action, so two otherwise-valid legacy records can + # share one operation id. Preserve both payloads but fail closed on any + # replay until the caller issues a fresh operation id under V2 semantics. + var prior_record: Dictionary = next_idempotency[operation_id] + var legacy_results: Array[Dictionary] = [] + if prior_record[&"result"].has(&"legacy_ambiguous_results"): + legacy_results.assign(prior_record[&"result"][&"legacy_ambiguous_results"]) + else: + legacy_results.append({ + &"action": String(prior_record[&"action"]), + &"result": (prior_record[&"result"] as Dictionary).duplicate(true), + }) + legacy_results.append({ + &"action": String(action), + &"result": (raw_result[&"result"] as Dictionary).duplicate(true), + }) + next_idempotency[operation_id] = { + &"action": &"__legacy_ambiguous__", + &"input_fingerprint": "", + &"legacy_unbound": true, + &"result": {&"legacy_ambiguous_results": legacy_results}, + } + continue + next_idempotency[operation_id] = { + &"action": action, + &"input_fingerprint": input_fingerprint, + &"legacy_unbound": legacy_unbound, + &"result": (raw_result[&"result"] as Dictionary).duplicate(true), + } + + var next_stack_sequence := int(snapshot.get(&"next_stack_sequence", 0)) + var next_reservation_sequence := int(snapshot.get(&"next_reservation_sequence", 0)) + if next_stack_sequence < 1 or next_reservation_sequence < 1: + last_restore_error = "Inventory snapshot sequence is invalid." + return false + if version == LEGACY_SNAPSHOT_VERSION: + # V1 only persisted the next counters. Reconstruct the consumed canonical + # ranges once so the V2 migration cannot later reuse a deleted legacy id. + for sequence: int in range(1, next_stack_sequence): + next_known_stack_ids[StringName("stack_%08d" % sequence)] = true + for sequence: int in range(1, next_reservation_sequence): + next_known_reservation_ids[StringName("reservation_%08d" % sequence)] = true + if ( + next_stack_sequence <= _max_canonical_sequence(next_known_stack_ids.keys(), "stack_") + or next_reservation_sequence <= _max_canonical_sequence(next_known_reservation_ids.keys(), "reservation_") + ): + last_restore_error = "Inventory snapshot sequence would reuse a known canonical id." + return false + + _inventories = next_inventories + _rebuild_stack_location_index() + _reservations = next_reservations + _known_stack_ids = next_known_stack_ids + _known_reservation_ids = next_known_reservation_ids + _idempotency_results = next_idempotency + _next_stack_sequence = next_stack_sequence + _next_reservation_sequence = next_reservation_sequence + current_minute = next_current_minute + last_restore_error = "" + return true + + +func _validate_active_reservation(reservation: InventoryReservation, absolute_minute: int) -> Dictionary: + if reservation.is_expired(absolute_minute): + return _failure(&"RESERVATION_EXPIRED", "Reservation has expired.") + var location := _find_stack(reservation.stack_id) + if not bool(location[&"found"]): + return _failure(&"ORPHAN_RESERVATION", "Reserved stack is missing.") + var stack: Dictionary = location[&"stack"] + if location[&"owner_inventory_id"] != reservation.owner_inventory_id: + return _failure(&"ORPHAN_RESERVATION", "Reserved stack belongs to another inventory.") + if _stack_is_expired(stack, absolute_minute): + return _failure(&"STACK_EXPIRED", "Reserved stock has expired.") + if int(stack[&"quantity"]) < reservation.quantity or int(stack[&"reserved_quantity"]) < reservation.quantity: + return _failure(&"INVARIANT_VIOLATION", "Reserved stack no longer contains the reserved quantity.") + return {&"ok": true, &"location": location} + + +func _available_stacks(owner_inventory_id: StringName, item_id: StringName, absolute_minute: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if not _inventories.has(owner_inventory_id): + return result + for stack: Dictionary in (_inventories[owner_inventory_id] as Dictionary).values(): + if stack[&"item_id"] == item_id and not _stack_is_expired(stack, absolute_minute) and _available_in_stack(stack) > 0: + result.append(stack.duplicate(true)) + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_expiry := int(a[&"expires_at"]) + var b_expiry := int(b[&"expires_at"]) + var a_order := 9223372036854775807 if a_expiry < 0 else a_expiry + var b_order := 9223372036854775807 if b_expiry < 0 else b_expiry + if a_order != b_order: + return a_order < b_order + if int(a[&"quality"]) != int(b[&"quality"]): + return int(a[&"quality"]) > int(b[&"quality"]) + return String(a[&"stack_id"]) < String(b[&"stack_id"]) + ) + return result + + +func _find_stack(stack_id: StringName) -> Dictionary: + if _stack_owner_by_id.has(stack_id): + var indexed_owner_id: StringName = _stack_owner_by_id[stack_id] + if _inventories.has(indexed_owner_id): + var indexed_stacks: Dictionary = _inventories[indexed_owner_id] + if indexed_stacks.has(stack_id): + return { + &"found": true, + &"owner_inventory_id": indexed_owner_id, + &"stack": (indexed_stacks[stack_id] as Dictionary).duplicate(true), + } + # A stale derived entry must never mask an externally corrupted state. + _stack_owner_by_id.erase(stack_id) + for owner_inventory_id: Variant in _inventories: + var stacks: Dictionary = _inventories[owner_inventory_id] + if stacks.has(stack_id): + _stack_owner_by_id[stack_id] = StringName(owner_inventory_id) + return { + &"found": true, + &"owner_inventory_id": StringName(owner_inventory_id), + &"stack": (stacks[stack_id] as Dictionary).duplicate(true), + } + return {&"found": false, &"owner_inventory_id": &"", &"stack": {}} + + +func _insert_stack(stack: Dictionary) -> void: + var owner_inventory_id: StringName = stack[&"owner_inventory_id"] + var stack_id: StringName = stack[&"stack_id"] + if not _inventories.has(owner_inventory_id): + _inventories[owner_inventory_id] = {} + var stacks: Dictionary = _inventories[owner_inventory_id] + stacks[stack_id] = stack.duplicate(true) + _inventories[owner_inventory_id] = stacks + _stack_owner_by_id[stack_id] = owner_inventory_id + _known_stack_ids[stack_id] = true + var canonical_sequence := _canonical_sequence(stack_id, "stack_") + if canonical_sequence >= _next_stack_sequence: + _next_stack_sequence = canonical_sequence + 1 + + +func _set_stack(owner_inventory_id: StringName, stack: Dictionary) -> void: + if not _inventories.has(owner_inventory_id): + _inventories[owner_inventory_id] = {} + var stacks: Dictionary = _inventories[owner_inventory_id] + stacks[stack[&"stack_id"]] = stack.duplicate(true) + _inventories[owner_inventory_id] = stacks + _stack_owner_by_id[stack[&"stack_id"]] = owner_inventory_id + _known_stack_ids[stack[&"stack_id"]] = true + + +func _set_or_remove_stack(owner_inventory_id: StringName, stack: Dictionary) -> void: + if int(stack[&"quantity"]) > 0: + _set_stack(owner_inventory_id, stack) + return + if not _inventories.has(owner_inventory_id): + return + var stacks: Dictionary = _inventories[owner_inventory_id] + stacks.erase(stack[&"stack_id"]) + _stack_owner_by_id.erase(stack[&"stack_id"]) + if stacks.is_empty(): + _inventories.erase(owner_inventory_id) + else: + _inventories[owner_inventory_id] = stacks + + +func _restore_standalone_capture(capture: Dictionary) -> void: + if _atomic_batch_depth == 0 and not capture.is_empty(): + restore_snapshot(capture) + + +func _rebuild_stack_location_index() -> void: + _stack_owner_by_id.clear() + for owner_inventory_id: Variant in _inventories: + var stacks: Dictionary = _inventories[owner_inventory_id] + for stack_id: Variant in stacks: + _stack_owner_by_id[StringName(stack_id)] = StringName(owner_inventory_id) + + +func _next_stack_id() -> StringName: + while true: + var value := StringName("stack_%08d" % _next_stack_sequence) + _next_stack_sequence += 1 + # Generated ids are fully represented by the maintained index. Falling + # back to an all-inventory diagnostic scan here made every destination + # insertion O(owner count) during large delivery waves. + if not _known_stack_ids.has(value): + _known_stack_ids[value] = true + return value + return &"" + + +func _next_reservation_id() -> StringName: + while true: + var value := StringName("reservation_%08d" % _next_reservation_sequence) + _next_reservation_sequence += 1 + if not _known_reservation_ids.has(value): + _known_reservation_ids[value] = true + return value + return &"" + + +func _get_replay( + action: StringName, + operation_id: StringName, + input_fingerprint: String, + bind_legacy: bool = true +) -> Dictionary: + if operation_id == &"": + return {} + if not _idempotency_results.has(operation_id): + return {} + var record: Dictionary = _idempotency_results[operation_id] + if ( + StringName(record[&"action"]) != action + or ( + not bool(record.get(&"legacy_unbound", false)) + and String(record[&"input_fingerprint"]) != input_fingerprint + ) + ): + return _failure( + &"IDEMPOTENCY_CONFLICT", + "Operation id is already bound to a different action or normalized input.", + {&"operation_id": operation_id} + ) + if bool(record.get(&"legacy_unbound", false)) and bind_legacy: + # Version-1 snapshots did not retain request fingerprints. Preserve their + # first valid replay, then permanently bind that id to the observed input. + record[&"input_fingerprint"] = input_fingerprint + record[&"legacy_unbound"] = false + _idempotency_results[operation_id] = record + var result: Dictionary = (record[&"result"] as Dictionary).duplicate(true) + result[&"replay"] = true + return result + + +func _remember( + action: StringName, + operation_id: StringName, + result: Dictionary, + input_fingerprint: String +) -> Dictionary: + if operation_id != &"": + if _idempotency_results.has(operation_id): + return _get_replay(action, operation_id, input_fingerprint) + _idempotency_results[operation_id] = { + &"action": action, + &"input_fingerprint": input_fingerprint, + &"legacy_unbound": false, + &"result": result.duplicate(true), + } + return result + + +func _remember_failure( + action: StringName, + operation_id: StringName, + input_fingerprint: String, + code: StringName, + message: String, + details: Dictionary = {} +) -> Dictionary: + return _remember(action, operation_id, _failure(code, message, details), input_fingerprint) + + +func _preflight_available_batch(transfers: Array[Dictionary], minute: int) -> Dictionary: + var seen_operation_ids: Dictionary = {} + for index: int in range(transfers.size()): + var entry: Dictionary = transfers[index] + var operation_id := StringName(entry.get(&"operation_id", entry.get("operation_id", ""))) + var fingerprint := _transfer_fingerprint( + StringName(entry.get(&"source_inventory_id", entry.get("source_inventory_id", ""))), + StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))), + StringName(entry.get(&"item_id", entry.get("item_id", ""))), + int(entry.get(&"quantity", entry.get("quantity", 0))), + minute + ) + var validation := _preflight_operation_id( + &"transfer", operation_id, fingerprint, seen_operation_ids, index + ) + if not bool(validation[&"ok"]): + return validation + return {&"ok": true} + + +func _preflight_reserved_batch(transfers: Array[Dictionary], minute: int) -> Dictionary: + var seen_operation_ids: Dictionary = {} + for index: int in range(transfers.size()): + var entry: Dictionary = transfers[index] + var reservation_ids: Array[StringName] = [] + var raw_ids: Variant = entry.get(&"reservation_ids", entry.get("reservation_ids", [])) + if raw_ids is Array: + for raw_id: Variant in raw_ids: + reservation_ids.append(StringName(raw_id)) + var operation_id := StringName(entry.get(&"operation_id", entry.get("operation_id", ""))) + var fingerprint := _reserved_transfer_fingerprint( + _sorted_names(reservation_ids), + StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))), + minute + ) + var validation := _preflight_operation_id( + &"transfer_reserved", operation_id, fingerprint, seen_operation_ids, index + ) + if not bool(validation[&"ok"]): + return validation + return {&"ok": true} + + +func _preflight_operation_id( + action: StringName, + operation_id: StringName, + input_fingerprint: String, + seen_operation_ids: Dictionary, + index: int +) -> Dictionary: + if operation_id == &"": + return {&"ok": true} + if seen_operation_ids.has(operation_id): + return _failure( + &"IDEMPOTENCY_CONFLICT", + "A non-empty operation id may occur only once in an atomic batch.", + {&"operation_id": operation_id, &"failed_index": index} + ) + seen_operation_ids[operation_id] = true + var existing := _get_replay(action, operation_id, input_fingerprint, false) + if ( + not existing.is_empty() + and not bool(existing.get(&"ok", false)) + and existing.get(&"code", &"") == &"IDEMPOTENCY_CONFLICT" + ): + existing[&"failed_index"] = index + return existing + return {&"ok": true} + + +static func _transfer_fingerprint( + source_inventory_id: StringName, + destination_inventory_id: StringName, + item_id: StringName, + quantity: int, + minute: int +) -> String: + return _canonical_fingerprint([ + source_inventory_id, + destination_inventory_id, + item_id, + quantity, + minute, + ]) + + +static func _reserved_transfer_fingerprint( + reservation_ids: Array[StringName], + destination_inventory_id: StringName, + minute: int +) -> String: + return _canonical_fingerprint([reservation_ids, destination_inventory_id, minute]) + + +static func _canonical_fingerprint(values: Array) -> String: + var encoded := "" + for value: Variant in values: + var token := _canonical_fingerprint_token(value) + encoded += "%d:%s" % [token.length(), token] + return encoded + + +static func _canonical_fingerprint_token(value: Variant) -> String: + if value is Array: + var items := "" + for item: Variant in value: + var token := _canonical_fingerprint_token(item) + items += "%d:%s" % [token.length(), token] + return "a%d:%s" % [value.size(), items] + if value is int: + return "i:%d" % value + var text := String(value) + return "s%d:%s" % [text.length(), text] + + +static func _success(details: Dictionary = {}) -> Dictionary: + var result := {&"ok": true, &"code": &"OK", &"replay": false} + result.merge(details, true) + return result + + +static func _failure(code: StringName, message: String, details: Dictionary = {}) -> Dictionary: + var result := { + &"ok": false, + &"code": code, + &"message": message, + &"replay": false, + } + result.merge(details, true) + return result + + +static func _make_stack( + stack_id: StringName, + owner_inventory_id: StringName, + item_id: StringName, + quantity: int, + quality: int, + expires_at: int, + created_at: int, + unit_weight: int +) -> Dictionary: + return { + &"stack_id": stack_id, + &"owner_inventory_id": owner_inventory_id, + &"item_id": item_id, + &"quality": quality, + &"quantity": quantity, + &"reserved_quantity": 0, + &"created_at": maxi(created_at, 0), + &"expires_at": expires_at, + &"unit_weight": unit_weight, + } + + +static func _serialize_stack(stack: Dictionary) -> Dictionary: + var serialized := stack.duplicate(true) + serialized[&"stack_id"] = String(stack[&"stack_id"]) + serialized[&"owner_inventory_id"] = String(stack[&"owner_inventory_id"]) + serialized[&"item_id"] = String(stack[&"item_id"]) + return serialized + + +static func _parse_stack(raw_stack: Variant, expected_owner_id: StringName) -> Dictionary: + if not raw_stack is Dictionary: + return {} + var stack_id := StringName(raw_stack.get(&"stack_id", "")) + var owner_inventory_id := StringName(raw_stack.get(&"owner_inventory_id", "")) + var item_id := StringName(raw_stack.get(&"item_id", "")) + var quantity := int(raw_stack.get(&"quantity", -1)) + var reserved_quantity := int(raw_stack.get(&"reserved_quantity", -1)) + var unit_weight := int(raw_stack.get(&"unit_weight", 0)) + var created_at := int(raw_stack.get(&"created_at", -1)) + var expires_at := int(raw_stack.get(&"expires_at", -2)) + if ( + stack_id == &"" + or owner_inventory_id == &"" + or owner_inventory_id != expected_owner_id + or item_id == &"" + or quantity <= 0 + or reserved_quantity < 0 + or reserved_quantity > quantity + or unit_weight <= 0 + or created_at < 0 + or expires_at < -1 + ): + return {} + return { + &"stack_id": stack_id, + &"owner_inventory_id": owner_inventory_id, + &"item_id": item_id, + &"quality": int(raw_stack.get(&"quality", DEFAULT_QUALITY)), + &"quantity": quantity, + &"reserved_quantity": reserved_quantity, + &"created_at": created_at, + &"expires_at": expires_at, + &"unit_weight": unit_weight, + } + + +static func _validate_state(inventories: Dictionary, reservations: Dictionary) -> String: + var stack_locations: Dictionary = {} + for owner_inventory_id: Variant in inventories: + var stacks: Dictionary = inventories[owner_inventory_id] + for stack_id: Variant in stacks: + var stack: Dictionary = stacks[stack_id] + if stack_locations.has(stack_id): + return "Stack id is duplicated across inventories." + if int(stack[&"quantity"]) <= 0: + return "Empty stack remained in inventory state." + if int(stack[&"reserved_quantity"]) < 0 or int(stack[&"reserved_quantity"]) > int(stack[&"quantity"]): + return "Stack available quantity invariant is violated." + stack_locations[stack_id] = { + &"owner_inventory_id": StringName(owner_inventory_id), + &"stack": stack, + } + var expected_reserved: Dictionary = {} + for reservation_id: Variant in reservations: + var reservation: InventoryReservation = reservations[reservation_id] + if not reservation.is_active(): + continue + if not stack_locations.has(reservation.stack_id): + return "Active reservation refers to a missing stack." + var location: Dictionary = stack_locations[reservation.stack_id] + if location[&"owner_inventory_id"] != reservation.owner_inventory_id: + return "Active reservation owner does not match its stack." + var stack: Dictionary = location[&"stack"] + if stack[&"item_id"] != reservation.item_id: + return "Active reservation item does not match its stack." + expected_reserved[reservation.stack_id] = int(expected_reserved.get(reservation.stack_id, 0)) + reservation.quantity + for stack_id: Variant in stack_locations: + var stack: Dictionary = stack_locations[stack_id][&"stack"] + if int(stack[&"reserved_quantity"]) != int(expected_reserved.get(stack_id, 0)): + return "Stack reserved quantity does not match active reservations." + return "" + + +static func _available_in_stack(stack: Dictionary) -> int: + return int(stack[&"quantity"]) - int(stack[&"reserved_quantity"]) + + +static func _stack_is_expired(stack: Dictionary, absolute_minute: int) -> bool: + var expires_at := int(stack[&"expires_at"]) + return expires_at >= 0 and absolute_minute >= expires_at + + +static func _unique_sorted_names(values: Array[StringName]) -> Array[StringName]: + var seen: Dictionary = {} + var result: Array[StringName] = [] + for value: StringName in values: + if value == &"" or seen.has(value): + continue + seen[value] = true + result.append(value) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _sorted_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + for value: Variant in values: + result.append(StringName(value)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _string_array(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + + +static func _max_canonical_sequence(values: Variant, prefix: String) -> int: + var maximum := 0 + for value: Variant in values: + var sequence := _canonical_sequence(value, prefix) + if sequence > maximum: + maximum = sequence + return maximum + + +static func _canonical_sequence(value: Variant, prefix: String) -> int: + var text := String(value) + if not text.begins_with(prefix): + return 0 + var suffix := text.substr(prefix.length()) + if suffix.is_empty() or not suffix.is_valid_int(): + return 0 + return maxi(int(suffix), 0) + + +static func _reservation_timestamps_are_valid(snapshot: Dictionary) -> bool: + var created_at := int(snapshot.get(&"created_at", -1)) + var expires_at := int(snapshot.get(&"expires_at", -2)) + var terminal_at := int(snapshot.get(&"terminal_at", -2)) + var state_name := String(snapshot.get(&"state", "")) + if created_at < 0 or expires_at < -1 or terminal_at < -1: + return false + if state_name == "ACTIVE": + return terminal_at == -1 + return state_name in ["CONSUMED", "RELEASED", "EXPIRED"] and terminal_at >= 0 + + +static func _sum_lines(lines: Array[Dictionary]) -> int: + var total := 0 + for line: Dictionary in lines: + total += int(line.get(&"quantity", 0)) + return total diff --git a/scripts/inventory/inventory_service.gd.uid b/scripts/inventory/inventory_service.gd.uid new file mode 100644 index 0000000..ce54cc3 --- /dev/null +++ b/scripts/inventory/inventory_service.gd.uid @@ -0,0 +1 @@ +uid://kuhl1ypidibh diff --git a/scripts/inventory/logistics_dispatcher.gd b/scripts/inventory/logistics_dispatcher.gd new file mode 100644 index 0000000..1620a05 --- /dev/null +++ b/scripts/inventory/logistics_dispatcher.gd @@ -0,0 +1,481 @@ +class_name LogisticsDispatcher +extends RefCounted + +const LogisticsRequestType = preload("res://scripts/inventory/logistics_request.gd") + +const LEGACY_SNAPSHOT_VERSION := 1 +const SNAPSHOT_VERSION := 2 +const PRIORITY_MULTIPLIER := 10 +const PATH_MINUTE_PENALTY := 3 +const DETOUR_MINUTE_PENALTY := 2 +const DEADLINE_RISK_WINDOW := 120 + +var last_report: Dictionary = {} +var last_restore_error := "" +var restored_snapshot_version := SNAPSHOT_VERSION + +var _carrier_reserved_load: Dictionary = {} +var _assignment_ownership: Dictionary = {} +var _assignment_history: Array[Dictionary] = [] + + +func dispatch( + requests: Array, + carriers: Dictionary, + absolute_minute: int = 0 +) -> Array[Dictionary]: + var pending: Array[LogisticsRequest] = [] + for value: Variant in requests: + if value is LogisticsRequest and value.is_dispatchable(): + pending.append(value) + pending.sort_custom(func(a: LogisticsRequest, b: LogisticsRequest) -> bool: + if a.priority != b.priority: + return a.priority > b.priority + var a_deadline := 9223372036854775807 if a.deadline < 0 else a.deadline + var b_deadline := 9223372036854775807 if b.deadline < 0 else b.deadline + if a_deadline != b_deadline: + return a_deadline < b_deadline + if a.created_at != b.created_at: + return a.created_at < b.created_at + return String(a.request_id) < String(b.request_id) + ) + + var normalized_carriers := _normalize_carriers(carriers) + # Carrier order is invariant for this dispatch pass. Building and sorting it + # once avoids repeating the same allocation and lexical sort for every request. + var ordered_carrier_ids := _sorted_names(normalized_carriers.keys()) + # A carrier can disappear from one scheduler projection while its accepted + # assignment is still in flight. Keep those reservations until the owner + # explicitly releases them; otherwise reappearing carriers can be overbooked. + var reserved_loads: Dictionary = _carrier_reserved_load.duplicate(true) + var working_loads: Dictionary = {} + for carrier_id: StringName in ordered_carrier_ids: + var carrier: Dictionary = normalized_carriers[carrier_id] + working_loads[carrier_id] = maxi( + int(carrier[&"current_load"]), + int(_carrier_reserved_load.get(carrier_id, 0)) + ) + + var assignments: Array[Dictionary] = [] + var unassigned: Array[Dictionary] = [] + for request: LogisticsRequest in pending: + if _assignment_ownership.has(request.request_id): + unassigned.append({ + &"request_id": request.request_id, + &"reason": &"REQUEST_ALREADY_OWNED", + }) + continue + var best: Dictionary = {} + var deadline_score := _deadline_risk(request.deadline, absolute_minute) + for carrier_id: StringName in ordered_carrier_ids: + var carrier: Dictionary = normalized_carriers[carrier_id] + var current_load := int(working_loads[carrier_id]) + if current_load + request.total_weight > int(carrier[&"capacity"]): + continue + var path_minutes := _path_minutes_for(carrier, request) + if path_minutes < 0: + continue + # Keep score_candidate as the public single-candidate API, but avoid a + # second path lookup while evaluating the same pair in a dispatch batch. + var score := ( + request.priority * PRIORITY_MULTIPLIER + - path_minutes * PATH_MINUTE_PENALTY + - int(carrier[&"detour_minutes"]) * DETOUR_MINUTE_PENALTY + + _role_fitness(carrier, request) + + deadline_score + ) + if ( + best.is_empty() + or score > int(best[&"score"]) + or (score == int(best[&"score"]) and String(carrier_id) < String(best[&"carrier_id"])) + ): + best = { + &"carrier_id": carrier_id, + &"score": score, + &"path_minutes": path_minutes, + &"position": carrier[&"position"], + } + if best.is_empty(): + unassigned.append({ + &"request_id": request.request_id, + &"reason": &"NO_ELIGIBLE_CARRIER", + }) + continue + var selected_carrier_id: StringName = best[&"carrier_id"] + if not request.assign(selected_carrier_id, absolute_minute): + unassigned.append({ + &"request_id": request.request_id, + &"reason": &"STATE_CHANGED", + }) + continue + working_loads[selected_carrier_id] = int(working_loads[selected_carrier_id]) + request.total_weight + reserved_loads[selected_carrier_id] = ( + int(reserved_loads.get(selected_carrier_id, 0)) + request.total_weight + ) + var assignment := { + &"request_id": request.request_id, + &"carrier_id": selected_carrier_id, + &"score": int(best[&"score"]), + &"path_minutes": int(best[&"path_minutes"]), + &"weight": request.total_weight, + &"assigned_at": absolute_minute, + } + assignments.append(assignment) + _assignment_history.append(assignment.duplicate(true)) + _assignment_ownership[request.request_id] = assignment.duplicate(true) + + _carrier_reserved_load = reserved_loads + last_report = { + &"assigned": assignments.duplicate(true), + &"unassigned": unassigned, + &"carrier_loads": _carrier_reserved_load.duplicate(true), + } + return assignments + + +func score_candidate( + request: LogisticsRequest, + carrier: Dictionary, + absolute_minute: int +) -> int: + var path_minutes := _path_minutes_for(carrier, request) + if path_minutes < 0: + return -2_147_483_648 + return ( + request.priority * PRIORITY_MULTIPLIER + - path_minutes * PATH_MINUTE_PENALTY + - int(carrier.get(&"detour_minutes", 0)) * DETOUR_MINUTE_PENALTY + + _role_fitness(carrier, request) + + _deadline_risk(request.deadline, absolute_minute) + ) + + +func release_assignment(carrier_id: StringName, weight: int) -> int: + var candidates: Array[StringName] = [] + for request_id: StringName in _sorted_names(_assignment_ownership.keys()): + var ownership: Dictionary = _assignment_ownership[request_id] + if ownership[&"carrier_id"] == carrier_id and int(ownership[&"weight"]) == weight: + candidates.append(request_id) + if candidates.size() == 1: + release_assignment_for_request(candidates[0], carrier_id, weight) + return get_reserved_load(carrier_id) + if candidates.size() > 1 or restored_snapshot_version != LEGACY_SNAPSHOT_VERSION: + return get_reserved_load(carrier_id) + var next_load := maxi(int(_carrier_reserved_load.get(carrier_id, 0)) - maxi(weight, 0), 0) + if next_load == 0: + _carrier_reserved_load.erase(carrier_id) + else: + _carrier_reserved_load[carrier_id] = next_load + return next_load + + +func release_assignment_for_request( + request_id: StringName, + carrier_id: StringName, + weight: int +) -> bool: + if request_id.is_empty() or carrier_id.is_empty() or weight <= 0 \ + or not _assignment_ownership.has(request_id): + return false + var ownership: Dictionary = _assignment_ownership[request_id] + if ownership[&"carrier_id"] != carrier_id or int(ownership[&"weight"]) != weight: + return false + var current_load := int(_carrier_reserved_load.get(carrier_id, 0)) + if current_load < weight: + return false + var next_load := current_load - weight + if next_load == 0: + _carrier_reserved_load.erase(carrier_id) + else: + _carrier_reserved_load[carrier_id] = next_load + _assignment_ownership.erase(request_id) + return true + + +func get_reserved_load(carrier_id: StringName) -> int: + return int(_carrier_reserved_load.get(carrier_id, 0)) + + +func get_assignment_ownership() -> Dictionary: + var result: Dictionary = {} + for request_id: StringName in _sorted_names(_assignment_ownership.keys()): + result[request_id] = (_assignment_ownership[request_id] as Dictionary).duplicate(true) + return result + + +func get_assignment_history() -> Array[Dictionary]: + return _assignment_history.duplicate(true) + + +func clear_runtime_loads() -> void: + _carrier_reserved_load.clear() + _assignment_ownership.clear() + + +func create_snapshot() -> Dictionary: + var carrier_loads: Array[Dictionary] = [] + for carrier_id: StringName in _sorted_names(_carrier_reserved_load.keys()): + carrier_loads.append({ + &"carrier_id": String(carrier_id), + &"reserved_load": int(_carrier_reserved_load[carrier_id]), + }) + var history: Array[Dictionary] = [] + for assignment: Dictionary in _assignment_history: + var serialized := assignment.duplicate(true) + serialized[&"request_id"] = String(assignment[&"request_id"]) + serialized[&"carrier_id"] = String(assignment[&"carrier_id"]) + history.append(serialized) + var ownership: Array[Dictionary] = [] + for request_id: StringName in _sorted_names(_assignment_ownership.keys()): + var entry: Dictionary = _assignment_ownership[request_id] + ownership.append({ + &"request_id": String(request_id), + &"carrier_id": String(entry[&"carrier_id"]), + &"weight": int(entry[&"weight"]), + &"assigned_at": int(entry[&"assigned_at"]), + }) + return { + &"version": SNAPSHOT_VERSION, + &"carrier_loads": carrier_loads, + &"assignment_ownership": ownership, + &"assignment_history": history, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + last_restore_error = "" + var version := int(snapshot.get(&"version", -1)) + if version not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + last_restore_error = "Unsupported logistics dispatcher snapshot version." + return false + var raw_loads: Variant = snapshot.get(&"carrier_loads", null) + var raw_history: Variant = snapshot.get(&"assignment_history", null) + var raw_ownership: Variant = snapshot.get(&"assignment_ownership", null) + if not raw_loads is Array or not raw_history is Array \ + or (version == SNAPSHOT_VERSION and not raw_ownership is Array): + last_restore_error = "Logistics dispatcher snapshot collections are malformed." + return false + + var next_loads: Dictionary = {} + for raw_load: Variant in raw_loads: + if not raw_load is Dictionary: + last_restore_error = "Carrier load entry must be a Dictionary." + return false + var carrier_id := StringName(raw_load.get(&"carrier_id", "")) + var reserved_load := int(raw_load.get(&"reserved_load", -1)) + if carrier_id == &"" or reserved_load < 0 or next_loads.has(carrier_id) \ + or (version == SNAPSHOT_VERSION and reserved_load == 0): + last_restore_error = "Carrier load entry is invalid or duplicated." + return false + next_loads[carrier_id] = reserved_load + + var next_history: Array[Dictionary] = [] + var history_fingerprints: Dictionary = {} + for raw_assignment: Variant in raw_history: + if not raw_assignment is Dictionary: + last_restore_error = "Assignment history entry must be a Dictionary." + return false + var request_id := StringName(raw_assignment.get(&"request_id", "")) + var carrier_id := StringName(raw_assignment.get(&"carrier_id", "")) + var weight_value: Variant = raw_assignment.get(&"weight", null) + var assigned_at_value: Variant = raw_assignment.get(&"assigned_at", null) + if request_id == &"" or carrier_id == &"" or not weight_value is int \ + or int(weight_value) < (1 if version == SNAPSHOT_VERSION else 0) \ + or (version == SNAPSHOT_VERSION and ( + not assigned_at_value is int or int(assigned_at_value) < 0 + )): + last_restore_error = "Assignment history entry is invalid." + return false + var assignment: Dictionary = raw_assignment.duplicate(true) + assignment[&"request_id"] = request_id + assignment[&"carrier_id"] = carrier_id + var fingerprint := "%s|%s|%d|%d" % [ + request_id, + carrier_id, + int(weight_value), + int(assigned_at_value) if assigned_at_value is int else -1, + ] + if version == SNAPSHOT_VERSION and history_fingerprints.has(fingerprint): + last_restore_error = "Assignment history contains an exact duplicate." + return false + history_fingerprints[fingerprint] = true + next_history.append(assignment) + + var next_ownership: Dictionary = {} + if version == SNAPSHOT_VERSION: + for raw_entry: Variant in raw_ownership: + if not raw_entry is Dictionary: + last_restore_error = "Assignment ownership entry must be a Dictionary." + return false + var request_id := StringName(raw_entry.get(&"request_id", "")) + var carrier_id := StringName(raw_entry.get(&"carrier_id", "")) + var weight_value: Variant = raw_entry.get(&"weight", null) + var assigned_at_value: Variant = raw_entry.get(&"assigned_at", null) + if request_id.is_empty() or carrier_id.is_empty() or next_ownership.has(request_id) \ + or not weight_value is int or int(weight_value) <= 0 \ + or not assigned_at_value is int or int(assigned_at_value) < 0: + last_restore_error = "Assignment ownership entry is invalid or duplicated." + return false + next_ownership[request_id] = { + &"request_id": request_id, + &"carrier_id": carrier_id, + &"weight": int(weight_value), + &"assigned_at": int(assigned_at_value), + } + if not _ownership_matches_loads_and_history(next_ownership, next_loads, next_history): + last_restore_error = "Assignment ownership does not match reserved loads or history." + return false + + _carrier_reserved_load = next_loads + _assignment_ownership = next_ownership + _assignment_history = next_history + restored_snapshot_version = version + last_restore_error = "" + return true + + +func migrate_legacy_assignment_ownership(expected: Dictionary) -> bool: + if restored_snapshot_version != LEGACY_SNAPSHOT_VERSION or not _assignment_ownership.is_empty(): + return false + var normalized: Dictionary = {} + for request_id: StringName in _sorted_names(expected.keys()): + var raw_entry: Variant = expected[request_id] + if request_id.is_empty() or not raw_entry is Dictionary: + return false + var carrier_id := StringName(raw_entry.get(&"carrier_id", &"")) + var weight := int(raw_entry.get(&"weight", -1)) + var assigned_at := int(raw_entry.get(&"assigned_at", -1)) + if carrier_id.is_empty() or weight <= 0 or assigned_at < 0: + return false + normalized[request_id] = { + &"request_id": request_id, + &"carrier_id": carrier_id, + &"weight": weight, + &"assigned_at": assigned_at, + } + var rebuilt_loads: Dictionary = {} + for request_id: StringName in _sorted_names(normalized.keys()): + var entry: Dictionary = normalized[request_id] + var carrier_id: StringName = entry[&"carrier_id"] + rebuilt_loads[carrier_id] = int(rebuilt_loads.get(carrier_id, 0)) + int(entry[&"weight"]) + if int(_carrier_reserved_load.get(carrier_id, -1)) < int(rebuilt_loads[carrier_id]): + return false + # v1 mixed external carrier.current_load into this table. Active Requests plus + # assignment history are the only durable ownership authority during migration; + # any excess legacy load is therefore discarded deterministically. + if not _ownership_matches_loads_and_history( + normalized, + rebuilt_loads, + _assignment_history + ): + return false + _carrier_reserved_load = rebuilt_loads + _assignment_ownership = normalized + restored_snapshot_version = SNAPSHOT_VERSION + return true + + +static func _ownership_matches_loads_and_history( + ownership: Dictionary, + loads: Dictionary, + history: Array[Dictionary] +) -> bool: + var expected_loads: Dictionary = {} + var latest_by_request: Dictionary = {} + for assignment: Dictionary in history: + latest_by_request[StringName(assignment[&"request_id"])] = assignment + for request_id: StringName in _sorted_names(ownership.keys()): + var entry: Dictionary = ownership[request_id] + var carrier_id: StringName = entry[&"carrier_id"] + var weight := int(entry[&"weight"]) + expected_loads[carrier_id] = int(expected_loads.get(carrier_id, 0)) + weight + if not latest_by_request.has(request_id): + return false + var latest: Dictionary = latest_by_request[request_id] + if StringName(latest[&"carrier_id"]) != carrier_id \ + or int(latest[&"weight"]) != weight \ + or int(latest.get(&"assigned_at", -1)) != int(entry[&"assigned_at"]): + return false + if expected_loads.size() != loads.size(): + return false + for carrier_id: StringName in _sorted_names(expected_loads.keys()): + if int(loads.get(carrier_id, -1)) != int(expected_loads[carrier_id]): + return false + return true + + +static func _normalize_carriers(carriers: Dictionary) -> Dictionary: + var result: Dictionary = {} + for raw_carrier_id: Variant in carriers: + if not carriers[raw_carrier_id] is Dictionary: + continue + var carrier_id := StringName(raw_carrier_id) + var source: Dictionary = carriers[raw_carrier_id] + var capacity := int(source.get(&"capacity", source.get(&"capacity_weight", 0))) + var current_load := maxi(int(source.get(&"current_load", 0)), 0) + if carrier_id == &"" or capacity <= 0 or current_load > capacity: + continue + result[carrier_id] = { + &"carrier_id": carrier_id, + &"position": source.get(&"position", Vector3i.ZERO), + &"capacity": capacity, + &"current_load": current_load, + &"role": StringName(source.get(&"role", &"")), + &"role_fit": source.get(&"role_fit", 0), + &"path_minutes": source.get(&"path_minutes", -1), + &"detour_minutes": maxi(int(source.get(&"detour_minutes", 0)), 0), + } + return result + + +static func _path_minutes_for(carrier: Dictionary, request: LogisticsRequest) -> int: + var path_source: Variant = carrier.get(&"path_minutes", -1) + if path_source is int or path_source is float: + return int(path_source) + if not path_source is Dictionary: + return -1 + var paths: Dictionary = path_source + for key: Variant in [request.request_id, request.source_inventory_id, &"default", "default"]: + if paths.has(key): + return int(paths[key]) + return -1 + + +static func _role_fitness(carrier: Dictionary, request: LogisticsRequest) -> int: + var explicit_fit: Variant = carrier.get(&"role_fit", 0) + if explicit_fit is Dictionary: + var fits: Dictionary = explicit_fit + if fits.has(request.request_id): + return int(fits[request.request_id]) + if fits.has(&"default"): + return int(fits[&"default"]) + elif explicit_fit is int or explicit_fit is float: + var fit := int(explicit_fit) + if fit != 0: + return fit + match String(carrier.get(&"role", &"")).to_lower(): + "logistics": + return 100 + "porter": + return 80 + "carrier": + return 60 + _: + return 0 + + +static func _deadline_risk(deadline: int, absolute_minute: int) -> int: + if deadline < 0: + return 0 + var remaining := deadline - absolute_minute + if remaining <= 0: + return 200 + mini(-remaining, 100) + return maxi(DEADLINE_RISK_WINDOW - remaining, 0) + + +static func _sorted_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + for value: Variant in values: + result.append(StringName(value)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result diff --git a/scripts/inventory/logistics_dispatcher.gd.uid b/scripts/inventory/logistics_dispatcher.gd.uid new file mode 100644 index 0000000..1fa634a --- /dev/null +++ b/scripts/inventory/logistics_dispatcher.gd.uid @@ -0,0 +1 @@ +uid://bakay5aor6q03 diff --git a/scripts/inventory/logistics_request.gd b/scripts/inventory/logistics_request.gd new file mode 100644 index 0000000..e373a96 --- /dev/null +++ b/scripts/inventory/logistics_request.gd @@ -0,0 +1,222 @@ +class_name LogisticsRequest +extends RefCounted + +enum State { + OPEN, + RESERVED, + ASSIGNED, + PICKING_UP, + DELIVERING, + COMPLETED, + BLOCKED, + CANCELLED, +} + +const MAX_PRIORITY := 100 + +var request_id: StringName = &"" +var source_inventory_id: StringName = &"" +var destination_inventory_id: StringName = &"" +var item_requirements: Dictionary = {} +var priority := 0 +var created_at := 0 +var deadline := -1 +var total_weight := 0 +var reserved_stack_ids: Array[StringName] = [] +var reservation_ids: Array[StringName] = [] +var assigned_carrier_id: StringName = &"" +var state: State = State.OPEN +var retry_count := 0 +var state_reason := "" +var state_changed_at := 0 + + +func configure( + p_request_id: StringName, + p_source_inventory_id: StringName, + p_destination_inventory_id: StringName, + p_item_requirements: Dictionary, + p_priority: int, + p_created_at: int, + p_deadline: int, + p_total_weight: int +) -> LogisticsRequest: + request_id = p_request_id + source_inventory_id = p_source_inventory_id + destination_inventory_id = p_destination_inventory_id + item_requirements = _normalize_requirements(p_item_requirements) + priority = clampi(p_priority, 0, MAX_PRIORITY) + created_at = maxi(p_created_at, 0) + deadline = p_deadline + total_weight = maxi(p_total_weight, 0) + state_changed_at = created_at + return self + + +func mark_reserved( + p_reservation_ids: Array[StringName], + p_reserved_stack_ids: Array[StringName] = [], + absolute_minute: int = -1 +) -> bool: + if state != State.OPEN and state != State.BLOCKED: + return false + if p_reservation_ids.is_empty(): + return false + reservation_ids.assign(p_reservation_ids) + reserved_stack_ids.assign(p_reserved_stack_ids) + assigned_carrier_id = &"" + return transition_to(State.RESERVED, "", absolute_minute) + + +func assign(carrier_id: StringName, absolute_minute: int = -1) -> bool: + if carrier_id == &"" or state != State.RESERVED: + return false + assigned_carrier_id = carrier_id + if transition_to(State.ASSIGNED, "", absolute_minute): + return true + assigned_carrier_id = &"" + return false + + +func unassign(absolute_minute: int = -1) -> bool: + if state != State.ASSIGNED: + return false + assigned_carrier_id = &"" + return transition_to(State.RESERVED, "", absolute_minute) + + +func mark_blocked(reason: String, absolute_minute: int = -1) -> bool: + if state == State.COMPLETED or state == State.CANCELLED: + return false + retry_count += 1 + assigned_carrier_id = &"" + return transition_to(State.BLOCKED, reason, absolute_minute) + + +func cancel(reason: String = "", absolute_minute: int = -1) -> bool: + return transition_to(State.CANCELLED, reason, absolute_minute) + + +func transition_to(next_state: State, reason: String = "", absolute_minute: int = -1) -> bool: + if state == next_state: + return true + if not _can_transition(state, next_state): + return false + state = next_state + state_reason = reason + state_changed_at = created_at if absolute_minute < 0 else absolute_minute + if state == State.COMPLETED or state == State.CANCELLED: + assigned_carrier_id = &"" + return true + + +func is_terminal() -> bool: + return state == State.COMPLETED or state == State.CANCELLED + + +func is_dispatchable() -> bool: + return state == State.RESERVED and assigned_carrier_id == &"" and total_weight > 0 + + +func create_snapshot() -> Dictionary: + return { + &"request_id": String(request_id), + &"source_inventory_id": String(source_inventory_id), + &"destination_inventory_id": String(destination_inventory_id), + &"item_requirements": item_requirements.duplicate(true), + &"priority": priority, + &"created_at": created_at, + &"deadline": deadline, + &"total_weight": total_weight, + &"reserved_stack_ids": _names_to_strings(reserved_stack_ids), + &"reservation_ids": _names_to_strings(reservation_ids), + &"assigned_carrier_id": String(assigned_carrier_id), + &"state": State.keys()[state], + &"retry_count": retry_count, + &"state_reason": state_reason, + &"state_changed_at": state_changed_at, + } + + +static func from_snapshot(snapshot: Dictionary) -> LogisticsRequest: + if not _is_valid_snapshot(snapshot): + return null + var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN"))) + if state_index < 0: + return null + var request := LogisticsRequest.new().configure( + StringName(snapshot.get(&"request_id", "")), + StringName(snapshot.get(&"source_inventory_id", "")), + StringName(snapshot.get(&"destination_inventory_id", "")), + snapshot.get(&"item_requirements", {}), + int(snapshot.get(&"priority", 0)), + int(snapshot.get(&"created_at", 0)), + int(snapshot.get(&"deadline", -1)), + int(snapshot.get(&"total_weight", 0)) + ) + request.reserved_stack_ids = _parse_names(snapshot.get(&"reserved_stack_ids", [])) + request.reservation_ids = _parse_names(snapshot.get(&"reservation_ids", [])) + request.assigned_carrier_id = StringName(snapshot.get(&"assigned_carrier_id", "")) + request.state = state_index as State + request.retry_count = maxi(int(snapshot.get(&"retry_count", 0)), 0) + request.state_reason = String(snapshot.get(&"state_reason", "")) + request.state_changed_at = int(snapshot.get(&"state_changed_at", request.created_at)) + if request.state == State.ASSIGNED and request.assigned_carrier_id == &"": + return null + return request + + +static func _can_transition(from_state: State, to_state: State) -> bool: + match from_state: + State.OPEN: + return to_state in [State.RESERVED, State.BLOCKED, State.CANCELLED] + State.RESERVED: + return to_state in [State.OPEN, State.ASSIGNED, State.BLOCKED, State.CANCELLED] + State.ASSIGNED: + return to_state in [State.RESERVED, State.PICKING_UP, State.BLOCKED, State.CANCELLED] + State.PICKING_UP: + return to_state in [State.DELIVERING, State.BLOCKED, State.CANCELLED] + State.DELIVERING: + return to_state in [State.COMPLETED, State.BLOCKED, State.CANCELLED] + State.BLOCKED: + return to_state in [State.OPEN, State.RESERVED, State.CANCELLED] + _: + return false + + +static func _normalize_requirements(source: Variant) -> Dictionary: + var normalized: Dictionary = {} + if not source is Dictionary: + return normalized + for raw_item_id: Variant in source: + var item_id := StringName(raw_item_id) + var quantity := int(source[raw_item_id]) + if item_id != &"" and quantity > 0: + normalized[item_id] = quantity + return normalized + + +static func _is_valid_snapshot(snapshot: Dictionary) -> bool: + return ( + not String(snapshot.get(&"request_id", "")).is_empty() + and not String(snapshot.get(&"source_inventory_id", "")).is_empty() + and not String(snapshot.get(&"destination_inventory_id", "")).is_empty() + and snapshot.get(&"item_requirements", null) is Dictionary + and int(snapshot.get(&"total_weight", -1)) >= 0 + ) + + +static func _parse_names(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for value: Variant in source: + result.append(StringName(value)) + return result + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result diff --git a/scripts/inventory/logistics_request.gd.uid b/scripts/inventory/logistics_request.gd.uid new file mode 100644 index 0000000..1658c06 --- /dev/null +++ b/scripts/inventory/logistics_request.gd.uid @@ -0,0 +1 @@ +uid://bny06mspvcg8u diff --git a/scripts/mana/mana_network_service.gd b/scripts/mana/mana_network_service.gd new file mode 100644 index 0000000..e256c25 --- /dev/null +++ b/scripts/mana/mana_network_service.gd @@ -0,0 +1,574 @@ +class_name ManaNetworkService +extends RefCounted + +## Deterministic Mana graph, allocation, storage and brownout simulation. +## +## The graph is intentionally separate from the walking graph. Authoritative +## values are integers so save/load and repeated simulations produce the same +## result on every platform. + +signal consumer_state_changed(consumer_id: StringName, previous_state: int, next_state: int, reasons: Array[StringName]) + +enum NodeKind { + SOURCE, + CONSUMER, + CONNECTOR, + STORAGE, +} + +enum ConsumerPriority { + LUXURY = 0, + NORMAL = 50, + ESSENTIAL = 100, +} + +enum ConsumerState { + ACTIVE, + DEGRADED, + NO_POWER, + DISABLED, +} + +const SNAPSHOT_VERSION := 1 +const EVALUATION_INTERVAL_MINUTES := 10 +const MINIMUM_OFF_HOLD_MINUTES := 10 +const DEGRADED_THRESHOLD_PERCENT := 75 + +var revision := 0 +var last_evaluation_minute := -1 +var last_error := "" + +var _nodes: Dictionary = {} +var _adjacency: Dictionary = {} +var _last_report: Dictionary = {} + + +func register_source(node_id: StringName, output: int, enabled := true) -> bool: + if output < 0: + return _fail("Source output cannot be negative.") + return _register_node(node_id, NodeKind.SOURCE, { + &"output": output, + &"enabled": enabled, + }) + + +func register_consumer( + node_id: StringName, + demand: int, + priority := ConsumerPriority.NORMAL, + enabled := true +) -> bool: + if demand < 0: + return _fail("Consumer demand cannot be negative.") + return _register_node(node_id, NodeKind.CONSUMER, { + &"demand": demand, + &"priority": priority, + &"enabled": enabled, + &"allocated": 0, + &"state": ConsumerState.NO_POWER if enabled else ConsumerState.DISABLED, + &"state_reasons": [&"awaiting_evaluation"] if enabled else [&"disabled"], + &"hold_until": 0, + }) + + +func register_connector(node_id: StringName, enabled := true) -> bool: + return _register_node(node_id, NodeKind.CONNECTOR, {&"enabled": enabled}) + + +func register_storage( + node_id: StringName, + capacity: int, + current: int, + max_flow: int, + enabled := true +) -> bool: + if capacity < 0 or current < 0 or current > capacity or max_flow < 0: + return _fail("Storage values must satisfy 0 <= current <= capacity and max_flow >= 0.") + return _register_node(node_id, NodeKind.STORAGE, { + &"capacity": capacity, + &"current": current, + &"max_flow": max_flow, + &"enabled": enabled, + }) + + +func unregister_node(node_id: StringName) -> bool: + if not _nodes.has(node_id): + return _fail("Unknown Mana node: %s" % node_id) + var neighbors: Array = _adjacency.get(node_id, {}).keys() + for neighbor: Variant in neighbors: + _adjacency[neighbor].erase(node_id) + _adjacency.erase(node_id) + _nodes.erase(node_id) + revision += 1 + last_error = "" + return true + + +func connect_nodes(first_id: StringName, second_id: StringName) -> bool: + if first_id == second_id: + return _fail("A Mana node cannot connect to itself.") + if not _nodes.has(first_id) or not _nodes.has(second_id): + return _fail("Both Mana nodes must exist before they are connected.") + if _adjacency[first_id].has(second_id): + last_error = "" + return true + _adjacency[first_id][second_id] = true + _adjacency[second_id][first_id] = true + revision += 1 + last_error = "" + return true + + +func disconnect_nodes(first_id: StringName, second_id: StringName) -> bool: + if not _adjacency.has(first_id) or not _adjacency[first_id].has(second_id): + return _fail("Mana edge does not exist.") + _adjacency[first_id].erase(second_id) + _adjacency[second_id].erase(first_id) + revision += 1 + last_error = "" + return true + + +func set_node_enabled(node_id: StringName, enabled: bool) -> bool: + if not _nodes.has(node_id): + return _fail("Unknown Mana node: %s" % node_id) + var node: Dictionary = _nodes[node_id] + if bool(node[&"enabled"]) == enabled: + last_error = "" + return true + node[&"enabled"] = enabled + if int(node[&"kind"]) == NodeKind.CONSUMER and not enabled: + node[&"allocated"] = 0 + _set_consumer_state(node_id, node, ConsumerState.DISABLED, [&"disabled"]) + _nodes[node_id] = node + revision += 1 + last_error = "" + return true + + +func set_source_output(node_id: StringName, output: int) -> bool: + if output < 0 or not _is_kind(node_id, NodeKind.SOURCE): + return _fail("A valid source and non-negative output are required.") + var node: Dictionary = _nodes[node_id] + node[&"output"] = output + _nodes[node_id] = node + revision += 1 + last_error = "" + return true + + +func set_consumer_demand(node_id: StringName, demand: int) -> bool: + if demand < 0 or not _is_kind(node_id, NodeKind.CONSUMER): + return _fail("A valid consumer and non-negative demand are required.") + var node: Dictionary = _nodes[node_id] + node[&"demand"] = demand + _nodes[node_id] = node + revision += 1 + last_error = "" + return true + + +func set_consumer_priority(node_id: StringName, priority: int) -> bool: + if not _is_kind(node_id, NodeKind.CONSUMER): + return _fail("A valid consumer is required.") + var node: Dictionary = _nodes[node_id] + node[&"priority"] = priority + _nodes[node_id] = node + revision += 1 + last_error = "" + return true + + +func advance_one_minute(absolute_minute: int) -> Dictionary: + if absolute_minute < 0: + return {&"evaluated": false, &"error": "Minute cannot be negative."} + if absolute_minute % EVALUATION_INTERVAL_MINUTES != 0 \ + or absolute_minute == last_evaluation_minute: + return {&"evaluated": false, &"minute": absolute_minute} + return evaluate_now(absolute_minute) + + +func evaluate_now(absolute_minute: int) -> Dictionary: + if absolute_minute < 0: + return {&"evaluated": false, &"error": "Minute cannot be negative."} + var changes: Array = [] + var component_reports: Array = [] + for component: Array[StringName] in _connected_components(): + component_reports.append(_evaluate_component(component, absolute_minute, changes)) + last_evaluation_minute = absolute_minute + _last_report = { + &"evaluated": true, + &"minute": absolute_minute, + &"components": component_reports, + &"state_changes": changes, + } + last_error = "" + return _last_report.duplicate(true) + + +func get_node(node_id: StringName) -> Dictionary: + if not _nodes.has(node_id): + return {} + return (_nodes[node_id] as Dictionary).duplicate(true) + + +func get_consumer_state(node_id: StringName) -> int: + if not _is_kind(node_id, NodeKind.CONSUMER): + return -1 + return int(_nodes[node_id][&"state"]) + + +func get_allocated_mana(node_id: StringName) -> int: + if not _is_kind(node_id, NodeKind.CONSUMER): + return 0 + return int(_nodes[node_id][&"allocated"]) + + +func get_storage_current(node_id: StringName) -> int: + if not _is_kind(node_id, NodeKind.STORAGE): + return 0 + return int(_nodes[node_id][&"current"]) + + +func get_last_report() -> Dictionary: + return _last_report.duplicate(true) + + +func node_count() -> int: + return _nodes.size() + + +func edge_count() -> int: + var doubled := 0 + for neighbors: Variant in _adjacency.values(): + doubled += (neighbors as Dictionary).size() + return doubled / 2 + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + for node_id: Variant in _sorted_ids(_nodes.keys()): + var node: Dictionary = _nodes[node_id] + if not _adjacency.has(node_id): + errors.append("Node %s has no adjacency entry." % node_id) + if int(node[&"kind"]) == NodeKind.STORAGE: + var current := int(node[&"current"]) + var capacity := int(node[&"capacity"]) + if current < 0 or current > capacity: + errors.append("Storage %s is outside its capacity." % node_id) + if int(node[&"kind"]) == NodeKind.CONSUMER: + var allocation := int(node[&"allocated"]) + if allocation < 0 or allocation > int(node[&"demand"]): + errors.append("Consumer %s has an invalid allocation." % node_id) + for first_id: Variant in _adjacency: + for second_id: Variant in _adjacency[first_id]: + if not _adjacency.has(second_id) or not _adjacency[second_id].has(first_id): + errors.append("Edge %s-%s is not symmetric." % [first_id, second_id]) + return {&"valid": errors.is_empty(), &"errors": errors} + + +func create_snapshot() -> Dictionary: + var serialized_nodes: Array = [] + for node_id: StringName in _sorted_ids(_nodes.keys()): + var node: Dictionary = (_nodes[node_id] as Dictionary).duplicate(true) + node[&"id"] = String(node_id) + var serialized_reasons: Array[String] = [] + for reason: Variant in node.get(&"state_reasons", []): + serialized_reasons.append(String(reason)) + if node.has(&"state_reasons"): + node[&"state_reasons"] = serialized_reasons + serialized_nodes.append(node) + + var edges: Array = [] + for first_id: StringName in _sorted_ids(_adjacency.keys()): + for second_id: StringName in _sorted_ids(_adjacency[first_id].keys()): + if String(first_id) < String(second_id): + edges.append([String(first_id), String(second_id)]) + return { + &"version": SNAPSHOT_VERSION, + &"revision": revision, + &"last_evaluation_minute": last_evaluation_minute, + &"nodes": serialized_nodes, + &"edges": edges, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if int(snapshot.get(&"version", 0)) != SNAPSHOT_VERSION: + return _fail("Unsupported Mana snapshot version.") + var raw_nodes: Variant = snapshot.get(&"nodes", null) + var raw_edges: Variant = snapshot.get(&"edges", null) + if not raw_nodes is Array or not raw_edges is Array: + return _fail("Mana snapshot nodes and edges must be Arrays.") + + var next_nodes: Dictionary = {} + var next_adjacency: Dictionary = {} + for raw_node: Variant in raw_nodes: + var normalization := _normalize_saved_node(raw_node) + if not normalization[&"ok"]: + return _fail(normalization[&"error"]) + var node: Dictionary = normalization[&"node"] + var node_id: StringName = node[&"id"] + if next_nodes.has(node_id): + return _fail("Duplicate Mana node in snapshot: %s" % node_id) + next_nodes[node_id] = node + next_adjacency[node_id] = {} + + for raw_edge: Variant in raw_edges: + if not raw_edge is Array or raw_edge.size() != 2: + return _fail("Mana snapshot edge must contain two node ids.") + var first_id := StringName(raw_edge[0]) + var second_id := StringName(raw_edge[1]) + if first_id == second_id or not next_nodes.has(first_id) or not next_nodes.has(second_id): + return _fail("Mana snapshot contains an invalid edge.") + next_adjacency[first_id][second_id] = true + next_adjacency[second_id][first_id] = true + + _nodes = next_nodes + _adjacency = next_adjacency + revision = maxi(int(snapshot.get(&"revision", 0)), 0) + last_evaluation_minute = int(snapshot.get(&"last_evaluation_minute", -1)) + _last_report = {} + last_error = "" + var validation := validate_invariants() + if not validation[&"valid"]: + return _fail("Restored Mana state violates its invariants.") + return true + + +func _register_node(node_id: StringName, kind: int, fields: Dictionary) -> bool: + if node_id.is_empty(): + return _fail("Mana node id cannot be empty.") + if _nodes.has(node_id): + return _fail("Duplicate Mana node: %s" % node_id) + var node := { + &"id": node_id, + &"kind": kind, + &"enabled": true, + } + for key: Variant in fields: + node[key] = fields[key] + _nodes[node_id] = node + _adjacency[node_id] = {} + revision += 1 + last_error = "" + return true + + +func _evaluate_component( + component: Array[StringName], + absolute_minute: int, + changes: Array +) -> Dictionary: + var generation := 0 + var consumers: Array[StringName] = [] + var storages: Array[StringName] = [] + for node_id: StringName in component: + var node: Dictionary = _nodes[node_id] + if not bool(node[&"enabled"]): + if int(node[&"kind"]) == NodeKind.CONSUMER: + node[&"allocated"] = 0 + _record_state_change(node_id, node, ConsumerState.DISABLED, [&"disabled"], changes) + _nodes[node_id] = node + continue + match int(node[&"kind"]): + NodeKind.SOURCE: + generation += int(node[&"output"]) + NodeKind.CONSUMER: + consumers.append(node_id) + NodeKind.STORAGE: + storages.append(node_id) + consumers.sort_custom(Callable(self, "_consumer_precedes")) + storages = _sorted_ids(storages) + + var eligible_demand := 0 + for consumer_id: StringName in consumers: + var consumer: Dictionary = _nodes[consumer_id] + if absolute_minute >= int(consumer[&"hold_until"]): + eligible_demand += int(consumer[&"demand"]) + + var discharged := 0 + var shortage := maxi(eligible_demand - generation, 0) + for storage_id: StringName in storages: + if shortage <= 0: + break + var storage: Dictionary = _nodes[storage_id] + var amount := mini(shortage, mini(int(storage[&"current"]), int(storage[&"max_flow"]))) + storage[&"current"] = int(storage[&"current"]) - amount + _nodes[storage_id] = storage + shortage -= amount + discharged += amount + + var remaining := generation + discharged + var allocated_total := 0 + for consumer_id: StringName in consumers: + var consumer: Dictionary = _nodes[consumer_id] + var demand := int(consumer[&"demand"]) + if absolute_minute < int(consumer[&"hold_until"]): + consumer[&"allocated"] = 0 + _record_state_change(consumer_id, consumer, ConsumerState.NO_POWER, [&"minimum_off_hold"], changes) + _nodes[consumer_id] = consumer + continue + var allocation := mini(demand, remaining) + remaining -= allocation + allocated_total += allocation + consumer[&"allocated"] = allocation + var next_state := ConsumerState.ACTIVE + var reasons: Array[StringName] = [] + if demand > 0 and allocation < demand: + if allocation * 100 >= demand * DEGRADED_THRESHOLD_PERCENT: + next_state = ConsumerState.DEGRADED + reasons = [&"brownout"] + else: + next_state = ConsumerState.NO_POWER + reasons = [&"insufficient_supply"] + consumer[&"hold_until"] = absolute_minute + MINIMUM_OFF_HOLD_MINUTES + _record_state_change(consumer_id, consumer, next_state, reasons, changes) + _nodes[consumer_id] = consumer + + var charged := 0 + for storage_id: StringName in storages: + if remaining <= 0: + break + var storage: Dictionary = _nodes[storage_id] + var headroom := int(storage[&"capacity"]) - int(storage[&"current"]) + var amount := mini(remaining, mini(headroom, int(storage[&"max_flow"]))) + storage[&"current"] = int(storage[&"current"]) + amount + _nodes[storage_id] = storage + remaining -= amount + charged += amount + + return { + &"component_id": String(component[0]) if not component.is_empty() else "", + &"generation": generation, + &"discharged": discharged, + &"charged": charged, + &"allocated": allocated_total, + &"unused": remaining, + &"nodes": _string_ids(component), + } + + +func _record_state_change( + consumer_id: StringName, + consumer: Dictionary, + next_state: int, + reasons: Array[StringName], + changes: Array +) -> void: + var previous_state := int(consumer[&"state"]) + var previous_reasons: Array = consumer.get(&"state_reasons", []) + consumer[&"state"] = next_state + consumer[&"state_reasons"] = reasons.duplicate() + if previous_state == next_state and previous_reasons == reasons: + return + changes.append({ + &"consumer_id": String(consumer_id), + &"previous_state": previous_state, + &"state": next_state, + &"reasons": _string_ids(reasons), + }) + consumer_state_changed.emit(consumer_id, previous_state, next_state, reasons) + + +func _set_consumer_state( + consumer_id: StringName, + consumer: Dictionary, + next_state: int, + reasons: Array[StringName] +) -> void: + var ignored_changes: Array = [] + _record_state_change(consumer_id, consumer, next_state, reasons, ignored_changes) + + +func _connected_components() -> Array[Array]: + var components: Array[Array] = [] + var visited: Dictionary = {} + for start_id: StringName in _sorted_ids(_nodes.keys()): + if visited.has(start_id) or not bool(_nodes[start_id][&"enabled"]): + continue + var component: Array[StringName] = [] + var frontier: Array[StringName] = [start_id] + visited[start_id] = true + while not frontier.is_empty(): + var current: StringName = frontier.pop_front() + component.append(current) + for neighbor: StringName in _sorted_ids(_adjacency[current].keys()): + if visited.has(neighbor) or not bool(_nodes[neighbor][&"enabled"]): + continue + visited[neighbor] = true + frontier.append(neighbor) + component = _sorted_ids(component) + components.append(component) + return components + + +func _consumer_precedes(first_id: StringName, second_id: StringName) -> bool: + var first_priority := int(_nodes[first_id][&"priority"]) + var second_priority := int(_nodes[second_id][&"priority"]) + if first_priority != second_priority: + return first_priority > second_priority + return String(first_id) < String(second_id) + + +func _normalize_saved_node(raw_node: Variant) -> Dictionary: + if not raw_node is Dictionary: + return {&"ok": false, &"error": "Mana snapshot node must be a Dictionary."} + var node_id := StringName(raw_node.get(&"id", "")) + var kind := int(raw_node.get(&"kind", -1)) + if node_id.is_empty() or kind < NodeKind.SOURCE or kind > NodeKind.STORAGE: + return {&"ok": false, &"error": "Mana snapshot node id or kind is invalid."} + var node: Dictionary = raw_node.duplicate(true) + node[&"id"] = node_id + node[&"kind"] = kind + node[&"enabled"] = bool(node.get(&"enabled", true)) + match kind: + NodeKind.SOURCE: + if int(node.get(&"output", -1)) < 0: + return {&"ok": false, &"error": "Mana source output is invalid."} + NodeKind.CONSUMER: + var demand := int(node.get(&"demand", -1)) + var allocated := int(node.get(&"allocated", -1)) + var state := int(node.get(&"state", -1)) + if demand < 0 or allocated < 0 or allocated > demand \ + or state < ConsumerState.ACTIVE or state > ConsumerState.DISABLED: + return {&"ok": false, &"error": "Mana consumer values are invalid."} + var reasons: Array[StringName] = [] + for raw_reason: Variant in node.get(&"state_reasons", []): + reasons.append(StringName(raw_reason)) + node[&"state_reasons"] = reasons + node[&"priority"] = int(node.get(&"priority", ConsumerPriority.NORMAL)) + node[&"hold_until"] = maxi(int(node.get(&"hold_until", 0)), 0) + NodeKind.STORAGE: + var capacity := int(node.get(&"capacity", -1)) + var current := int(node.get(&"current", -1)) + if capacity < 0 or current < 0 or current > capacity or int(node.get(&"max_flow", -1)) < 0: + return {&"ok": false, &"error": "Mana storage values are invalid."} + return {&"ok": true, &"node": node} + + +func _is_kind(node_id: StringName, kind: int) -> bool: + return _nodes.has(node_id) and int(_nodes[node_id][&"kind"]) == kind + + +func _sorted_ids(raw_ids: Array) -> Array[StringName]: + var ids: Array[StringName] = [] + for raw_id: Variant in raw_ids: + ids.append(StringName(raw_id)) + ids.sort_custom(func(first: StringName, second: StringName) -> bool: + return String(first) < String(second) + ) + return ids + + +func _string_ids(raw_ids: Array) -> Array[String]: + var result: Array[String] = [] + for raw_id: Variant in raw_ids: + result.append(String(raw_id)) + return result + + +func _fail(message: String) -> bool: + last_error = message + return false diff --git a/scripts/mana/mana_network_service.gd.uid b/scripts/mana/mana_network_service.gd.uid new file mode 100644 index 0000000..d47e8ed --- /dev/null +++ b/scripts/mana/mana_network_service.gd.uid @@ -0,0 +1 @@ +uid://c11a4j1u7gogb diff --git a/scripts/operations/operations_coordinator.gd b/scripts/operations/operations_coordinator.gd new file mode 100644 index 0000000..1475bde --- /dev/null +++ b/scripts/operations/operations_coordinator.gd @@ -0,0 +1,2955 @@ +class_name OperationsCoordinator +extends RefCounted + +## Standalone deterministic coordinator for the 14-day operating loop. +## +## This aggregate owns scheduling, demand, arrival carry, daily summaries and +## insolvency state. It deliberately does not own Guests, Facilities or Gold. +## Those domains are reached only through capture/validate/apply/rollback +## callbacks so a future SimulationRuntime adapter can compose one transaction. + +signal demand_updated(report: Dictionary) +signal arrivals_committed(report: Dictionary) +signal daily_close_completed(summary: Dictionary) +signal morning_boundary_completed(report: Dictionary) +signal operations_alerted(alert: Dictionary) +signal terminal_state_changed(old_state: StringName, new_state: StringName, reason: StringName) + +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 3 +const MIN_SUPPORTED_SNAPSHOT_VERSION := 1 +const MINUTES_PER_DAY := 1_440 +const DEMAND_INTERVAL_MINUTES := 60 +const ARRIVAL_INTERVAL_MINUTES := 30 +const MORNING_MINUTE_OF_DAY := 360 +const FIRST_DAILY_CLOSE_MINUTE := MORNING_MINUTE_OF_DAY + MINUTES_PER_DAY +const KEY_SEPARATOR := "\u001f" +const MAX_START_MINUTE := 360 +const MAX_TRACKED_DAYS := 31 +const MAX_TRACKED_MINUTES := MAX_TRACKED_DAYS * MINUTES_PER_DAY +const MAX_ADVANCE_SPAN_MINUTES := 31 * MINUTES_PER_DAY +const MAX_SAFE_COLLECTION_SIZE := 20_000 +const MAX_VARIANT_DEPTH := 32 +const MAX_VARIANT_NODES := 2_000_000 +const MAX_TOTAL_STRING_LENGTH := 8_000_000 +const MAX_SINGLE_STRING_LENGTH := 4_096 +const MAX_CALLBACK_VARIANT_NODES := 20_000 +const MAX_CALLBACK_STRING_LENGTH := 100_000 +const MAX_DEMAND_PROFILES := 16 +const MAX_WORLD_SEED := 2_147_483_647 +const MAX_FINANCIAL_AMOUNT := 1_000_000_000_000_000 +const MAX_SEQUENCE_VALUE := 99_999_999 + +const PHASE_CAPTURE: StringName = &"capture" +const PHASE_VALIDATE: StringName = &"validate" +const PHASE_APPLY: StringName = &"apply" +const PHASE_ROLLBACK: StringName = &"rollback" + +const TERMINAL_RUNNING: StringName = &"RUNNING" +const TERMINAL_BANKRUPT: StringName = &"BANKRUPT" + +const TIME_BANDS: Array[StringName] = [ + &"MORNING", + &"DAY", + &"EVENING", + &"NIGHT", + &"ANY", +] + +const DEMAND_COMPONENTS: Array[StringName] = [ + &"base", + &"reputation", + &"unmet_need", + &"event", + &"recent_price_penalty", + &"wait_penalty", +] + +const DAILY_CATEGORIES: Array[StringName] = [ + &"revenue", + &"refunds", + &"cost_of_goods", + &"wages", + &"maintenance", + &"utilities", + &"taxes_and_interest", + &"fines", + &"construction_cash_flow", +] + +const DEFAULT_CONFIG := { + &"world_seed": 1, + &"population_cap": 100, + &"max_arrivals_per_interval": 10, + # One arrival per 100 DemandIndex points per active profile and interval. + # These are tuning values, not a final balance decision. + &"arrival_rate_numerator": 1, + &"arrival_rate_denominator": 100, + # A profile may retain at most three full interval caps of fixed-point carry. + &"arrival_carry_interval_limit": 3, + &"max_component_magnitude": 10_000, + &"bankruptcy_close_count": 3, +} + +var current_minute := 0 +var pause_requested := false +var last_error := "" + +var _origin_minute := 0 + +var _config: Dictionary = DEFAULT_CONFIG.duplicate(true) +var _demand_profiles: Dictionary = {} +var _arrival_carry: Dictionary = {} +var _demand_history: Array[Dictionary] = [] +var _arrival_reports: Array[Dictionary] = [] +var _day_summaries: Dictionary = {} +var _morning_reports: Dictionary = {} +var _alerts: Array[Dictionary] = [] +var _processed_demand_minutes: Dictionary = {} +var _processed_arrival_minutes: Dictionary = {} + +var _population_count := 0 +var _arrears_total := 0 +var _consecutive_insolvency_count := 0 +var _terminal_state: StringName = TERMINAL_RUNNING +var _next_arrival_plan_sequence := 1 +var _next_guest_sequence := 1 +var _arrival_plan_high_water := 0 +var _guest_high_water := 0 +var _fair_rotation_counter := 0 +var _pending_boundary_minute := -1 +var _deferred_boundary_minute := -1 +var _pause_reason: StringName = &"" +var _external_quarantined := false +var _quarantine_report: Dictionary = {} + +var _is_advancing := false +var _notification_queue: Array[Dictionary] = [] +var _notification_delivery_deferred := false +var _initialization_valid := true + +var _daily_close_boundary := Callable() +var _arrival_boundary := Callable() +var _morning_boundary := Callable() + + +func _init(config: Dictionary = {}) -> void: + if not _canonical_key_collision(config).is_empty(): + last_error = "Operations constructor config has duplicate canonical keys." + _initialization_valid = false + _origin_minute = 0 + current_minute = 0 + return + var start_value: Variant = _value(config, &"start_minute", 0) + var start_minute := int(start_value) if _is_integral_value(start_value) else -1 + var candidate := config.duplicate(true) + candidate.erase(&"start_minute") + candidate.erase("start_minute") + var normalized := _normalize_config(candidate) + if bool(normalized[&"ok"]): + _config = normalized[&"config"] + else: + last_error = String(normalized[&"error"]) + _initialization_valid = false + if start_minute < 0 or start_minute > MAX_START_MINUTE: + last_error = "Start minute must be within the explicit baseline range 0..360." + _initialization_valid = false + start_minute = 0 + _origin_minute = start_minute + current_minute = start_minute + + +func configure_callbacks( + daily_close_boundary: Callable = Callable(), + arrival_boundary: Callable = Callable(), + morning_boundary: Callable = Callable() +) -> OperationsCoordinator: + _daily_close_boundary = daily_close_boundary + _arrival_boundary = arrival_boundary + _morning_boundary = morning_boundary + return self + + +func configure(config: Dictionary) -> CommandResult: + if _is_advancing: + return _failure("REENTRANT_MUTATION", "Operations state cannot mutate during advance notification or callbacks.") + if not _initialization_valid: + return _failure("INITIALIZATION_INVALID", last_error) + if _external_quarantined: + return _failure("EXTERNAL_ROLLBACK_FAILED", "External uncertainty quarantines configuration changes.") + if _has_committed_operations(): + return _failure( + "CONFIG_LOCKED", + "Operating configuration cannot change after a boundary has committed." + ) + var normalized := _normalize_config(config) + if not bool(normalized[&"ok"]): + return _failure("INVALID_CONFIG", String(normalized[&"error"])) + var candidate_config: Dictionary = normalized[&"config"] + if _population_count > int(candidate_config[&"population_cap"]): + return _failure("INVALID_CONFIG", "Population cannot be silently truncated by a lower cap.") + for profile_value: Variant in _demand_profiles.values(): + if not profile_value is Dictionary: + return _failure("INVALID_CONFIG", "Existing demand state is malformed.") + var components_value: Variant = _value(profile_value, &"components", null) + if not components_value is Dictionary: + return _failure("INVALID_CONFIG", "Existing demand components are malformed.") + var candidate_components := _normalize_demand_components_for_limit( + components_value, + int(candidate_config[&"max_component_magnitude"]) + ) + if not bool(candidate_components[&"ok"]): + return _failure( + "INVALID_CONFIG", + "New configuration would invalidate an existing demand profile." + ) + _config = candidate_config + last_error = "" + return _success({&"config": get_config()}) + + +func get_config() -> Dictionary: + return _config.duplicate(true) + + +func set_demand_input( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName, + components: Dictionary +) -> CommandResult: + if _is_advancing: + return _failure("REENTRANT_MUTATION", "Demand cannot mutate during advance notification or callbacks.") + if not _initialization_valid: + return _failure("INITIALIZATION_INVALID", last_error) + if _external_quarantined: + return _failure("EXTERNAL_ROLLBACK_FAILED", "External uncertainty quarantines demand changes.") + if _terminal_state != TERMINAL_RUNNING: + return _failure("TERMINAL_STATE", "Demand cannot change after bankruptcy.") + var identity_error := _validate_demand_identity(facility_tag, guest_class, time_band) + if not identity_error.is_empty(): + return _failure("INVALID_DEMAND_KEY", identity_error) + var normalized := _normalize_demand_components(components) + if not bool(normalized[&"ok"]): + return _failure("INVALID_DEMAND_INPUT", String(normalized[&"error"])) + var key := _demand_key(facility_tag, guest_class, time_band) + if not _demand_profiles.has(key) and _demand_profiles.size() >= MAX_DEMAND_PROFILES: + return _failure("DEMAND_PROFILE_LIMIT", "Demand profile limit has been reached.") + var normalized_components: Dictionary = normalized[&"components"] + var profile := { + &"facility_tag": facility_tag, + &"guest_class": guest_class, + &"time_band": time_band, + &"components": normalized_components, + &"demand_index": _calculate_demand_index(normalized_components), + &"last_evaluated_at": -1, + } + _demand_profiles[key] = profile + if not _arrival_carry.has(key): + _arrival_carry[key] = 0 + last_error = "" + return _success({ + &"key": key, + &"demand_index": profile[&"demand_index"], + }) + + +func remove_demand_input( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName +) -> CommandResult: + if _is_advancing: + return _failure("REENTRANT_MUTATION", "Demand cannot mutate during advance notification or callbacks.") + if not _initialization_valid: + return _failure("INITIALIZATION_INVALID", last_error) + if _external_quarantined: + return _failure("EXTERNAL_ROLLBACK_FAILED", "External uncertainty quarantines demand changes.") + if _terminal_state != TERMINAL_RUNNING: + return _failure("TERMINAL_STATE", "Demand cannot change after bankruptcy.") + var key := _demand_key(facility_tag, guest_class, time_band) + if not _demand_profiles.has(key): + return _failure("DEMAND_NOT_FOUND", "Demand profile does not exist.") + if int(_arrival_carry.get(key, 0)) > 0: + return _failure( + "ARRIVAL_CARRY_REMAINS", + "A demand profile with pending arrival carry cannot be removed." + ) + _demand_profiles.erase(key) + _arrival_carry.erase(key) + return _success({&"key": key}) + + +func get_demand_profile( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName +) -> Dictionary: + var key := _demand_key(facility_tag, guest_class, time_band) + if not _demand_profiles.has(key): + return {} + return (_demand_profiles[key] as Dictionary).duplicate(true) + + +func get_demand_profiles() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for key: String in _sorted_string_keys(_demand_profiles): + result.append((_demand_profiles[key] as Dictionary).duplicate(true)) + return result + + +func get_demand_index( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName +) -> int: + var profile := get_demand_profile(facility_tag, guest_class, time_band) + return -1 if profile.is_empty() else int(profile[&"demand_index"]) + + +func set_population_count(value: int) -> CommandResult: + if _is_advancing: + return _failure("REENTRANT_MUTATION", "Population cannot mutate during advance notification or callbacks.") + if not _initialization_valid: + return _failure("INITIALIZATION_INVALID", last_error) + if _external_quarantined: + return _failure("EXTERNAL_ROLLBACK_FAILED", "External uncertainty quarantines population changes.") + if _terminal_state != TERMINAL_RUNNING: + return _failure("TERMINAL_STATE", "Population cannot change after bankruptcy.") + if value < 0 or value > int(_config[&"population_cap"]): + return _failure( + "INVALID_POPULATION", + "Population must remain within zero and the configured cap." + ) + _population_count = value + return _success({ + &"population_count": _population_count, + &"population_cap": int(_config[&"population_cap"]), + }) + + +func get_population_count() -> int: + return _population_count + + +func get_origin_minute() -> int: + return _origin_minute + + +func is_external_quarantined() -> bool: + return _external_quarantined + + +func get_quarantine_report() -> Dictionary: + return _quarantine_report.duplicate(true) + + +func advance_to(target_minute: int, defer_notifications := false) -> CommandResult: + if _is_advancing: + return _failure( + "REENTRANT_ADVANCE", + "Operations advance cannot re-enter from a callback or signal handler." + ) + if _notification_delivery_deferred: + return _failure( + "NOTIFICATION_COMMIT_REQUIRED", + "Deferred Operations notifications must be committed or discarded before advancing." + ) + _is_advancing = true + var result := _advance_to_internal(target_minute) + # Domain signals are emitted only after every coordinator commit and clock + # assignment for this call. Re-entrant advance remains guarded while they run. + if defer_notifications: + _notification_delivery_deferred = not _notification_queue.is_empty() + else: + _flush_notifications() + _is_advancing = false + return result + + +func has_deferred_notifications() -> bool: + return _notification_delivery_deferred + + +func commit_deferred_notifications() -> bool: + if _is_advancing: + last_error = "Operations notification commit cannot re-enter an advance." + return false + if not _notification_delivery_deferred: + return _notification_queue.is_empty() + _is_advancing = true + _notification_delivery_deferred = false + _flush_notifications() + _is_advancing = false + last_error = "" + return true + + +func discard_deferred_notifications() -> bool: + if _is_advancing: + last_error = "Operations notification discard cannot re-enter an advance." + return false + _notification_queue.clear() + _notification_delivery_deferred = false + last_error = "" + return true + + +func _advance_to_internal(target_minute: int) -> CommandResult: + if not _initialization_valid: + return _failure("INITIALIZATION_INVALID", last_error) + if target_minute < 0: + return _failure("INVALID_TIME", "Target minute must be non-negative.") + if target_minute < current_minute: + return _failure( + "TIME_REWIND_REJECTED", + "Operations time cannot move backwards.", + {&"current_minute": current_minute, &"target_minute": target_minute} + ) + if target_minute > _origin_minute + MAX_TRACKED_MINUTES: + return _failure("HISTORY_LIMIT_REACHED", "Uncompacted operations history is limited to 31 days.") + if target_minute - current_minute > MAX_ADVANCE_SPAN_MINUTES: + return _failure("ADVANCE_SPAN_TOO_LARGE", "One operations advance may cover at most 31 days.") + if _external_quarantined: + return _failure( + "EXTERNAL_ROLLBACK_FAILED", + "External rollback failed; normal retry is quarantined until verified recovery." + ) + if _terminal_state != TERMINAL_RUNNING: + return _failure("TERMINAL_STATE", "Operations are terminal after bankruptcy.") + if pause_requested: + return _failure("PAUSE_REQUESTED", "Operations require an explicit pause acknowledgement.") + + var started_at := current_minute + var counts := { + &"daily_closes": 0, + &"morning_boundaries": 0, + &"demand_updates": 0, + &"arrival_boundaries": 0, + } + var completed_deferred := false + if _deferred_boundary_minute != -1: + if _deferred_boundary_minute != current_minute: + return _failure("INVALID_DEFERRED_BOUNDARY", "Deferred boundary no longer matches the clock.") + var deferred_result := _process_minute_boundaries(current_minute, counts) + if not deferred_result.is_success(): + deferred_result.details[&"current_minute"] = current_minute + deferred_result.details[&"failed_boundary_minute"] = current_minute + return deferred_result + if bool(deferred_result.details.get(&"deferred", false)): + return _success({ + &"from_minute": started_at, + &"requested_minute": target_minute, + &"current_minute": current_minute, + &"processed_minutes": 0, + &"boundary_counts": counts, + &"pause_requested": pause_requested, + &"terminal_state": _terminal_state, + }) + _deferred_boundary_minute = -1 + completed_deferred = true + if target_minute == current_minute: + if completed_deferred: + return _success({ + &"current_minute": current_minute, + &"processed_minutes": 0, + &"deferred_completed": true, + &"boundary_counts": counts, + }) + var replay := _success({ + &"current_minute": current_minute, + &"processed_minutes": 0, + &"replay": true, + &"boundary_counts": counts, + }) + replay.is_replay = true + return replay + + for minute in range(current_minute + 1, target_minute + 1): + var boundary_result := _process_minute_boundaries(minute, counts) + if not boundary_result.is_success(): + # The failing boundary made no coordinator commit. Earlier, lower-minute + # boundaries stay committed, and retry begins at this exact minute. + current_minute = minute - 1 + _pending_boundary_minute = minute + boundary_result.details[&"current_minute"] = current_minute + boundary_result.details[&"failed_boundary_minute"] = minute + return boundary_result + current_minute = minute + if _pending_boundary_minute == minute: + _pending_boundary_minute = -1 + if bool(boundary_result.details.get(&"deferred", false)): + _deferred_boundary_minute = minute + if _terminal_state != TERMINAL_RUNNING: + break + if pause_requested: + break + + last_error = "" + return _success({ + &"from_minute": started_at, + &"requested_minute": target_minute, + &"current_minute": current_minute, + &"processed_minutes": current_minute - started_at, + &"boundary_counts": counts, + &"pause_requested": pause_requested, + &"terminal_state": _terminal_state, + }) + + +func acknowledge_pause() -> CommandResult: + if _is_advancing: + return _failure("REENTRANT_MUTATION", "Pause acknowledgement cannot re-enter an advance.") + if _terminal_state != TERMINAL_RUNNING: + return _failure("TERMINAL_STATE", "Bankruptcy cannot be acknowledged away.") + if _external_quarantined: + return _failure( + "EXTERNAL_ROLLBACK_FAILED", + "External state is uncertain and requires recovery from a verified snapshot." + ) + var had_request := pause_requested + pause_requested = false + _pause_reason = &"" + return _success({&"had_request": had_request}) + + +func is_terminal() -> bool: + return _terminal_state != TERMINAL_RUNNING + + +func get_terminal_state() -> StringName: + return _terminal_state + + +func get_arrears_total() -> int: + return _arrears_total + + +func get_consecutive_insolvency_count() -> int: + return _consecutive_insolvency_count + + +func can_start_construction_or_investment() -> Dictionary: + var allowed := _terminal_state == TERMINAL_RUNNING \ + and _arrears_total == 0 and not _external_quarantined + var reason: StringName = &"" + if _terminal_state == TERMINAL_BANKRUPT: + reason = &"BANKRUPT" + elif _external_quarantined: + reason = &"EXTERNAL_ROLLBACK_FAILED" + elif _arrears_total > 0: + reason = &"ARREARS_REMAIN" + return { + &"allowed": allowed, + &"reason": reason, + &"arrears_total": _arrears_total, + &"consecutive_insolvency_count": _consecutive_insolvency_count, + } + + +func get_day_summary(day: int) -> Dictionary: + if not _day_summaries.has(day): + return {} + return (_day_summaries[day] as Dictionary).duplicate(true) + + +func get_day_summaries() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for day: int in _sorted_int_keys(_day_summaries): + result.append((_day_summaries[day] as Dictionary).duplicate(true)) + return result + + +func get_morning_report(day: int) -> Dictionary: + if not _morning_reports.has(day): + return {} + return (_morning_reports[day] as Dictionary).duplicate(true) + + +func get_arrival_reports() -> Array[Dictionary]: + return _arrival_reports.duplicate(true) + + +func get_demand_history() -> Array[Dictionary]: + return _demand_history.duplicate(true) + + +func get_alerts() -> Array[Dictionary]: + return _alerts.duplicate(true) + + +func get_arrival_carry( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName +) -> int: + return int(_arrival_carry.get(_demand_key(facility_tag, guest_class, time_band), -1)) + + +func deterministic_digest() -> String: + return JSON.stringify(create_snapshot(), "", true, true).sha256_text() + + +func create_snapshot() -> Dictionary: + if not _initialization_valid: + last_error = "Operations coordinator initialization is invalid." + return {} + if _notification_delivery_deferred or not _notification_queue.is_empty(): + last_error = "Deferred Operations notifications must be resolved before snapshotting." + return {} + var internal_report := _variant_tree_report({ + &"config": _config, + &"demand_profiles": _demand_profiles, + &"arrival_carry": _arrival_carry, + &"demand_history": _demand_history, + &"arrival_reports": _arrival_reports, + &"day_summaries": _day_summaries, + &"morning_reports": _morning_reports, + &"alerts": _alerts, + &"quarantine_report": _quarantine_report, + }) + var shape_error := _internal_snapshot_shape_error() + if not bool(internal_report[&"ok"]) or not shape_error.is_empty(): + last_error = "Operations state cannot be serialized: %s" % [ + internal_report[&"code"] if not bool(internal_report[&"ok"]) else shape_error + ] + return {} + var saved_profiles: Array[Dictionary] = [] + var saved_carry: Array[Dictionary] = [] + for key: String in _sorted_string_keys(_demand_profiles): + var profile: Dictionary = _demand_profiles[key] + saved_profiles.append({ + &"facility_tag": String(profile[&"facility_tag"]), + &"guest_class": String(profile[&"guest_class"]), + &"time_band": String(profile[&"time_band"]), + &"components": _string_key_dictionary(profile[&"components"]), + &"demand_index": int(profile[&"demand_index"]), + &"last_evaluated_at": int(profile[&"last_evaluated_at"]), + }) + saved_carry.append({ + &"facility_tag": String(profile[&"facility_tag"]), + &"guest_class": String(profile[&"guest_class"]), + &"time_band": String(profile[&"time_band"]), + &"numerator": int(_arrival_carry.get(key, 0)), + }) + + var saved_mornings: Array[Dictionary] = [] + for day: int in _sorted_int_keys(_morning_reports): + saved_mornings.append((_morning_reports[day] as Dictionary).duplicate(true)) + + var raw_snapshot := { + &"schema_version": SNAPSHOT_VERSION, + &"origin_minute": _origin_minute, + &"current_minute": current_minute, + &"config": _string_key_dictionary(_config), + &"demand_profiles": saved_profiles, + &"arrival_carry": saved_carry, + &"demand_history": _demand_history.duplicate(true), + &"arrival_reports": _arrival_reports.duplicate(true), + &"day_summaries": get_day_summaries(), + &"morning_reports": saved_mornings, + &"alerts": _alerts.duplicate(true), + &"processed_demand_minutes": _sorted_int_keys(_processed_demand_minutes), + &"processed_arrival_minutes": _sorted_int_keys(_processed_arrival_minutes), + &"population_count": _population_count, + &"arrears_total": _arrears_total, + &"consecutive_insolvency_count": _consecutive_insolvency_count, + &"terminal_state": String(_terminal_state), + &"pause_requested": pause_requested, + &"next_arrival_plan_sequence": _next_arrival_plan_sequence, + &"next_guest_sequence": _next_guest_sequence, + &"arrival_plan_high_water": _arrival_plan_high_water, + &"guest_high_water": _guest_high_water, + &"fair_rotation_counter": _fair_rotation_counter, + &"pending_boundary_minute": _pending_boundary_minute, + &"deferred_boundary_minute": _deferred_boundary_minute, + &"pause_reason": String(_pause_reason), + &"external_quarantined": _external_quarantined, + &"quarantine_report": _quarantine_report.duplicate(true), + } + var tree_report := _variant_tree_report(raw_snapshot) + if not bool(tree_report[&"ok"]): + last_error = "Operations state cannot be serialized: %s" % tree_report[&"code"] + return {} + return _to_snapshot_variant(raw_snapshot) + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _is_advancing or _notification_delivery_deferred \ + or not _notification_queue.is_empty(): + last_error = "Operations snapshot restore cannot re-enter an advance." + return false + var parsed := _parse_snapshot(snapshot) + if not bool(parsed.get(&"ok", false)): + last_error = String(parsed.get(&"error", "Snapshot is invalid.")) + return false + var state: Dictionary = parsed[&"state"] + # Atomic swap begins only after the entire candidate passed validation. + _origin_minute = int(state[&"origin_minute"]) + current_minute = int(state[&"current_minute"]) + _config = (state[&"config"] as Dictionary).duplicate(true) + _demand_profiles = (state[&"demand_profiles"] as Dictionary).duplicate(true) + _arrival_carry = (state[&"arrival_carry"] as Dictionary).duplicate(true) + _demand_history = _copy_dictionary_array(state[&"demand_history"]) + _arrival_reports = _copy_dictionary_array(state[&"arrival_reports"]) + _day_summaries = (state[&"day_summaries"] as Dictionary).duplicate(true) + _morning_reports = (state[&"morning_reports"] as Dictionary).duplicate(true) + _alerts = _copy_dictionary_array(state[&"alerts"]) + _processed_demand_minutes = (state[&"processed_demand_minutes"] as Dictionary).duplicate(true) + _processed_arrival_minutes = (state[&"processed_arrival_minutes"] as Dictionary).duplicate(true) + _population_count = int(state[&"population_count"]) + _arrears_total = int(state[&"arrears_total"]) + _consecutive_insolvency_count = int(state[&"consecutive_insolvency_count"]) + _terminal_state = StringName(state[&"terminal_state"]) + pause_requested = bool(state[&"pause_requested"]) + _next_arrival_plan_sequence = int(state[&"next_arrival_plan_sequence"]) + _next_guest_sequence = int(state[&"next_guest_sequence"]) + _arrival_plan_high_water = int(state[&"arrival_plan_high_water"]) + _guest_high_water = int(state[&"guest_high_water"]) + _fair_rotation_counter = int(state[&"fair_rotation_counter"]) + _pending_boundary_minute = int(state[&"pending_boundary_minute"]) + _deferred_boundary_minute = int(state[&"deferred_boundary_minute"]) + _pause_reason = StringName(state[&"pause_reason"]) + _external_quarantined = bool(state[&"external_quarantined"]) + _quarantine_report = (state[&"quarantine_report"] as Dictionary).duplicate(true) + _initialization_valid = true + _notification_queue.clear() + _notification_delivery_deferred = false + last_error = "" + return true + + +func validate_invariants() -> Dictionary: + var errors: Array[Dictionary] = [] + if not _initialization_valid: + errors.append({&"code": &"INITIALIZATION_INVALID"}) + if _origin_minute < 0 or _origin_minute > MAX_START_MINUTE: + errors.append({&"code": &"INVALID_ORIGIN"}) + if current_minute < _origin_minute \ + or current_minute > _origin_minute + MAX_TRACKED_MINUTES: + errors.append({&"code": &"NEGATIVE_TIME"}) + var normalized_config := _normalize_config(_config) + if not bool(normalized_config[&"ok"]) or normalized_config[&"config"] != _config: + errors.append({&"code": &"INVALID_CONFIG"}) + if _population_count < 0 or _population_count > int(_config[&"population_cap"]): + errors.append({&"code": &"INVALID_POPULATION"}) + if _arrears_total < 0 or _consecutive_insolvency_count < 0: + errors.append({&"code": &"INVALID_INSOLVENCY_STATE"}) + if _terminal_state not in [TERMINAL_RUNNING, TERMINAL_BANKRUPT]: + errors.append({&"code": &"INVALID_TERMINAL_STATE"}) + if _terminal_state == TERMINAL_BANKRUPT \ + and _consecutive_insolvency_count < int(_config[&"bankruptcy_close_count"]): + errors.append({&"code": &"BANKRUPTCY_WITHOUT_THRESHOLD"}) + if _arrival_plan_high_water < 0 or _guest_high_water < 0 \ + or _arrival_plan_high_water > MAX_SEQUENCE_VALUE \ + or _guest_high_water > MAX_SEQUENCE_VALUE \ + or _next_arrival_plan_sequence != _arrival_plan_high_water + 1 \ + or _next_guest_sequence != _guest_high_water + 1: + errors.append({&"code": &"INVALID_SEQUENCE"}) + if _fair_rotation_counter < 0 \ + or _fair_rotation_counter != _processed_arrival_minutes.size(): + errors.append({&"code": &"INVALID_FAIR_ROTATION_COUNTER"}) + if _pending_boundary_minute != -1 \ + and (_pending_boundary_minute != current_minute + 1 \ + or not _minute_has_scheduled_boundary(_pending_boundary_minute)): + errors.append({&"code": &"INVALID_PENDING_BOUNDARY"}) + if _deferred_boundary_minute != -1 \ + and (_deferred_boundary_minute != current_minute \ + or not _minute_has_scheduled_boundary(_deferred_boundary_minute)): + errors.append({&"code": &"INVALID_DEFERRED_BOUNDARY"}) + if _external_quarantined: + if not pause_requested or _pause_reason != &"EXTERNAL_ROLLBACK_FAILED" \ + or _quarantine_report.is_empty(): + errors.append({&"code": &"INVALID_EXTERNAL_QUARANTINE"}) + elif not _quarantine_report.is_empty(): + errors.append({&"code": &"ORPHAN_QUARANTINE_REPORT"}) + if pause_requested and _pause_reason not in [&"LEDGER_IMBALANCE", &"EXTERNAL_ROLLBACK_FAILED"]: + errors.append({&"code": &"INVALID_PAUSE_REASON"}) + if not pause_requested and not _pause_reason.is_empty(): + errors.append({&"code": &"ORPHAN_PAUSE_REASON"}) + + for key: String in _sorted_string_keys(_demand_profiles): + var profile_value: Variant = _demand_profiles[key] + if not profile_value is Dictionary: + errors.append({&"code": &"INVALID_DEMAND_PROFILE", &"key": key}) + continue + var profile: Dictionary = profile_value + var facility_tag := StringName(_value(profile, &"facility_tag", &"")) + var guest_class := StringName(_value(profile, &"guest_class", &"")) + var time_band := StringName(_value(profile, &"time_band", &"")) + if not _validate_demand_identity(facility_tag, guest_class, time_band).is_empty() \ + or key != _demand_key(facility_tag, guest_class, time_band): + errors.append({&"code": &"INVALID_DEMAND_KEY", &"key": key}) + continue + var components_value: Variant = _value(profile, &"components", null) + if not components_value is Dictionary: + errors.append({&"code": &"INVALID_DEMAND_COMPONENTS", &"key": key}) + continue + var normalized_components := _normalize_demand_components_for_limit( + components_value, + int((normalized_config[&"config"] as Dictionary)[&"max_component_magnitude"]) + ) + if not bool(normalized_components[&"ok"]): + errors.append({&"code": &"INVALID_DEMAND_COMPONENTS", &"key": key}) + continue + if int(_value(profile, &"demand_index", -1)) \ + != _calculate_demand_index(normalized_components[&"components"]): + errors.append({&"code": &"DEMAND_INDEX_MISMATCH", &"key": key}) + var evaluated_at := int(_value(profile, &"last_evaluated_at", -2)) + var maximum_evaluated_minute := current_minute + if _pending_boundary_minute == current_minute + 1: + maximum_evaluated_minute = _pending_boundary_minute + if evaluated_at < -1 or evaluated_at > maximum_evaluated_minute \ + or (evaluated_at >= 0 and evaluated_at % DEMAND_INTERVAL_MINUTES != 0): + errors.append({&"code": &"INVALID_DEMAND_EVALUATION_TIME", &"key": key}) + if not _arrival_carry.has(key): + errors.append({&"code": &"MISSING_ARRIVAL_CARRY", &"key": key}) + elif int(_arrival_carry[key]) < 0 or int(_arrival_carry[key]) > _max_carry_numerator(): + errors.append({&"code": &"INVALID_ARRIVAL_CARRY", &"key": key}) + for carry_key: Variant in _arrival_carry: + if not _demand_profiles.has(carry_key): + errors.append({&"code": &"ORPHAN_ARRIVAL_CARRY", &"key": carry_key}) + + _validate_boundary_minute_set( + _processed_demand_minutes, + DEMAND_INTERVAL_MINUTES, + &"INVALID_DEMAND_BOUNDARY", + errors + ) + _validate_boundary_minute_set( + _processed_arrival_minutes, + ARRIVAL_INTERVAL_MINUTES, + &"INVALID_ARRIVAL_BOUNDARY", + errors + ) + _validate_histories(errors) + _validate_financial_history(errors) + _validate_boundary_coverage(errors) + _validate_alerts_and_pause(errors) + var candidate_snapshot := create_snapshot() + if candidate_snapshot.is_empty() or not _variant_tree_report(candidate_snapshot)[&"ok"]: + errors.append({&"code": &"UNSAVEABLE_VARIANT"}) + return { + &"ok": errors.is_empty(), + &"errors": errors, + &"demand_profile_count": _demand_profiles.size(), + &"arrival_boundary_count": _arrival_reports.size(), + &"closed_day_count": _day_summaries.size(), + } + + +func _process_minute_boundaries(minute: int, counts: Dictionary) -> CommandResult: + # Fixed 06:00 boundary order: prior operating-day finance, morning + # operations, demand, arrivals. The first 06:00 opens Day 1; the first close + # occurs at Day 2 06:00 after a complete 24-hour operating period. + if _is_daily_close_boundary(minute): + var close := _process_daily_close(minute) + if not close.is_success(): + return close + counts[&"daily_closes"] = int(counts[&"daily_closes"]) + int(not close.is_replay) + if _terminal_state != TERMINAL_RUNNING: + return _success({&"terminal": true}) + if pause_requested: + # A ledger imbalance pauses before morning, demand and arrival work. + return _success({&"deferred": true, &"after": &"DAILY_CLOSE"}) + + if minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY: + var morning := _process_morning_boundary(minute) + if not morning.is_success(): + return morning + counts[&"morning_boundaries"] = int(counts[&"morning_boundaries"]) + int(not morning.is_replay) + if pause_requested: + return _success({&"deferred": true, &"after": &"MORNING_BOUNDARY"}) + + if minute % DEMAND_INTERVAL_MINUTES == 0: + var demand := _process_demand_boundary(minute) + if not demand.is_success(): + return demand + counts[&"demand_updates"] = int(counts[&"demand_updates"]) + int(not demand.is_replay) + if pause_requested: + return _success({&"deferred": true, &"after": &"DEMAND_UPDATE"}) + + if minute % ARRIVAL_INTERVAL_MINUTES == 0: + var arrivals := _process_arrival_boundary(minute) + if not arrivals.is_success(): + return arrivals + counts[&"arrival_boundaries"] = int(counts[&"arrival_boundaries"]) + int(not arrivals.is_replay) + return _success() + + +func _process_demand_boundary(minute: int) -> CommandResult: + if _processed_demand_minutes.has(minute): + var replay := _success({&"minute": minute}) + replay.is_replay = true + return replay + var values: Array[Dictionary] = [] + for key: String in _sorted_string_keys(_demand_profiles): + var profile: Dictionary = _demand_profiles[key] + var demand_index := _calculate_demand_index(profile[&"components"]) + profile[&"demand_index"] = demand_index + profile[&"last_evaluated_at"] = minute + values.append({ + &"facility_tag": String(profile[&"facility_tag"]), + &"guest_class": String(profile[&"guest_class"]), + &"time_band": String(profile[&"time_band"]), + &"demand_index": demand_index, + }) + var report := {&"minute": minute, &"values": values} + _processed_demand_minutes[minute] = true + _demand_history.append(report) + _queue_notification(&"demand", [report.duplicate(true)]) + return _success(report) + + +func _process_arrival_boundary(minute: int) -> CommandResult: + if _processed_arrival_minutes.has(minute): + var replay := _success({&"minute": minute}) + replay.is_replay = true + return replay + var active_band := _time_band_for_minute(minute) + var denominator := int(_config[&"arrival_rate_denominator"]) + var numerator_rate := int(_config[&"arrival_rate_numerator"]) + var carry_candidate := _arrival_carry.duplicate(true) + var intent_rows: Array[Dictionary] = [] + for key: String in _sorted_string_keys(_demand_profiles): + var profile: Dictionary = _demand_profiles[key] + if profile[&"time_band"] not in [active_band, &"ANY"]: + continue + var raw := int(carry_candidate.get(key, 0)) \ + + int(profile[&"demand_index"]) * numerator_rate + var desired := raw / denominator + intent_rows.append({ + &"key": key, + &"facility_tag": profile[&"facility_tag"], + &"guest_class": profile[&"guest_class"], + &"time_band": profile[&"time_band"], + &"demand_index": int(profile[&"demand_index"]), + &"raw_numerator": raw, + &"desired_count": desired, + }) + var fair_start_index := 0 + if not intent_rows.is_empty(): + fair_start_index = posmod( + int(_config[&"world_seed"]) + _fair_rotation_counter, + intent_rows.size() + ) + intent_rows = _rotated_dictionary_rows(intent_rows, fair_start_index) + + var local_capacity := int(_config[&"population_cap"]) - _population_count + var capture_payload := { + &"operation": &"ARRIVAL_PLAN", + &"minute": minute, + &"time_band": active_band, + &"population_count": _population_count, + &"population_cap": int(_config[&"population_cap"]), + &"fair_rotation_counter": _fair_rotation_counter, + &"fair_start_index": fair_start_index, + &"intent_rows": intent_rows.duplicate(true), + } + var capture := _call_boundary( + _arrival_boundary, + capture_payload, + PHASE_CAPTURE, + null, + {&"ok": true, &"token": null, &"capacity_remaining": local_capacity} + ) + var token: Variant = capture.get(&"token", null) + if not _boundary_result_succeeded(capture): + return _failed_boundary_after_capture( + _arrival_boundary, capture_payload, token, + &"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_FAILED" + ) + var capacity_value: Variant = capture.get(&"capacity_remaining", null) + if not _is_integral_value(capacity_value) or int(capacity_value) < 0: + return _failed_boundary_after_capture( + _arrival_boundary, capture_payload, token, + &"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID" + ) + var external_capacity := int(capacity_value) + var remaining := mini( + mini(local_capacity, external_capacity), + int(_config[&"max_arrivals_per_interval"]) + ) + var plan_sequence := _next_arrival_plan_sequence + var guest_sequence := _next_guest_sequence + var plans: Array[Dictionary] = [] + var total_count := 0 + var capped_rows: Array[Dictionary] = [] + for row: Dictionary in intent_rows: + var accepted := mini(int(row[&"desired_count"]), remaining) + var key: String = row[&"key"] + var retained := int(row[&"raw_numerator"]) - accepted * denominator + var capped_retained := mini(retained, _max_carry_numerator()) + carry_candidate[key] = capped_retained + if retained > capped_retained: + capped_rows.append({ + &"facility_tag": String(row[&"facility_tag"]), + &"guest_class": String(row[&"guest_class"]), + &"discarded_numerator": retained - capped_retained, + }) + if accepted <= 0: + continue + if plan_sequence > MAX_SEQUENCE_VALUE \ + or guest_sequence > MAX_SEQUENCE_VALUE - accepted + 1: + return _failed_boundary_after_capture( + _arrival_boundary, capture_payload, token, + &"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ID_SEQUENCE_EXHAUSTED" + ) + var guest_ids: Array[String] = [] + for _index in accepted: + guest_ids.append("guest-auto-%08d" % guest_sequence) + guest_sequence += 1 + plans.append({ + &"plan_id": "arrival-plan-%08d" % plan_sequence, + &"minute": minute, + &"facility_tag": String(row[&"facility_tag"]), + &"guest_class": String(row[&"guest_class"]), + &"time_band": String(row[&"time_band"]), + &"demand_index": int(row[&"demand_index"]), + &"count": accepted, + &"guest_ids": guest_ids, + }) + plan_sequence += 1 + total_count += accepted + remaining -= accepted + + var payload := capture_payload.duplicate(true) + payload[&"plans"] = plans + payload[&"total_count"] = total_count + payload[&"idempotency_key"] = "arrival:%d" % minute + var validated := _call_boundary( + _arrival_boundary, + payload, + PHASE_VALIDATE, + token, + {&"ok": true, &"token": token} + ) + if not _boundary_result_succeeded(validated): + return _failed_boundary_after_capture( + _arrival_boundary, payload, token, + &"ARRIVAL_PLAN", minute, PHASE_VALIDATE, &"ARRIVAL_VALIDATE_FAILED" + ) + token = validated.get(&"token", token) + var applied := _call_boundary( + _arrival_boundary, + payload, + PHASE_APPLY, + token, + {&"ok": true, &"accepted_count": total_count} + ) + var accepted_value: Variant = applied.get(&"accepted_count", null) + if not _boundary_result_succeeded(applied) \ + or not _is_integral_value(accepted_value) or int(accepted_value) != total_count: + return _failed_boundary_after_capture( + _arrival_boundary, payload, token, + &"ARRIVAL_PLAN", minute, PHASE_APPLY, &"ARRIVAL_APPLY_FAILED" + ) + + _arrival_carry = carry_candidate + _next_arrival_plan_sequence = plan_sequence + _next_guest_sequence = guest_sequence + _arrival_plan_high_water = plan_sequence - 1 + _guest_high_water = guest_sequence - 1 + _fair_rotation_counter += 1 + _population_count += total_count + var report := { + &"minute": minute, + &"time_band": String(active_band), + &"fair_rotation_counter": _fair_rotation_counter - 1, + &"fair_start_index": fair_start_index, + &"plans": plans, + &"total_count": total_count, + &"population_after": _population_count, + &"capacity_remaining": int(_config[&"population_cap"]) - _population_count, + &"carry_capped": capped_rows, + } + _processed_arrival_minutes[minute] = true + _arrival_reports.append(report) + if not capped_rows.is_empty(): + _append_alert({ + &"code": &"ARRIVAL_CARRY_CAPPED", + &"minute": minute, + &"rows": capped_rows, + }) + _queue_notification(&"arrivals", [report.duplicate(true)]) + return _success(report) + + +func _process_daily_close(boundary_minute: int) -> CommandResult: + var completed_day := _financial_day_for_boundary(boundary_minute) + if _day_summaries.has(completed_day): + var replay := _success({&"day": completed_day}) + replay.is_replay = true + return replay + var payload := { + &"operation": &"DAILY_CLOSE", + &"day": completed_day, + &"boundary_minute": boundary_minute, + &"period_start_minute": boundary_minute - MINUTES_PER_DAY, + &"period_end_minute_exclusive": boundary_minute, + &"idempotency_key": "daily-close:%d" % completed_day, + &"prior_arrears_total": _arrears_total, + &"steps": [ + &"FINALIZE_PENDING_SERVICES", + &"AGGREGATE_LEDGER_CATEGORIES", + &"RECONCILE_LEDGER", + &"GENERATE_FACILITY_PROFIT_LOSS", + &"DETECT_ANOMALIES", + &"BUILD_DAY_SUMMARY", + &"EVALUATE_ARREARS_AND_BANKRUPTCY", + ], + } + var transaction := _run_boundary_transaction( + _daily_close_boundary, + payload, + _default_daily_apply_result() + ) + if not bool(transaction[&"ok"]): + return _transaction_boundary_failure( + transaction, &"DAILY_CLOSE", boundary_minute, &"DAILY_CLOSE_FAILED" + ) + var applied: Dictionary = transaction[&"result"] + var normalized := _normalize_daily_result(applied) + if not bool(normalized[&"ok"]): + var rollback := _rollback_boundary_result( + _daily_close_boundary, + payload, + transaction.get(&"token", null) + ) + if not _boundary_result_succeeded(rollback): + _enter_external_quarantine( + &"DAILY_CLOSE", boundary_minute, &"RESULT_SCHEMA", &"DAILY_CLOSE_RESULT_INVALID" + ) + return _boundary_failure( + "EXTERNAL_ROLLBACK_FAILED", + {&"operation": &"DAILY_CLOSE", &"phase": &"RESULT_SCHEMA"} + ) + return _boundary_failure( + "DAILY_CLOSE_RESULT_INVALID", + { + &"reason": normalized[&"error"], + &"rollback_ok": true, + } + ) + + var result: Dictionary = normalized[&"result"] + var category_totals: Dictionary = result[&"category_totals"] + var net_profit := _net_profit(category_totals) + var previous_profit := net_profit + if _day_summaries.has(completed_day - 1): + previous_profit = int((_day_summaries[completed_day - 1] as Dictionary)[&"net_profit"]) + var rolling := _rolling_profit_with(net_profit) + var arrears := int((result[&"settlement"] as Dictionary)[&"arrears_total"]) + var consecutive := _consecutive_insolvency_count + 1 if arrears > 0 else 0 + var future_terminal := _terminal_state + if consecutive >= int(_config[&"bankruptcy_close_count"]): + future_terminal = TERMINAL_BANKRUPT + var reconciliation_difference := int(result[&"reconciliation_difference"]) + var summary := { + &"day": completed_day, + &"period_start_minute": boundary_minute - MINUTES_PER_DAY, + &"period_end_minute_exclusive": boundary_minute, + &"closed_at": boundary_minute, + &"category_totals": category_totals, + &"net_profit": net_profit, + &"previous_day_delta": net_profit - previous_profit, + &"seven_day_average": rolling[&"quotient"], + &"seven_day_average_detail": rolling, + &"facility_profit_loss": result[&"facility_profit_loss"], + &"anomalies": result[&"anomalies"], + &"reconciliation_difference": reconciliation_difference, + &"balanced": reconciliation_difference == 0, + &"settlement": result[&"settlement"], + &"arrears_total": arrears, + &"consecutive_insolvency_count": consecutive, + &"terminal_state": String(future_terminal), + } + + # Commit coordinator state only after the external apply result and every + # derived summary field have passed validation. + _day_summaries[completed_day] = summary + _arrears_total = arrears + _consecutive_insolvency_count = consecutive + if reconciliation_difference != 0: + pause_requested = true + _pause_reason = &"LEDGER_IMBALANCE" + _append_alert({ + &"code": &"LEDGER_IMBALANCE", + &"minute": boundary_minute, + &"day": completed_day, + &"difference": reconciliation_difference, + &"automatic_correction": false, + }) + if arrears > 0: + _append_alert({ + &"code": &"ARREARS_REMAIN", + &"minute": boundary_minute, + &"day": completed_day, + &"arrears_total": arrears, + &"consecutive_count": consecutive, + }) + if future_terminal != _terminal_state: + var old_state := _terminal_state + _terminal_state = future_terminal + _append_alert({ + &"code": &"BANKRUPT", + &"minute": boundary_minute, + &"day": completed_day, + &"arrears_total": arrears, + &"consecutive_count": consecutive, + }) + _queue_notification( + &"terminal", + [old_state, _terminal_state, &"CONSECUTIVE_INSOLVENCY"] + ) + _queue_notification(&"daily", [summary.duplicate(true)]) + return _success(summary) + + +func _process_morning_boundary(boundary_minute: int) -> CommandResult: + var day := boundary_minute / MINUTES_PER_DAY + 1 + if _morning_reports.has(day): + var replay := _success({&"day": day}) + replay.is_replay = true + return replay + var payload := { + &"operation": &"MORNING_BOUNDARY", + &"day": day, + &"boundary_minute": boundary_minute, + &"idempotency_key": "morning:%d" % day, + &"steps": [ + &"START_DAY_SHIFT", + &"RESTORE_PERSONAL_ACTION_POINTS", + &"ROLL_INSPECTION", + &"RESOLVE_MORNING_EVENTS", + ], + } + var transaction := _run_boundary_transaction( + _morning_boundary, + payload, + {&"ok": true, &"event_payload": {}} + ) + if not bool(transaction[&"ok"]): + return _transaction_boundary_failure( + transaction, &"MORNING_BOUNDARY", boundary_minute, &"MORNING_BOUNDARY_FAILED" + ) + var applied: Dictionary = transaction[&"result"] + var event_payload: Variant = applied.get(&"event_payload", {}) + if not event_payload is Dictionary or not _variant_tree_report(event_payload)[&"ok"]: + var rollback := _rollback_boundary_result( + _morning_boundary, + payload, + transaction.get(&"token", null) + ) + if not _boundary_result_succeeded(rollback): + _enter_external_quarantine( + &"MORNING_BOUNDARY", boundary_minute, &"RESULT_SCHEMA", &"MORNING_RESULT_INVALID" + ) + return _boundary_failure( + "EXTERNAL_ROLLBACK_FAILED", + {&"operation": &"MORNING_BOUNDARY", &"phase": &"RESULT_SCHEMA"} + ) + return _boundary_failure( + "MORNING_RESULT_INVALID", + {&"rollback_ok": true} + ) + var report := { + &"day": day, + &"minute": boundary_minute, + &"event_payload": (event_payload as Dictionary).duplicate(true), + } + _morning_reports[day] = report + _queue_notification(&"morning", [report.duplicate(true)]) + return _success(report) + + +func _run_boundary_transaction( + callback: Callable, + payload: Dictionary, + default_apply_result: Dictionary +) -> Dictionary: + var capture := _call_boundary( + callback, + payload, + PHASE_CAPTURE, + null, + {&"ok": true, &"token": null} + ) + var token: Variant = capture.get(&"token", null) + if not _boundary_result_succeeded(capture): + return _transaction_failure(callback, payload, token, PHASE_CAPTURE, capture) + var validation := _call_boundary( + callback, + payload, + PHASE_VALIDATE, + token, + {&"ok": true, &"token": token} + ) + if not _boundary_result_succeeded(validation): + return _transaction_failure(callback, payload, token, PHASE_VALIDATE, validation) + token = validation.get(&"token", token) + var applied := _call_boundary( + callback, + payload, + PHASE_APPLY, + token, + default_apply_result + ) + if not _boundary_result_succeeded(applied): + return _transaction_failure(callback, payload, token, PHASE_APPLY, applied) + return {&"ok": true, &"result": applied, &"token": token} + + +func _transaction_failure( + callback: Callable, + payload: Dictionary, + token: Variant, + phase: StringName, + result: Dictionary +) -> Dictionary: + var rollback := _rollback_boundary_result(callback, payload, token) + return { + &"ok": false, + &"phase": phase, + &"result": result, + &"token": token, + &"rollback_ok": _boundary_result_succeeded(rollback), + } + + +func _rollback_boundary_result(callback: Callable, payload: Dictionary, token: Variant) -> Dictionary: + return _call_boundary( + callback, + payload, + PHASE_ROLLBACK, + token, + {&"ok": true} + ) + + +func _transaction_boundary_failure( + transaction: Dictionary, + operation: StringName, + minute: int, + failure_code: StringName +) -> CommandResult: + if not bool(transaction.get(&"rollback_ok", false)): + _enter_external_quarantine( + operation, + minute, + StringName(transaction.get(&"phase", &"UNKNOWN")), + failure_code + ) + return _boundary_failure( + "EXTERNAL_ROLLBACK_FAILED", + {&"operation": operation, &"phase": transaction.get(&"phase", &"UNKNOWN")} + ) + return _boundary_failure(failure_code, { + &"operation": operation, + &"phase": transaction.get(&"phase", &"UNKNOWN"), + &"rollback_ok": true, + }) + + +func _failed_boundary_after_capture( + callback: Callable, + payload: Dictionary, + token: Variant, + operation: StringName, + minute: int, + phase: StringName, + failure_code: StringName +) -> CommandResult: + var rollback := _rollback_boundary_result(callback, payload, token) + if not _boundary_result_succeeded(rollback): + _enter_external_quarantine(operation, minute, phase, failure_code) + return _boundary_failure( + "EXTERNAL_ROLLBACK_FAILED", + {&"operation": operation, &"phase": phase} + ) + return _boundary_failure(failure_code, { + &"operation": operation, + &"phase": phase, + &"rollback_ok": true, + }) + + +func _enter_external_quarantine( + operation: StringName, + minute: int, + failed_phase: StringName, + original_code: StringName +) -> void: + if _external_quarantined: + return + _external_quarantined = true + pause_requested = true + _pause_reason = &"EXTERNAL_ROLLBACK_FAILED" + _quarantine_report = { + &"code": &"EXTERNAL_ROLLBACK_FAILED", + &"operation": operation, + &"minute": minute, + &"failed_phase": failed_phase, + &"original_code": original_code, + &"recovery_required": true, + } + _append_alert(_quarantine_report) + + +static func _boundary_result_succeeded(result: Dictionary) -> bool: + var ok_value: Variant = result.get(&"ok", result.get("ok", null)) + return typeof(ok_value) == TYPE_BOOL and bool(ok_value) + + +func _call_boundary( + callback: Callable, + payload: Dictionary, + phase: StringName, + token: Variant, + default_result: Dictionary +) -> Dictionary: + var payload_report := _variant_tree_report(payload) + if not bool(payload_report[&"ok"]): + return {&"ok": false, &"reason": payload_report[&"code"]} + if not callback.is_valid(): + return default_result.duplicate(true) + var value: Variant = callback.call(payload.duplicate(true), phase, token) + if not value is Dictionary: + return {&"ok": false, &"reason": &"NON_DICTIONARY_RESULT"} + var result: Dictionary = value + if not _canonical_key_collision(result).is_empty(): + return {&"ok": false, &"reason": &"DUPLICATE_CANONICAL_KEY"} + # The rollback token is intentionally ephemeral and may be an external + # snapshot handle. Every other returned value must obey payload budgets. + var inspectable := result.duplicate(false) + inspectable.erase(&"token") + inspectable.erase("token") + var report := _variant_tree_report(inspectable) + if not bool(report[&"ok"]): + return {&"ok": false, &"reason": report[&"code"]} + if int(report[&"nodes"]) > MAX_CALLBACK_VARIANT_NODES \ + or int(report[&"string_length"]) > MAX_CALLBACK_STRING_LENGTH: + return {&"ok": false, &"reason": &"CALLBACK_PAYLOAD_BUDGET"} + return result.duplicate(true) + + +func _normalize_daily_result(source: Dictionary) -> Dictionary: + if not _canonical_key_collision(source).is_empty(): + return {&"ok": false, &"error": "Daily result has duplicate canonical keys."} + var categories_value: Variant = _value(source, &"category_totals", null) + var facilities_value: Variant = _value(source, &"facility_profit_loss", null) + var anomalies_value: Variant = _value(source, &"anomalies", null) + var settlement_value: Variant = _value(source, &"settlement", null) + if not categories_value is Dictionary \ + or not facilities_value is Dictionary \ + or not anomalies_value is Array \ + or not settlement_value is Dictionary: + return {&"ok": false, &"error": "Daily close result schema is incomplete."} + var categories := _normalize_categories(categories_value) + if not bool(categories[&"ok"]): + return categories + var facilities := _normalize_facility_profit_loss(facilities_value) + if not bool(facilities[&"ok"]): + return facilities + if not _variant_tree_report(anomalies_value)[&"ok"]: + return {&"ok": false, &"error": "Daily anomalies contain an unsaveable value."} + for anomaly: Variant in anomalies_value: + if not anomaly is Dictionary: + return {&"ok": false, &"error": "Daily anomaly rows must be dictionaries."} + var difference_value: Variant = _value(source, &"reconciliation_difference", null) + if not _is_integral_value(difference_value): + return {&"ok": false, &"error": "Reconciliation difference must be an integer."} + if int(difference_value) < -MAX_FINANCIAL_AMOUNT \ + or int(difference_value) > MAX_FINANCIAL_AMOUNT: + return {&"ok": false, &"error": "Reconciliation difference exceeds checked limits."} + var arrears_value: Variant = _value(settlement_value, &"arrears_total", null) + if not _is_integral_value(arrears_value) or int(arrears_value) < 0 \ + or int(arrears_value) > MAX_FINANCIAL_AMOUNT: + return {&"ok": false, &"error": "Settlement arrears must be a non-negative integer."} + if not _variant_tree_report(settlement_value)[&"ok"]: + return {&"ok": false, &"error": "Settlement contains an unsaveable value."} + var settlement := (settlement_value as Dictionary).duplicate(true) + settlement[&"arrears_total"] = int(arrears_value) + return { + &"ok": true, + &"result": { + &"category_totals": categories[&"categories"], + &"facility_profit_loss": facilities[&"facilities"], + &"anomalies": (anomalies_value as Array).duplicate(true), + &"reconciliation_difference": int(difference_value), + &"settlement": settlement, + }, + } + + +func _normalize_categories(source: Dictionary) -> Dictionary: + var collision := _canonical_key_collision(source) + if not collision.is_empty(): + return {&"ok": false, &"error": "Daily categories contain duplicate canonical keys."} + for raw_key: Variant in source: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(str(raw_key)) not in DAILY_CATEGORIES: + return { + &"ok": false, + &"error": "Unknown financial category requires explicit classification: %s" % raw_key, + } + var result: Dictionary = {} + for category: StringName in DAILY_CATEGORIES: + var value: Variant = _value(source, category, 0) + if not _is_integral_value(value) or int(value) < 0 \ + or int(value) > MAX_FINANCIAL_AMOUNT: + return { + &"ok": false, + &"error": "Daily category '%s' must be a non-negative integer." % category, + } + result[category] = int(value) + return {&"ok": true, &"categories": result} + + +func _normalize_facility_profit_loss(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var facility_ids: Array = source.keys() + facility_ids.sort_custom(_variant_string_less) + for raw_id: Variant in facility_ids: + if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"error": "Facility P/L IDs must be strings."} + var facility_id := StringName(str(raw_id)) + var row_value: Variant = source[raw_id] + if facility_id.is_empty() or not row_value is Dictionary: + return {&"ok": false, &"error": "Facility P/L row is malformed."} + var row_source: Dictionary = row_value + if not _canonical_key_collision(row_source).is_empty(): + return {&"ok": false, &"error": "Facility P/L row has duplicate canonical keys."} + for row_key: Variant in row_source: + if StringName(row_key) not in [&"revenue", &"expenses", &"net_profit"]: + return {&"ok": false, &"error": "Facility P/L row contains an unknown field."} + var revenue_value: Variant = _value(row_source, &"revenue", null) + var expense_value: Variant = _value(row_source, &"expenses", null) + if not _is_integral_value(revenue_value) or int(revenue_value) < 0 \ + or int(revenue_value) > MAX_FINANCIAL_AMOUNT \ + or not _is_integral_value(expense_value) or int(expense_value) < 0 \ + or int(expense_value) > MAX_FINANCIAL_AMOUNT: + return {&"ok": false, &"error": "Facility P/L values must be non-negative integers."} + var expected_net := int(revenue_value) - int(expense_value) + var supplied_net: Variant = _value(row_source, &"net_profit", expected_net) + if not _is_integral_value(supplied_net) or int(supplied_net) != expected_net: + return {&"ok": false, &"error": "Facility P/L net does not reconcile."} + result[facility_id] = { + &"revenue": int(revenue_value), + &"expenses": int(expense_value), + &"net_profit": expected_net, + } + return {&"ok": true, &"facilities": result} + + +func _default_daily_apply_result() -> Dictionary: + return { + &"ok": true, + &"category_totals": {}, + &"reconciliation_difference": 0, + &"facility_profit_loss": {}, + &"anomalies": [], + &"settlement": {&"arrears_total": _arrears_total}, + } + + +func _net_profit(categories: Dictionary) -> int: + return int(categories.get(&"revenue", 0)) \ + - int(categories.get(&"refunds", 0)) \ + - int(categories.get(&"cost_of_goods", 0)) \ + - int(categories.get(&"wages", 0)) \ + - int(categories.get(&"maintenance", 0)) \ + - int(categories.get(&"utilities", 0)) \ + - int(categories.get(&"taxes_and_interest", 0)) \ + - int(categories.get(&"fines", 0)) + + +func _rolling_profit_with(current_profit: int) -> Dictionary: + var values: Array[int] = [] + var days := _sorted_int_keys(_day_summaries) + var start := maxi(days.size() - 6, 0) + for index in range(start, days.size()): + values.append(int((_day_summaries[days[index]] as Dictionary)[&"net_profit"])) + values.append(current_profit) + var total := 0 + for value: int in values: + total += value + return { + &"sum": total, + &"count": values.size(), + &"quotient": total / values.size(), + &"remainder": total % values.size(), + } + + +func _append_alert(alert: Dictionary) -> void: + var safe := alert.duplicate(true) + _alerts.append(safe) + _queue_notification(&"alert", [safe.duplicate(true)]) + + +func _queue_notification(kind: StringName, arguments: Array) -> void: + _notification_queue.append({&"kind": kind, &"arguments": arguments.duplicate(true)}) + + +func _flush_notifications() -> void: + var queued := _notification_queue.duplicate(true) + _notification_queue.clear() + for item: Dictionary in queued: + var arguments: Array = item[&"arguments"] + match StringName(item[&"kind"]): + &"demand": + demand_updated.emit(arguments[0]) + &"arrivals": + arrivals_committed.emit(arguments[0]) + &"daily": + daily_close_completed.emit(arguments[0]) + &"morning": + morning_boundary_completed.emit(arguments[0]) + &"alert": + operations_alerted.emit(arguments[0]) + &"terminal": + terminal_state_changed.emit(arguments[0], arguments[1], arguments[2]) + + +func _normalize_config(overrides: Dictionary) -> Dictionary: + if not _canonical_key_collision(overrides).is_empty(): + return {&"ok": false, &"error": "Operations config has duplicate canonical keys."} + var result := DEFAULT_CONFIG.duplicate(true) + for raw_key: Variant in overrides: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"error": "Operations config keys must be strings."} + var key := StringName(str(raw_key)) + if not result.has(key): + return {&"ok": false, &"error": "Unknown operations config key: %s" % key} + var value: Variant = overrides[raw_key] + if not _is_integral_value(value): + return {&"ok": false, &"error": "Operations config values must be integers."} + result[key] = int(value) + if int(result[&"world_seed"]) < 0: + return {&"ok": false, &"error": "World seed must be non-negative."} + if int(result[&"world_seed"]) > MAX_WORLD_SEED: + return {&"ok": false, &"error": "World seed exceeds the supported deterministic range."} + for positive_key: StringName in [ + &"population_cap", + &"max_arrivals_per_interval", + &"arrival_rate_numerator", + &"arrival_rate_denominator", + &"arrival_carry_interval_limit", + &"max_component_magnitude", + &"bankruptcy_close_count", + ]: + if int(result[positive_key]) <= 0: + return {&"ok": false, &"error": "Operations config requires positive limits."} + if int(result[&"max_arrivals_per_interval"]) > int(result[&"population_cap"]): + return {&"ok": false, &"error": "Interval arrival cap cannot exceed population cap."} + if int(result[&"bankruptcy_close_count"]) > 100: + return {&"ok": false, &"error": "Bankruptcy threshold is outside a safe range."} + if int(result[&"population_cap"]) > 1_000 \ + or int(result[&"max_arrivals_per_interval"]) > 100 \ + or int(result[&"arrival_rate_numerator"]) > 1_000_000 \ + or int(result[&"arrival_rate_denominator"]) > 1_000_000 \ + or int(result[&"arrival_carry_interval_limit"]) > MAX_TRACKED_DAYS \ + or int(result[&"max_component_magnitude"]) > 1_000_000: + return {&"ok": false, &"error": "Operations config exceeds checked-arithmetic limits."} + return {&"ok": true, &"config": result} + + +func _normalize_demand_components(source: Dictionary) -> Dictionary: + return _normalize_demand_components_for_limit( + source, + int(_config[&"max_component_magnitude"]) + ) + + +func _normalize_demand_components_for_limit(source: Dictionary, magnitude_limit: int) -> Dictionary: + if not _canonical_key_collision(source).is_empty(): + return {&"ok": false, &"error": "Demand input has duplicate canonical keys."} + if source.size() != DEMAND_COMPONENTS.size(): + return {&"ok": false, &"error": "Demand input must contain exactly six components."} + var result: Dictionary = {} + for component: StringName in DEMAND_COMPONENTS: + if not source.has(component) and not source.has(String(component)): + return {&"ok": false, &"error": "Demand component is missing: %s" % component} + var value: Variant = _value(source, component, null) + if not _is_finite_numeric(value): + return {&"ok": false, &"error": "Demand component is not finite: %s" % component} + if float(value) < -float(magnitude_limit) or float(value) > float(magnitude_limit): + return {&"ok": false, &"error": "Demand component exceeds the configured bound."} + var normalized_value := int(round(float(value))) + if normalized_value < -magnitude_limit or normalized_value > magnitude_limit: + return {&"ok": false, &"error": "Demand component exceeds the configured bound."} + if component in [&"base", &"unmet_need", &"event", &"recent_price_penalty", &"wait_penalty"] \ + and normalized_value < 0: + return {&"ok": false, &"error": "Unsigned demand component cannot be negative."} + result[component] = normalized_value + return {&"ok": true, &"components": result} + + +func _calculate_demand_index(components: Dictionary) -> int: + return clampi( + int(components[&"base"]) + + int(components[&"reputation"]) + + int(components[&"unmet_need"]) + + int(components[&"event"]) + - int(components[&"recent_price_penalty"]) + - int(components[&"wait_penalty"]), + 0, + 200 + ) + + +func _validate_demand_identity( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName +) -> String: + if facility_tag.is_empty() or guest_class.is_empty() or time_band.is_empty(): + return "Demand identity fields cannot be empty." + if time_band not in TIME_BANDS: + return "Demand time band is not recognized." + if KEY_SEPARATOR in String(facility_tag) or KEY_SEPARATOR in String(guest_class): + return "Demand identity contains a reserved separator." + if String(facility_tag).length() > 128 or String(guest_class).length() > 128: + return "Demand identity is too long." + return "" + + +func _demand_key( + facility_tag: StringName, + guest_class: StringName, + time_band: StringName +) -> String: + return "%s%s%s%s%s" % [facility_tag, KEY_SEPARATOR, guest_class, KEY_SEPARATOR, time_band] + + +func _time_band_for_minute(absolute_minute: int) -> StringName: + var minute_of_day := posmod(absolute_minute, MINUTES_PER_DAY) + if minute_of_day >= 360 and minute_of_day < 600: + return &"MORNING" + if minute_of_day >= 600 and minute_of_day < 960: + return &"DAY" + if minute_of_day >= 960 and minute_of_day < 1_200: + return &"EVENING" + return &"NIGHT" + + +func _max_carry_numerator() -> int: + return int(_config[&"arrival_rate_denominator"]) \ + * int(_config[&"max_arrivals_per_interval"]) \ + * int(_config[&"arrival_carry_interval_limit"]) + + +func _dictionary_has_exact_string_keys( + source: Dictionary, + expected: Array[StringName] +) -> bool: + if source.size() != expected.size(): + return false + var seen: Dictionary = {} + for raw_key: Variant in source: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var key := StringName(raw_key) + if not expected.has(key) or seen.has(key): + return false + seen[key] = true + return seen.size() == expected.size() + + +func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String: + var top_keys: Array[StringName] = [ + &"schema_version", &"origin_minute", &"current_minute", &"config", + &"demand_profiles", &"arrival_carry", &"demand_history", + &"arrival_reports", &"day_summaries", &"morning_reports", &"alerts", + &"processed_demand_minutes", &"processed_arrival_minutes", + &"population_count", &"arrears_total", &"consecutive_insolvency_count", + &"terminal_state", &"pause_requested", &"next_arrival_plan_sequence", + &"next_guest_sequence", &"arrival_plan_high_water", &"guest_high_water", + &"fair_rotation_counter", &"pending_boundary_minute", + &"deferred_boundary_minute", &"pause_reason", &"external_quarantined", + &"quarantine_report", + ] + if not _dictionary_has_exact_string_keys(snapshot, top_keys): + return "Operations v3 snapshot has missing or unknown root fields." + + var profiles: Variant = _value(snapshot, &"demand_profiles", null) + var carry: Variant = _value(snapshot, &"arrival_carry", null) + var demand_history: Variant = _value(snapshot, &"demand_history", null) + var arrivals: Variant = _value(snapshot, &"arrival_reports", null) + var days: Variant = _value(snapshot, &"day_summaries", null) + var mornings: Variant = _value(snapshot, &"morning_reports", null) + if not profiles is Array or not carry is Array or not demand_history is Array \ + or not arrivals is Array or not days is Array or not mornings is Array: + return "Operations v3 snapshot history collections are malformed." + for raw_profile: Variant in profiles: + if not raw_profile is Dictionary or not _dictionary_has_exact_string_keys( + raw_profile, + [&"facility_tag", &"guest_class", &"time_band", &"components", + &"demand_index", &"last_evaluated_at"] + ): + return "Operations v3 demand profile has missing or unknown fields." + for raw_carry: Variant in carry: + if not raw_carry is Dictionary or not _dictionary_has_exact_string_keys( + raw_carry, [&"facility_tag", &"guest_class", &"time_band", &"numerator"] + ): + return "Operations v3 arrival carry row has missing or unknown fields." + for raw_report: Variant in demand_history: + if not raw_report is Dictionary or not _dictionary_has_exact_string_keys( + raw_report, [&"minute", &"values"] + ): + return "Operations v3 demand history row has missing or unknown fields." + var values: Variant = (raw_report as Dictionary).get(&"values", null) + if not values is Array: + return "Operations v3 demand history values are malformed." + for raw_value: Variant in values: + if not raw_value is Dictionary or not _dictionary_has_exact_string_keys( + raw_value, + [&"facility_tag", &"guest_class", &"time_band", &"demand_index"] + ): + return "Operations v3 demand history value has missing or unknown fields." + for raw_report: Variant in arrivals: + if not raw_report is Dictionary or not _dictionary_has_exact_string_keys( + raw_report, + [&"minute", &"time_band", &"fair_rotation_counter", &"fair_start_index", + &"plans", &"total_count", &"population_after", &"capacity_remaining", + &"carry_capped"] + ): + return "Operations v3 arrival report has missing or unknown fields." + var plans: Variant = (raw_report as Dictionary).get(&"plans", null) + var capped: Variant = (raw_report as Dictionary).get(&"carry_capped", null) + if not plans is Array or not capped is Array: + return "Operations v3 arrival report collections are malformed." + for raw_plan: Variant in plans: + if not raw_plan is Dictionary or not _dictionary_has_exact_string_keys( + raw_plan, + [&"plan_id", &"minute", &"facility_tag", &"guest_class", &"time_band", + &"demand_index", &"count", &"guest_ids"] + ): + return "Operations v3 arrival plan has missing or unknown fields." + for raw_capped: Variant in capped: + if not raw_capped is Dictionary or not _dictionary_has_exact_string_keys( + raw_capped, [&"facility_tag", &"guest_class", &"discarded_numerator"] + ): + return "Operations v3 capped carry row has missing or unknown fields." + for raw_day: Variant in days: + if not raw_day is Dictionary or not _dictionary_has_exact_string_keys( + raw_day, + [&"day", &"period_start_minute", &"period_end_minute_exclusive", + &"closed_at", &"category_totals", &"net_profit", &"previous_day_delta", + &"seven_day_average", &"seven_day_average_detail", &"facility_profit_loss", + &"anomalies", &"reconciliation_difference", &"balanced", &"settlement", + &"arrears_total", &"consecutive_insolvency_count", &"terminal_state"] + ): + return "Operations v3 DaySummary has missing or unknown fields." + var detail: Variant = (raw_day as Dictionary).get(&"seven_day_average_detail", null) + if not detail is Dictionary or not _dictionary_has_exact_string_keys( + detail, [&"sum", &"count", &"quotient", &"remainder"] + ): + return "Operations v3 rolling average evidence has missing or unknown fields." + var anomalies: Variant = (raw_day as Dictionary).get(&"anomalies", null) + if not anomalies is Array: + return "Operations v3 DaySummary anomalies are malformed." + for raw_anomaly: Variant in anomalies: + if not raw_anomaly is Dictionary: + return "Operations v3 DaySummary anomaly row is malformed." + var anomaly: Dictionary = raw_anomaly + var code_value: Variant = anomaly.get(&"code", null) + if typeof(code_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return "Operations v3 DaySummary anomaly code is malformed." + match StringName(code_value): + &"WAGE_ALLOCATION_EVIDENCE": + if not _dictionary_has_exact_string_keys( + anomaly, + [&"code", &"version", &"new_wages_total", &"allocated_total", + &"unallocated_total", &"allocations", &"unallocated_by_staff", + &"facility_ids"] + ) or typeof(anomaly.get(&"version", null)) != TYPE_INT \ + or int(anomaly[&"version"]) != 1 \ + or typeof(anomaly.get(&"new_wages_total", null)) != TYPE_INT \ + or typeof(anomaly.get(&"allocated_total", null)) != TYPE_INT \ + or typeof(anomaly.get(&"unallocated_total", null)) != TYPE_INT \ + or not anomaly.get(&"allocations", null) is Array \ + or not anomaly.get(&"unallocated_by_staff", null) is Array \ + or not anomaly.get(&"facility_ids", null) is Array: + return "Operations v3 wage allocation evidence is malformed." + for raw_row: Variant in anomaly[&"allocations"]: + if not raw_row is Dictionary or not _dictionary_has_exact_string_keys( + raw_row, [&"facility_id", &"amount"] + ): + return "Operations v3 wage allocation row is malformed." + for raw_row: Variant in anomaly[&"unallocated_by_staff"]: + if not raw_row is Dictionary or not _dictionary_has_exact_string_keys( + raw_row, [&"staff_id", &"amount"] + ): + return "Operations v3 unallocated wage evidence is malformed." + &"UNALLOCATED_WAGES": + if not _dictionary_has_exact_string_keys( + anomaly, [&"code", &"staff_id", &"amount"] + ): + return "Operations v3 unallocated wage anomaly is malformed." + &"OPERATING_REFUND_EXCEEDS_PERIOD_COST": + if not _dictionary_has_exact_string_keys( + anomaly, + [&"code", &"refund_total", &"cost_offset", &"excess_revenue"] + ): + return "Operations v3 operating refund anomaly is malformed." + _: + return "Operations v3 DaySummary anomaly code is unknown." + for raw_morning: Variant in mornings: + if not raw_morning is Dictionary or not _dictionary_has_exact_string_keys( + raw_morning, [&"day", &"minute", &"event_payload"] + ): + return "Operations v3 morning report has missing or unknown fields." + return "" + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + var tree_report := _variant_tree_report(snapshot) + if not bool(tree_report[&"ok"]): + return { + &"ok": false, + &"error": "Operations snapshot Variant tree is invalid: %s" % tree_report[&"code"], + } + var version_value: Variant = _value(snapshot, &"schema_version", null) + if not _is_integral_value(version_value): + return {&"ok": false, &"error": "Operations snapshot version must be an integer."} + var version := int(version_value) + if version < MIN_SUPPORTED_SNAPSHOT_VERSION or version > SNAPSHOT_VERSION: + return {&"ok": false, &"error": "Unsupported operations snapshot version."} + if version == SNAPSHOT_VERSION: + var exact_shape_error := _current_snapshot_exact_shape_error(snapshot) + if not exact_shape_error.is_empty(): + return {&"ok": false, &"error": exact_shape_error} + var config_value: Variant = _value(snapshot, &"config", null) + if not config_value is Dictionary: + return {&"ok": false, &"error": "Operations snapshot has no config."} + var normalized_config := _normalize_config(config_value) + if not bool(normalized_config[&"ok"]): + return normalized_config + var current_value: Variant = _value(snapshot, &"current_minute", null) + if not _is_integral_value(current_value): + return {&"ok": false, &"error": "Operations snapshot time must be an integer."} + var restored_minute := int(current_value) + # v1/v2 never had an authoritative origin or partial-boundary markers. + # Ignore look-alike future fields on a downgraded payload instead of letting + # them weaken legacy coverage validation. + var origin_value: Variant = _value(snapshot, &"origin_minute", null) if version >= 3 else 0 + if not _is_integral_value(origin_value): + return {&"ok": false, &"error": "Operations snapshot origin must be an integer."} + var restored_origin := int(origin_value) + if restored_origin < 0 or restored_origin > MAX_START_MINUTE \ + or restored_minute < restored_origin \ + or restored_minute > restored_origin + MAX_TRACKED_MINUTES: + return {&"ok": false, &"error": "Operations snapshot time is invalid."} + var pending_value: Variant = _value(snapshot, &"pending_boundary_minute", -1) \ + if version >= 3 else -1 + var deferred_value: Variant = _value(snapshot, &"deferred_boundary_minute", -1) \ + if version >= 3 else -1 + if not _is_integral_value(pending_value) or not _is_integral_value(deferred_value): + return {&"ok": false, &"error": "Pending boundary markers must be integers."} + var pending_minute := int(pending_value) + var deferred_minute := int(deferred_value) + if pending_minute != -1 \ + and (pending_minute != restored_minute + 1 \ + or not _minute_has_scheduled_boundary(pending_minute)): + return {&"ok": false, &"error": "Pending operations boundary is invalid."} + if deferred_minute != -1 \ + and (deferred_minute != restored_minute \ + or not _minute_has_scheduled_boundary(deferred_minute)): + return {&"ok": false, &"error": "Deferred operations boundary is invalid."} + var maximum_committed_minute := pending_minute if pending_minute >= 0 else restored_minute + + var profiles_value: Variant = _value(snapshot, &"demand_profiles", null) + var carry_value: Variant = _value(snapshot, &"arrival_carry", null) + if not profiles_value is Array or not carry_value is Array: + return {&"ok": false, &"error": "Demand snapshot collections are malformed."} + if profiles_value.size() > MAX_SAFE_COLLECTION_SIZE or carry_value.size() > MAX_SAFE_COLLECTION_SIZE: + return {&"ok": false, &"error": "Demand snapshot collections are too large."} + if profiles_value.size() > MAX_DEMAND_PROFILES: + return {&"ok": false, &"error": "Demand snapshot exceeds the profile limit."} + var restored_profiles: Dictionary = {} + for profile_value: Variant in profiles_value: + if not profile_value is Dictionary: + return {&"ok": false, &"error": "Demand profile snapshot row is malformed."} + var facility_tag := StringName(_value(profile_value, &"facility_tag", &"")) + var guest_class := StringName(_value(profile_value, &"guest_class", &"")) + var time_band := StringName(_value(profile_value, &"time_band", &"")) + var identity_error := _validate_demand_identity(facility_tag, guest_class, time_band) + if not identity_error.is_empty(): + return {&"ok": false, &"error": identity_error} + var components_value: Variant = _value(profile_value, &"components", null) + if not components_value is Dictionary: + return {&"ok": false, &"error": "Demand components snapshot is malformed."} + var normalized_components := _normalize_demand_components_for_limit( + components_value, + int((normalized_config[&"config"] as Dictionary)[&"max_component_magnitude"]) + ) + if not bool(normalized_components[&"ok"]): + return normalized_components + var demand_index_value: Variant = _value(profile_value, &"demand_index", null) + var evaluated_value: Variant = _value(profile_value, &"last_evaluated_at", null) + if not _is_integral_value(demand_index_value) or not _is_integral_value(evaluated_value): + return {&"ok": false, &"error": "Demand derived fields must be integers."} + var demand_index := int(demand_index_value) + if demand_index != _calculate_demand_index(normalized_components[&"components"]): + return {&"ok": false, &"error": "Saved DemandIndex does not match its inputs."} + var evaluated_at := int(evaluated_value) + if evaluated_at < -1 or evaluated_at > maximum_committed_minute \ + or (evaluated_at >= 0 and evaluated_at % DEMAND_INTERVAL_MINUTES != 0): + return {&"ok": false, &"error": "Demand evaluation time is invalid."} + var key := _demand_key(facility_tag, guest_class, time_band) + if restored_profiles.has(key): + return {&"ok": false, &"error": "Demand profile IDs are duplicated."} + restored_profiles[key] = { + &"facility_tag": facility_tag, + &"guest_class": guest_class, + &"time_band": time_band, + &"components": normalized_components[&"components"], + &"demand_index": demand_index, + &"last_evaluated_at": evaluated_at, + } + var restored_carry: Dictionary = {} + for row_value: Variant in carry_value: + if not row_value is Dictionary: + return {&"ok": false, &"error": "Arrival carry row is malformed."} + var key := _demand_key( + StringName(_value(row_value, &"facility_tag", &"")), + StringName(_value(row_value, &"guest_class", &"")), + StringName(_value(row_value, &"time_band", &"")) + ) + var numerator_value: Variant = _value(row_value, &"numerator", null) + if not _is_integral_value(numerator_value): + return {&"ok": false, &"error": "Arrival carry numerator must be an integer."} + var numerator := int(numerator_value) + if not restored_profiles.has(key) or restored_carry.has(key) \ + or numerator < 0 or numerator > _max_carry_for_config(normalized_config[&"config"]): + return {&"ok": false, &"error": "Arrival carry is invalid."} + restored_carry[key] = numerator + if restored_carry.size() != restored_profiles.size(): + return {&"ok": false, &"error": "Every demand profile requires one carry row."} + + var histories := _parse_snapshot_histories( + snapshot, + version, + maximum_committed_minute + ) + if not bool(histories[&"ok"]): + return histories + var population_value: Variant = _value(snapshot, &"population_count", null) + if population_value == null: + if version == 1 and (histories[&"arrival_reports"] as Array).is_empty(): + population_value = 0 + else: + return { + &"ok": false, + &"error": "Legacy population is ambiguous when arrival history exists.", + } + var arrears_value: Variant = _value(snapshot, &"arrears_total", null) + var consecutive_value: Variant = _value(snapshot, &"consecutive_insolvency_count", null) + var next_plan_value: Variant = _value(snapshot, &"next_arrival_plan_sequence", null) + var next_guest_value: Variant = _value(snapshot, &"next_guest_sequence", null) + if not _is_integral_value(population_value) \ + or not _is_integral_value(arrears_value) \ + or not _is_integral_value(consecutive_value) \ + or not _is_integral_value(next_plan_value) \ + or not _is_integral_value(next_guest_value): + return {&"ok": false, &"error": "Operations scalar snapshot types are invalid."} + var population := int(population_value) + var arrears := int(arrears_value) + var consecutive := int(consecutive_value) + var terminal_value: Variant = _value(snapshot, &"terminal_state", null) + if typeof(terminal_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"error": "Terminal state must be a string."} + var terminal := StringName(terminal_value) + var next_plan := int(next_plan_value) + var next_guest := int(next_guest_value) + if population < 0 or population > int((normalized_config[&"config"] as Dictionary)[&"population_cap"]) \ + or arrears < 0 or consecutive < 0 or next_plan < 1 or next_guest < 1: + return {&"ok": false, &"error": "Operations scalar state is invalid."} + if terminal not in [TERMINAL_RUNNING, TERMINAL_BANKRUPT]: + return {&"ok": false, &"error": "Operations terminal state is invalid."} + if terminal == TERMINAL_BANKRUPT \ + and consecutive < int((normalized_config[&"config"] as Dictionary)[&"bankruptcy_close_count"]): + return {&"ok": false, &"error": "Bankruptcy threshold was not reached."} + var pause_value: Variant = _value(snapshot, &"pause_requested", false) + if typeof(pause_value) != TYPE_BOOL: + return {&"ok": false, &"error": "Pause flag must be boolean."} + var plan_high_water := next_plan - 1 + var guest_high_water := next_guest - 1 + var fair_counter := (histories[&"arrival_reports"] as Array).size() + if version >= 3: + var plan_high_value: Variant = _value(snapshot, &"arrival_plan_high_water", null) + var guest_high_value: Variant = _value(snapshot, &"guest_high_water", null) + var fair_value: Variant = _value(snapshot, &"fair_rotation_counter", null) + if not _is_integral_value(plan_high_value) \ + or not _is_integral_value(guest_high_value) \ + or not _is_integral_value(fair_value): + return {&"ok": false, &"error": "ID high-water and fairness state must be integers."} + plan_high_water = int(plan_high_value) + guest_high_water = int(guest_high_value) + fair_counter = int(fair_value) + if plan_high_water < 0 or plan_high_water > MAX_SEQUENCE_VALUE \ + or guest_high_water < 0 or guest_high_water > MAX_SEQUENCE_VALUE \ + or next_plan != plan_high_water + 1 or next_guest != guest_high_water + 1: + return {&"ok": false, &"error": "ID high-water state is invalid."} + var pause_reason_value: Variant = _value(snapshot, &"pause_reason", &"") \ + if version >= 3 else (&"LEDGER_IMBALANCE" if bool(pause_value) else &"") + if typeof(pause_reason_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"error": "Pause reason must be a string."} + var pause_reason := StringName(pause_reason_value) + var quarantine_value: Variant = _value(snapshot, &"external_quarantined", false) \ + if version >= 3 else false + var quarantine_report_value: Variant = _value(snapshot, &"quarantine_report", {}) \ + if version >= 3 else {} + if typeof(quarantine_value) != TYPE_BOOL or not quarantine_report_value is Dictionary: + return {&"ok": false, &"error": "External quarantine snapshot state is malformed."} + + var state := { + &"origin_minute": restored_origin, + &"current_minute": restored_minute, + &"config": normalized_config[&"config"], + &"demand_profiles": restored_profiles, + &"arrival_carry": restored_carry, + &"demand_history": histories[&"demand_history"], + &"arrival_reports": histories[&"arrival_reports"], + &"day_summaries": histories[&"day_summaries"], + &"morning_reports": histories[&"morning_reports"], + &"alerts": histories[&"alerts"], + &"processed_demand_minutes": histories[&"processed_demand_minutes"], + &"processed_arrival_minutes": histories[&"processed_arrival_minutes"], + &"population_count": population, + &"arrears_total": arrears, + &"consecutive_insolvency_count": consecutive, + &"terminal_state": terminal, + &"pause_requested": bool(pause_value), + &"next_arrival_plan_sequence": next_plan, + &"next_guest_sequence": next_guest, + &"arrival_plan_high_water": plan_high_water, + &"guest_high_water": guest_high_water, + &"fair_rotation_counter": fair_counter, + &"pending_boundary_minute": pending_minute, + &"deferred_boundary_minute": deferred_minute, + &"pause_reason": pause_reason, + &"external_quarantined": bool(quarantine_value), + &"quarantine_report": (quarantine_report_value as Dictionary).duplicate(true), + } + # Validate the fully assembled state using a temporary instance without + # copying any callback into save data. + var candidate := OperationsCoordinator.new(state[&"config"]) + candidate._origin_minute = restored_origin + candidate.current_minute = restored_minute + candidate._config = (state[&"config"] as Dictionary).duplicate(true) + candidate._demand_profiles = restored_profiles.duplicate(true) + candidate._arrival_carry = restored_carry.duplicate(true) + candidate._demand_history = _copy_dictionary_array(state[&"demand_history"]) + candidate._arrival_reports = _copy_dictionary_array(state[&"arrival_reports"]) + candidate._day_summaries = (state[&"day_summaries"] as Dictionary).duplicate(true) + candidate._morning_reports = (state[&"morning_reports"] as Dictionary).duplicate(true) + candidate._alerts = _copy_dictionary_array(state[&"alerts"]) + candidate._processed_demand_minutes = (state[&"processed_demand_minutes"] as Dictionary).duplicate(true) + candidate._processed_arrival_minutes = (state[&"processed_arrival_minutes"] as Dictionary).duplicate(true) + candidate._population_count = population + candidate._arrears_total = arrears + candidate._consecutive_insolvency_count = consecutive + candidate._terminal_state = terminal + candidate.pause_requested = bool(state[&"pause_requested"]) + candidate._next_arrival_plan_sequence = next_plan + candidate._next_guest_sequence = next_guest + candidate._arrival_plan_high_water = plan_high_water + candidate._guest_high_water = guest_high_water + candidate._fair_rotation_counter = fair_counter + candidate._pending_boundary_minute = pending_minute + candidate._deferred_boundary_minute = deferred_minute + candidate._pause_reason = pause_reason + candidate._external_quarantined = bool(quarantine_value) + candidate._quarantine_report = (quarantine_report_value as Dictionary).duplicate(true) + var report := candidate.validate_invariants() + if not bool(report[&"ok"]): + return {&"ok": false, &"error": "Operations snapshot invariants failed.", &"report": report} + return {&"ok": true, &"state": state} + + +func _parse_snapshot_histories( + snapshot: Dictionary, + version: int, + maximum_committed_minute: int +) -> Dictionary: + var demand_history_value: Variant = _value(snapshot, &"demand_history", []) + var arrivals_value: Variant = _value(snapshot, &"arrival_reports", null) + var days_value: Variant = _value(snapshot, &"day_summaries", null) + var mornings_value: Variant = _value(snapshot, &"morning_reports", null) + var alerts_value: Variant = _value(snapshot, &"alerts", []) + var demand_minutes_value: Variant = _value(snapshot, &"processed_demand_minutes", []) + var arrival_minutes_value: Variant = _value(snapshot, &"processed_arrival_minutes", []) + if not demand_history_value is Array or not arrivals_value is Array \ + or not days_value is Array or not mornings_value is Array \ + or not alerts_value is Array or not demand_minutes_value is Array \ + or not arrival_minutes_value is Array: + return {&"ok": false, &"error": "Operations history collections are malformed."} + for collection: Array in [ + demand_history_value, + arrivals_value, + days_value, + mornings_value, + alerts_value, + demand_minutes_value, + arrival_minutes_value, + ]: + if collection.size() > MAX_SAFE_COLLECTION_SIZE: + return {&"ok": false, &"error": "Operations history collection is too large."} + if not _variant_tree_report(demand_history_value)[&"ok"] \ + or not _variant_tree_report(arrivals_value)[&"ok"] \ + or not _variant_tree_report(days_value)[&"ok"] \ + or not _variant_tree_report(mornings_value)[&"ok"] \ + or not _variant_tree_report(alerts_value)[&"ok"]: + return {&"ok": false, &"error": "Operations history contains unsaveable data."} + for row_value: Variant in demand_history_value: + if not row_value is Dictionary: + return {&"ok": false, &"error": "Demand history row is malformed."} + var migrated_arrivals: Array = [] + var arrival_index := 0 + for row_value: Variant in arrivals_value: + if not row_value is Dictionary: + return {&"ok": false, &"error": "Arrival history row is malformed."} + var migrated_row := (row_value as Dictionary).duplicate(true) + if version < 3: + migrated_row[&"fair_rotation_counter"] = arrival_index + migrated_row[&"fair_start_index"] = 0 + migrated_arrivals.append(migrated_row) + arrival_index += 1 + for row_value: Variant in alerts_value: + if not row_value is Dictionary: + return {&"ok": false, &"error": "Operations alert row is malformed."} + var demand_minutes := _minute_array_to_set( + demand_minutes_value, + DEMAND_INTERVAL_MINUTES, + maximum_committed_minute + ) + var arrival_minutes := _minute_array_to_set( + arrival_minutes_value, + ARRIVAL_INTERVAL_MINUTES, + maximum_committed_minute + ) + if not bool(demand_minutes[&"ok"]) or not bool(arrival_minutes[&"ok"]): + return {&"ok": false, &"error": "Processed boundary minute list is invalid."} + var migrated_demand_history: Array = (demand_history_value as Array).duplicate(true) + if version == 1 and migrated_demand_history.is_empty() \ + and not (demand_minutes[&"set"] as Dictionary).is_empty(): + for minute: int in _sorted_int_keys(demand_minutes[&"set"]): + migrated_demand_history.append({&"minute": minute, &"values": []}) + var days: Dictionary = {} + for row_value: Variant in days_value: + if not row_value is Dictionary: + return {&"ok": false, &"error": "DaySummary row is malformed."} + var day_value: Variant = _value(row_value, &"day", null) + if not _is_integral_value(day_value): + return {&"ok": false, &"error": "DaySummary day must be an integer."} + var day := int(day_value) + if day < 1 or days.has(day): + return {&"ok": false, &"error": "DaySummary day is invalid or duplicated."} + days[day] = (row_value as Dictionary).duplicate(true) + var mornings: Dictionary = {} + for row_value: Variant in mornings_value: + if not row_value is Dictionary: + return {&"ok": false, &"error": "Morning report row is malformed."} + var day_value: Variant = _value(row_value, &"day", null) + if not _is_integral_value(day_value): + return {&"ok": false, &"error": "Morning report day must be an integer."} + var day := int(day_value) + if day < 1 or mornings.has(day): + return {&"ok": false, &"error": "Morning report day is invalid or duplicated."} + mornings[day] = (row_value as Dictionary).duplicate(true) + return { + &"ok": true, + &"demand_history": migrated_demand_history, + &"arrival_reports": migrated_arrivals, + &"day_summaries": days, + &"morning_reports": mornings, + &"alerts": (alerts_value as Array).duplicate(true) if version >= 2 else [], + &"processed_demand_minutes": demand_minutes[&"set"], + &"processed_arrival_minutes": arrival_minutes[&"set"], + } + + +func _validate_histories(errors: Array[Dictionary]) -> void: + var last_demand := -1 + for value: Variant in _demand_history: + if not value is Dictionary: + errors.append({&"code": &"INVALID_DEMAND_HISTORY"}) + continue + var minute_value: Variant = _value(value, &"minute", null) + if not _is_integral_value(minute_value): + errors.append({&"code": &"INVALID_DEMAND_HISTORY_TIME"}) + continue + var minute := int(minute_value) + if minute <= last_demand or not _processed_demand_minutes.has(minute): + errors.append({&"code": &"INVALID_DEMAND_HISTORY_ORDER"}) + last_demand = minute + var values_value: Variant = _value(value, &"values", null) + if not values_value is Array: + errors.append({&"code": &"INVALID_DEMAND_HISTORY_VALUES", &"minute": minute}) + continue + var identities: Dictionary = {} + for row_value: Variant in values_value: + if not row_value is Dictionary: + errors.append({&"code": &"INVALID_DEMAND_HISTORY_ROW", &"minute": minute}) + continue + var row: Dictionary = row_value + var facility_value: Variant = _value(row, &"facility_tag", null) + var guest_value: Variant = _value(row, &"guest_class", null) + var band_value: Variant = _value(row, &"time_band", null) + var index_value: Variant = _value(row, &"demand_index", null) + if typeof(facility_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(guest_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(band_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _is_integral_value(index_value) \ + or int(index_value) < 0 or int(index_value) > 200: + errors.append({&"code": &"INVALID_DEMAND_HISTORY_ROW", &"minute": minute}) + continue + var identity_error := _validate_demand_identity( + StringName(facility_value), StringName(guest_value), StringName(band_value) + ) + var identity := _demand_key( + StringName(facility_value), StringName(guest_value), StringName(band_value) + ) + if not identity_error.is_empty() or identities.has(identity): + errors.append({&"code": &"INVALID_DEMAND_HISTORY_IDENTITY", &"minute": minute}) + else: + identities[identity] = true + var last_arrival := -1 + var plan_ids: Dictionary = {} + var guest_ids: Dictionary = {} + var maximum_plan_sequence := 0 + var maximum_guest_sequence := 0 + var last_plan_sequence := 0 + var last_guest_sequence := 0 + var expected_fair_counter := 0 + for value: Variant in _arrival_reports: + if not value is Dictionary: + errors.append({&"code": &"INVALID_ARRIVAL_HISTORY"}) + continue + var report: Dictionary = value + var minute_value: Variant = _value(report, &"minute", null) + var fair_value: Variant = _value(report, &"fair_rotation_counter", null) + var fair_start_value: Variant = _value(report, &"fair_start_index", null) + var population_value: Variant = _value(report, &"population_after", null) + var capacity_value: Variant = _value(report, &"capacity_remaining", null) + var report_band_value: Variant = _value(report, &"time_band", null) + if not _is_integral_value(minute_value) or not _is_integral_value(fair_value) \ + or not _is_integral_value(fair_start_value) \ + or not _is_integral_value(population_value) \ + or not _is_integral_value(capacity_value) \ + or typeof(report_band_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + errors.append({&"code": &"INVALID_ARRIVAL_HISTORY_SCALAR"}) + continue + var minute := int(minute_value) + if minute <= last_arrival or not _processed_arrival_minutes.has(minute): + errors.append({&"code": &"INVALID_ARRIVAL_HISTORY_ORDER"}) + last_arrival = minute + if int(fair_value) != expected_fair_counter: + errors.append({&"code": &"FAIR_ROTATION_HISTORY_MISMATCH"}) + if int(fair_start_value) < 0 or int(fair_start_value) >= MAX_DEMAND_PROFILES: + errors.append({&"code": &"INVALID_FAIR_START_INDEX"}) + if StringName(report_band_value) != _time_band_for_minute(minute): + errors.append({&"code": &"ARRIVAL_TIME_BAND_MISMATCH"}) + var population_after := int(population_value) + if population_after < 0 or population_after > int(_config[&"population_cap"]) \ + or int(capacity_value) != int(_config[&"population_cap"]) - population_after: + errors.append({&"code": &"ARRIVAL_CAPACITY_DERIVATION_MISMATCH"}) + expected_fair_counter += 1 + var plans_value: Variant = _value(report, &"plans", null) + if not plans_value is Array: + errors.append({&"code": &"INVALID_ARRIVAL_PLANS"}) + continue + var total := 0 + for plan_value: Variant in plans_value: + if not plan_value is Dictionary: + errors.append({&"code": &"INVALID_ARRIVAL_PLAN"}) + continue + var plan: Dictionary = plan_value + var plan_id_value: Variant = _value(plan, &"plan_id", null) + var plan_minute_value: Variant = _value(plan, &"minute", null) + var plan_id := String(plan_id_value) + var plan_sequence := _sequence_from_id(plan_id, "arrival-plan-") + var count_value: Variant = _value(plan, &"count", null) + var ids_value: Variant = _value(plan, &"guest_ids", null) + var facility_value: Variant = _value(plan, &"facility_tag", null) + var guest_value: Variant = _value(plan, &"guest_class", null) + var band_value: Variant = _value(plan, &"time_band", null) + var demand_value: Variant = _value(plan, &"demand_index", null) + if typeof(plan_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _is_integral_value(plan_minute_value) or int(plan_minute_value) != minute \ + or not _is_integral_value(count_value) \ + or typeof(facility_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(guest_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(band_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _is_integral_value(demand_value) \ + or int(demand_value) < 0 or int(demand_value) > 200: + errors.append({&"code": &"INVALID_ARRIVAL_PLAN_COUNT"}) + continue + var count := int(count_value) + if plan_sequence <= 0 or plan_ids.has(plan_id) or count <= 0 \ + or plan_sequence <= last_plan_sequence \ + or not ids_value is Array or ids_value.size() != count \ + or not _validate_demand_identity( + StringName(facility_value), StringName(guest_value), StringName(band_value) + ).is_empty(): + errors.append({&"code": &"INVALID_ARRIVAL_PLAN"}) + continue + plan_ids[plan_id] = true + last_plan_sequence = plan_sequence + maximum_plan_sequence = maxi(maximum_plan_sequence, plan_sequence) + for guest_id_value: Variant in ids_value: + var guest_id := String(guest_id_value) + var guest_sequence := _sequence_from_id(guest_id, "guest-auto-") + if typeof(guest_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or guest_sequence <= 0 or guest_sequence <= last_guest_sequence \ + or guest_ids.has(guest_id): + errors.append({&"code": &"DUPLICATE_ARRIVAL_GUEST"}) + guest_ids[guest_id] = true + last_guest_sequence = guest_sequence + maximum_guest_sequence = maxi(maximum_guest_sequence, guest_sequence) + total += count + var total_value: Variant = _value(report, &"total_count", null) + if not _is_integral_value(total_value) \ + or total != int(total_value) \ + or total > int(_config[&"max_arrivals_per_interval"]) \ + or total > population_after: + errors.append({&"code": &"ARRIVAL_TOTAL_MISMATCH"}) + var capped_value: Variant = _value(report, &"carry_capped", null) + if not capped_value is Array: + errors.append({&"code": &"INVALID_CARRY_CAPPED_HISTORY"}) + else: + for capped_row: Variant in capped_value: + if not capped_row is Dictionary: + errors.append({&"code": &"INVALID_CARRY_CAPPED_HISTORY"}) + if _demand_history.size() != _processed_demand_minutes.size(): + errors.append({&"code": &"DEMAND_BOUNDARY_COUNT_MISMATCH"}) + if _arrival_reports.size() != _processed_arrival_minutes.size(): + errors.append({&"code": &"ARRIVAL_BOUNDARY_COUNT_MISMATCH"}) + if maximum_plan_sequence > _arrival_plan_high_water \ + or maximum_guest_sequence > _guest_high_water: + errors.append({&"code": &"ID_HIGH_WATER_BELOW_HISTORY"}) + + +func _validate_financial_history(errors: Array[Dictionary]) -> void: + var maximum_committed_minute := current_minute + if _pending_boundary_minute == current_minute + 1: + maximum_committed_minute = _pending_boundary_minute + var expected_consecutive := 0 + var expected_arrears := 0 + var expected_terminal: StringName = TERMINAL_RUNNING + var prior_net := 0 + var profits: Array[int] = [] + for day: int in _sorted_int_keys(_day_summaries): + var summary: Dictionary = _day_summaries[day] + var scalar_fields_valid := true + for scalar_key: StringName in [ + &"day", &"closed_at", &"period_start_minute", &"period_end_minute_exclusive", + &"net_profit", &"previous_day_delta", &"seven_day_average", + &"reconciliation_difference", &"arrears_total", &"consecutive_insolvency_count", + ]: + if not _is_integral_value(_value(summary, scalar_key, null)): + scalar_fields_valid = false + if not scalar_fields_valid: + errors.append({&"code": &"INVALID_DAY_SUMMARY_SCALAR_TYPE", &"day": day}) + continue + var expected_close := _daily_close_minute_for_day(day) + if int(_value(summary, &"day", 0)) != day \ + or int(_value(summary, &"closed_at", -1)) != expected_close \ + or int(_value(summary, &"period_start_minute", -1)) != expected_close - MINUTES_PER_DAY \ + or int(_value(summary, &"period_end_minute_exclusive", -1)) != expected_close \ + or expected_close <= _origin_minute \ + or expected_close > maximum_committed_minute: + errors.append({&"code": &"INVALID_DAY_SUMMARY_BOUNDARY", &"day": day}) + continue + var categories_value: Variant = _value(summary, &"category_totals", null) + if not categories_value is Dictionary: + errors.append({&"code": &"INVALID_DAY_CATEGORIES", &"day": day}) + continue + var categories := _normalize_categories(categories_value) + if not bool(categories[&"ok"]): + errors.append({&"code": &"INVALID_DAY_CATEGORIES", &"day": day}) + continue + var net := _net_profit(categories[&"categories"]) + if net != int(_value(summary, &"net_profit", 0)): + errors.append({&"code": &"NET_PROFIT_MISMATCH", &"day": day}) + var difference_value: Variant = _value(summary, &"reconciliation_difference", null) + var balanced_value: Variant = _value(summary, &"balanced", null) + if not _is_integral_value(difference_value) \ + or typeof(balanced_value) != TYPE_BOOL \ + or bool(balanced_value) != (int(difference_value) == 0): + errors.append({&"code": &"RECONCILIATION_DERIVATION_MISMATCH", &"day": day}) + var facilities_value: Variant = _value(summary, &"facility_profit_loss", null) + if not facilities_value is Dictionary: + errors.append({&"code": &"INVALID_FACILITY_PROFIT_LOSS", &"day": day}) + else: + var facilities := _normalize_facility_profit_loss(facilities_value) + if not bool(facilities[&"ok"]) \ + or _to_snapshot_variant(facilities[&"facilities"]) != _to_snapshot_variant(facilities_value): + errors.append({&"code": &"FACILITY_PROFIT_LOSS_MISMATCH", &"day": day}) + var anomalies_value: Variant = _value(summary, &"anomalies", null) + if not anomalies_value is Array or not _variant_tree_report(anomalies_value)[&"ok"]: + errors.append({&"code": &"INVALID_DAY_ANOMALIES", &"day": day}) + if int(_value(summary, &"previous_day_delta", 0)) != (0 if profits.is_empty() else net - prior_net): + errors.append({&"code": &"PREVIOUS_DAY_DELTA_MISMATCH", &"day": day}) + profits.append(net) + prior_net = net + var start := maxi(profits.size() - 7, 0) + var sum := 0 + for index in range(start, profits.size()): + sum += profits[index] + var count := profits.size() - start + if int(_value(summary, &"seven_day_average", 0)) != sum / count: + errors.append({&"code": &"SEVEN_DAY_AVERAGE_MISMATCH", &"day": day}) + var average_detail_value: Variant = _value(summary, &"seven_day_average_detail", null) + if not average_detail_value is Dictionary \ + or int(_value(average_detail_value, &"sum", 0)) != sum \ + or int(_value(average_detail_value, &"count", 0)) != count \ + or int(_value(average_detail_value, &"quotient", 0)) != sum / count \ + or int(_value(average_detail_value, &"remainder", 0)) != sum % count: + errors.append({&"code": &"SEVEN_DAY_DETAIL_MISMATCH", &"day": day}) + expected_arrears = int(_value(summary, &"arrears_total", -1)) + if expected_arrears < 0: + errors.append({&"code": &"INVALID_DAY_ARREARS", &"day": day}) + var settlement_value: Variant = _value(summary, &"settlement", null) + if not settlement_value is Dictionary \ + or not _is_integral_value(_value(settlement_value, &"arrears_total", null)) \ + or int(_value(settlement_value, &"arrears_total", -2)) != expected_arrears \ + or not _variant_tree_report(settlement_value)[&"ok"]: + errors.append({&"code": &"SETTLEMENT_ARREARS_MISMATCH", &"day": day}) + expected_consecutive = expected_consecutive + 1 if expected_arrears > 0 else 0 + if int(_value(summary, &"consecutive_insolvency_count", -1)) != expected_consecutive: + errors.append({&"code": &"INSOLVENCY_COUNT_MISMATCH", &"day": day}) + if expected_consecutive >= int(_config[&"bankruptcy_close_count"]): + expected_terminal = TERMINAL_BANKRUPT + if StringName(_value(summary, &"terminal_state", &"")) != expected_terminal: + errors.append({&"code": &"DAY_TERMINAL_MISMATCH", &"day": day}) + if not _day_summaries.is_empty(): + if expected_arrears != _arrears_total or expected_consecutive != _consecutive_insolvency_count: + errors.append({&"code": &"CURRENT_INSOLVENCY_MISMATCH"}) + if expected_terminal != _terminal_state: + errors.append({&"code": &"CURRENT_TERMINAL_MISMATCH"}) + for day: int in _sorted_int_keys(_morning_reports): + var report: Dictionary = _morning_reports[day] + var expected_minute := (day - 1) * MINUTES_PER_DAY + MORNING_MINUTE_OF_DAY + if not _is_integral_value(_value(report, &"day", null)) \ + or not _is_integral_value(_value(report, &"minute", null)) \ + or int(_value(report, &"day", 0)) != day \ + or int(_value(report, &"minute", -1)) != expected_minute \ + or expected_minute <= _origin_minute \ + or expected_minute > maximum_committed_minute or not _is_save_safe(report): + errors.append({&"code": &"INVALID_MORNING_REPORT", &"day": day}) + + +func _validate_boundary_minute_set( + values: Dictionary, + interval: int, + code: StringName, + errors: Array[Dictionary] +) -> void: + var maximum_committed_minute := current_minute + if _pending_boundary_minute == current_minute + 1: + maximum_committed_minute = _pending_boundary_minute + for minute_value: Variant in values: + var minute := int(minute_value) + if minute <= _origin_minute or minute > maximum_committed_minute or minute % interval != 0 \ + or not bool(values[minute_value]): + errors.append({&"code": code, &"minute": minute}) + + +func _validate_boundary_coverage(errors: Array[Dictionary]) -> void: + var partial_minute := -1 + var complete_through := current_minute + if _pending_boundary_minute == current_minute + 1: + partial_minute = _pending_boundary_minute + elif _deferred_boundary_minute == current_minute: + partial_minute = _deferred_boundary_minute + complete_through = current_minute - 1 + elif _terminal_state == TERMINAL_BANKRUPT and _is_daily_close_boundary(current_minute): + partial_minute = current_minute + complete_through = current_minute - 1 + + for minute in range(_origin_minute + 1, complete_through + 1): + if _is_daily_close_boundary(minute): + var completed_day := _financial_day_for_boundary(minute) + if not _day_summaries.has(completed_day): + errors.append({&"code": &"MISSING_DAILY_CLOSE", &"minute": minute}) + if minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY: + var morning_day := minute / MINUTES_PER_DAY + 1 + if not _morning_reports.has(morning_day): + errors.append({&"code": &"MISSING_MORNING_BOUNDARY", &"minute": minute}) + if minute % DEMAND_INTERVAL_MINUTES == 0 \ + and not _processed_demand_minutes.has(minute): + errors.append({&"code": &"MISSING_DEMAND_BOUNDARY", &"minute": minute}) + if minute % ARRIVAL_INTERVAL_MINUTES == 0 \ + and not _processed_arrival_minutes.has(minute): + errors.append({&"code": &"MISSING_ARRIVAL_BOUNDARY", &"minute": minute}) + + if partial_minute >= 0: + _validate_partial_boundary_prefix(partial_minute, errors) + + +func _validate_partial_boundary_prefix(minute: int, errors: Array[Dictionary]) -> void: + var states: Array[bool] = [] + if _is_daily_close_boundary(minute): + states.append(_day_summaries.has(_financial_day_for_boundary(minute))) + if minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY: + states.append(_morning_reports.has(minute / MINUTES_PER_DAY + 1)) + if minute % DEMAND_INTERVAL_MINUTES == 0: + states.append(_processed_demand_minutes.has(minute)) + if minute % ARRIVAL_INTERVAL_MINUTES == 0: + states.append(_processed_arrival_minutes.has(minute)) + var found_gap := false + for processed: bool in states: + if not processed: + found_gap = true + elif found_gap: + errors.append({&"code": &"NON_PREFIX_PARTIAL_BOUNDARY", &"minute": minute}) + return + + +func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void: + var imbalance_by_day: Dictionary = {} + var arrears_by_day: Dictionary = {} + var bankrupt_count := 0 + var quarantine_count := 0 + for alert: Dictionary in _alerts: + if not _variant_tree_report(alert)[&"ok"]: + errors.append({&"code": &"INVALID_ALERT_PAYLOAD"}) + continue + var code := StringName(_value(alert, &"code", &"")) + match code: + &"LEDGER_IMBALANCE": + var day := int(_value(alert, &"day", 0)) + imbalance_by_day[day] = int(imbalance_by_day.get(day, 0)) + 1 + &"ARREARS_REMAIN": + var day := int(_value(alert, &"day", 0)) + arrears_by_day[day] = int(arrears_by_day.get(day, 0)) + 1 + &"BANKRUPT": + bankrupt_count += 1 + &"EXTERNAL_ROLLBACK_FAILED": + quarantine_count += 1 + &"ARRIVAL_CARRY_CAPPED": + pass + _: + errors.append({&"code": &"UNKNOWN_ALERT", &"alert_code": code}) + for day: int in _sorted_int_keys(_day_summaries): + var summary: Dictionary = _day_summaries[day] + var imbalanced := int(_value(summary, &"reconciliation_difference", 0)) != 0 + if int(imbalance_by_day.get(day, 0)) != int(imbalanced): + errors.append({&"code": &"IMBALANCE_ALERT_MISMATCH", &"day": day}) + var has_arrears := int(_value(summary, &"arrears_total", 0)) > 0 + if int(arrears_by_day.get(day, 0)) != int(has_arrears): + errors.append({&"code": &"ARREARS_ALERT_MISMATCH", &"day": day}) + if bankrupt_count != int(_terminal_state == TERMINAL_BANKRUPT): + errors.append({&"code": &"BANKRUPT_ALERT_MISMATCH"}) + if quarantine_count != int(_external_quarantined): + errors.append({&"code": &"QUARANTINE_ALERT_MISMATCH"}) + if pause_requested and _pause_reason == &"LEDGER_IMBALANCE" and imbalance_by_day.is_empty(): + errors.append({&"code": &"PAUSE_WITHOUT_IMBALANCE_ALERT"}) + if pause_requested and _pause_reason == &"LEDGER_IMBALANCE": + var paused_day := _financial_day_for_boundary(current_minute) + var paused_summary: Dictionary = _day_summaries.get(paused_day, {}) + if not _is_daily_close_boundary(current_minute) \ + or paused_summary.is_empty() \ + or int(_value(paused_summary, &"reconciliation_difference", 0)) == 0 \ + or (_terminal_state == TERMINAL_RUNNING \ + and _deferred_boundary_minute != current_minute): + errors.append({&"code": &"STALE_LEDGER_PAUSE"}) + if _external_quarantined: + var quarantine_report_found := false + var expected_report: Variant = _to_snapshot_variant(_quarantine_report) + for alert: Dictionary in _alerts: + if _to_snapshot_variant(alert) == expected_report: + quarantine_report_found = true + break + if not quarantine_report_found: + errors.append({&"code": &"QUARANTINE_REPORT_NOT_ALERTED"}) + + +func _minute_array_to_set(values: Array, interval: int, maximum: int) -> Dictionary: + var result: Dictionary = {} + var prior := -1 + for value: Variant in values: + if not _is_integral_value(value): + return {&"ok": false} + var minute := int(value) + if minute <= prior or minute < 0 or minute > maximum or minute % interval != 0: + return {&"ok": false} + result[minute] = true + prior = minute + return {&"ok": true, &"set": result} + + +func _internal_snapshot_shape_error() -> StringName: + if _demand_profiles.size() > MAX_DEMAND_PROFILES: + return &"DEMAND_PROFILE_LIMIT" + for profile_value: Variant in _demand_profiles.values(): + if not profile_value is Dictionary: + return &"INVALID_DEMAND_PROFILE" + for row_value: Variant in _demand_history: + if not row_value is Dictionary: + return &"INVALID_DEMAND_HISTORY" + for row_value: Variant in _arrival_reports: + if not row_value is Dictionary: + return &"INVALID_ARRIVAL_HISTORY" + for row_value: Variant in _alerts: + if not row_value is Dictionary: + return &"INVALID_ALERT_HISTORY" + for row_value: Variant in _day_summaries.values(): + if not row_value is Dictionary: + return &"INVALID_DAY_SUMMARY" + for row_value: Variant in _morning_reports.values(): + if not row_value is Dictionary: + return &"INVALID_MORNING_REPORT" + return &"" + + +func _has_committed_operations() -> bool: + return not _processed_demand_minutes.is_empty() \ + or not _processed_arrival_minutes.is_empty() \ + or not _day_summaries.is_empty() \ + or not _morning_reports.is_empty() + + +func _minute_has_scheduled_boundary(minute: int) -> bool: + if minute < 0: + return false + return minute % ARRIVAL_INTERVAL_MINUTES == 0 \ + or minute % DEMAND_INTERVAL_MINUTES == 0 \ + or minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY \ + or _is_daily_close_boundary(minute) + + +func _is_daily_close_boundary(minute: int) -> bool: + return minute >= FIRST_DAILY_CLOSE_MINUTE \ + and minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY + + +func _financial_day_for_boundary(boundary_minute: int) -> int: + return (boundary_minute - MORNING_MINUTE_OF_DAY) / MINUTES_PER_DAY + + +func _daily_close_minute_for_day(day: int) -> int: + return day * MINUTES_PER_DAY + MORNING_MINUTE_OF_DAY + + +func _max_carry_for_config(config: Dictionary) -> int: + return int(config[&"arrival_rate_denominator"]) \ + * int(config[&"max_arrivals_per_interval"]) \ + * int(config[&"arrival_carry_interval_limit"]) + + +static func _is_integral_value(value: Variant) -> bool: + return typeof(value) == TYPE_INT + + +static func _is_finite_numeric(value: Variant) -> bool: + if typeof(value) == TYPE_INT: + return true + if typeof(value) != TYPE_FLOAT: + return false + return is_finite(float(value)) + + +static func _is_save_safe(value: Variant, depth := 0) -> bool: + if depth != 0: + # Kept for compatibility with older internal callers; whole-tree budget + # validation always begins at depth zero. + return false + return bool(_variant_tree_report(value)[&"ok"]) + + +static func _variant_tree_report(value: Variant) -> Dictionary: + var budget := {&"nodes": 0, &"string_length": 0} + var issue := _inspect_variant_tree(value, budget, 0) + if not issue.is_empty(): + return {&"ok": false, &"code": issue} + return { + &"ok": true, + &"code": &"OK", + &"nodes": int(budget[&"nodes"]), + &"string_length": int(budget[&"string_length"]), + } + + +static func _inspect_variant_tree( + value: Variant, + budget: Dictionary, + depth: int +) -> StringName: + if depth > MAX_VARIANT_DEPTH: + return &"MAX_VARIANT_DEPTH" + budget[&"nodes"] = int(budget[&"nodes"]) + 1 + if int(budget[&"nodes"]) > MAX_VARIANT_NODES: + return &"MAX_VARIANT_NODES" + match typeof(value): + TYPE_NIL, TYPE_BOOL, TYPE_INT: + return &"" + TYPE_FLOAT: + return &"" if is_finite(float(value)) else &"NON_FINITE_NUMBER" + TYPE_STRING, TYPE_STRING_NAME: + var text := String(value) + if text.length() > MAX_SINGLE_STRING_LENGTH: + return &"STRING_TOO_LONG" + budget[&"string_length"] = int(budget[&"string_length"]) + text.length() + return &"" if int(budget[&"string_length"]) <= MAX_TOTAL_STRING_LENGTH \ + else &"TOTAL_STRING_BUDGET" + TYPE_ARRAY: + var values: Array = value + if values.size() > MAX_SAFE_COLLECTION_SIZE: + return &"COLLECTION_TOO_LARGE" + for child: Variant in values: + var issue := _inspect_variant_tree(child, budget, depth + 1) + if not issue.is_empty(): + return issue + return &"" + TYPE_DICTIONARY: + var source: Dictionary = value + if source.size() > MAX_SAFE_COLLECTION_SIZE: + return &"COLLECTION_TOO_LARGE" + if not _canonical_key_collision(source).is_empty(): + return &"DUPLICATE_CANONICAL_KEY" + for key: Variant in source: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]: + return &"INVALID_DICTIONARY_KEY" + var key_issue := _inspect_variant_tree(str(key), budget, depth + 1) + if not key_issue.is_empty(): + return key_issue + var value_issue := _inspect_variant_tree(source[key], budget, depth + 1) + if not value_issue.is_empty(): + return value_issue + return &"" + _: + return &"UNSAVEABLE_VARIANT" + + +static func _canonical_key_collision(source: Dictionary) -> Dictionary: + var seen: Dictionary = {} + for key: Variant in source: + var canonical := str(key) + if seen.has(canonical): + return { + &"canonical_key": canonical, + &"first_type": typeof(seen[canonical]), + &"second_type": typeof(key), + } + seen[canonical] = key + return {} + + +static func _to_snapshot_variant(value: Variant) -> Variant: + match typeof(value): + TYPE_STRING_NAME: + return String(value) + TYPE_ARRAY: + var result: Array = [] + for child: Variant in value: + result.append(_to_snapshot_variant(child)) + return result + TYPE_DICTIONARY: + var source: Dictionary = value + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(_variant_string_less) + for key: Variant in keys: + result[str(key)] = _to_snapshot_variant(source[key]) + return result + _: + return value + + +static func _value(source: Dictionary, key: StringName, default_value: Variant) -> Variant: + if source.has(key): + return source[key] + var string_key := String(key) + if source.has(string_key): + return source[string_key] + return default_value + + +static func _sorted_string_keys(source: Dictionary) -> Array[String]: + var result: Array[String] = [] + for key: Variant in source: + result.append(str(key)) + result.sort() + return result + + +static func _sorted_int_keys(source: Dictionary) -> Array[int]: + var result: Array[int] = [] + for key: Variant in source: + result.append(int(key)) + result.sort() + return result + + +static func _rotated_dictionary_rows( + rows: Array[Dictionary], + start_index: int +) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if rows.is_empty(): + return result + for offset in rows.size(): + result.append(rows[posmod(start_index + offset, rows.size())]) + return result + + +static func _sequence_from_id(identifier: String, prefix: String) -> int: + if not identifier.begins_with(prefix): + return -1 + var suffix := identifier.substr(prefix.length()) + if suffix.length() != 8 or not suffix.is_valid_int(): + return -1 + var value := int(suffix) + return value if value > 0 and identifier == "%s%08d" % [prefix, value] else -1 + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(_variant_string_less) + for key: Variant in keys: + var value: Variant = source[key] + result[str(key)] = value.duplicate(true) if value is Array or value is Dictionary else value + return result + + +static func _copy_dictionary_array(source: Array) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for value: Variant in source: + assert(value is Dictionary, "Dictionary-array parser must reject non-dictionary rows before copy.") + result.append((value as Dictionary).duplicate(true)) + return result + + +static func _variant_string_less(left: Variant, right: Variant) -> bool: + return str(left) < str(right) + + +static func _success(details: Dictionary = {}) -> CommandResult: + return CommandResultType.succeeded(0, details) + + +func _failure(code: StringName, message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + var enriched := details.duplicate(true) + enriched[&"code"] = code + return CommandResultType.failed( + CommandResultType.Code.VALIDATION_FAILED, + 0, + message, + enriched + ) + + +func _boundary_failure(code: StringName, report: Dictionary) -> CommandResult: + return _failure( + code, + "An operations boundary failed without committing coordinator state.", + {&"boundary": report.duplicate(true)} + ) diff --git a/scripts/operations/operations_coordinator.gd.uid b/scripts/operations/operations_coordinator.gd.uid new file mode 100644 index 0000000..d3892be --- /dev/null +++ b/scripts/operations/operations_coordinator.gd.uid @@ -0,0 +1 @@ +uid://i34u80f4q4ot diff --git a/scripts/progression/progression_service.gd b/scripts/progression/progression_service.gd new file mode 100644 index 0000000..33913b3 --- /dev/null +++ b/scripts/progression/progression_service.gd @@ -0,0 +1,822 @@ +class_name ProgressionService +extends RefCounted + +## Monotonic World Tree rank progression with explicit eligibility evidence. +## +## The service owns only progression facts. World expansion and GameState +## mutation are supplied as capture/apply/rollback callbacks so a failed rank-up +## cannot leave the map and recorded rank out of sync. + +const SNAPSHOT_VERSION := 2 +const LEGACY_SNAPSHOT_VERSION := 1 +const MIN_RANK := 1 +const MAX_RANK := 7 +const MAX_COMPLETED_QUESTS := 256 +const MAX_PROCESSED_REQUESTS := MAX_RANK - MIN_RANK +const MAX_SAFE_INTEGER := 9_007_199_254_740_991 +const QUARANTINE_REASONS: Array[StringName] = [ + &"RANK_CALLBACK_TAMPERED", + &"RANK_ROLLBACK_FAILED", +] + +const METRIC_IDS: Array[StringName] = [ + &"active_above_ground_facilities", + &"active_underground_facilities", + &"asset_gold", + &"faith_lifetime", + &"guest_lifetime", + &"reputation", + &"underground_influence", +] +const PERCENT_METRICS: Array[StringName] = [&"reputation", &"underground_influence"] +const LIFETIME_METRICS: Array[StringName] = [&"faith_lifetime", &"guest_lifetime"] +const DEFAULT_METRICS := { + &"active_above_ground_facilities": 0, + &"active_underground_facilities": 0, + &"asset_gold": 0, + &"faith_lifetime": 0, + &"guest_lifetime": 0, + &"reputation": 0, + &"underground_influence": 0, +} + +const RANK_BOUNDS := { + 1: {&"min_x": -12, &"max_x": 12, &"min_y": 0, &"max_y": 6}, + 2: {&"min_x": -18, &"max_x": 18, &"min_y": -4, &"max_y": 10}, + 3: {&"min_x": -24, &"max_x": 24, &"min_y": -8, &"max_y": 16}, + 4: {&"min_x": -32, &"max_x": 32, &"min_y": -14, &"max_y": 24}, + 5: {&"min_x": -40, &"max_x": 40, &"min_y": -20, &"max_y": 32}, + 6: {&"min_x": -48, &"max_x": 48, &"min_y": -28, &"max_y": 42}, + 7: {&"min_x": -60, &"max_x": 60, &"min_y": -40, &"max_y": 56}, +} + +var current_rank := MIN_RANK +var metrics: Dictionary = DEFAULT_METRICS.duplicate(true) +var completed_quest_ids: Array[StringName] = [] +var quarantined := false +var quarantine_reason: StringName = &"" +var last_error := "" + +var _processed_requests: Dictionary = {} +var _transaction_active := false + + +func configure( + initial_rank: int = MIN_RANK, + initial_metrics: Dictionary = {}, + initial_quests: Array = [] +) -> bool: + if _transaction_active: + return _fail("Progression cannot be configured during a rank transaction.") + var candidate := ProgressionService.new() + candidate.current_rank = initial_rank + if initial_rank != MIN_RANK: + return _fail("New progression must start at Rank 1; later ranks require recorded promotions.") + if initial_quests.size() > MAX_COMPLETED_QUESTS: + return _fail("Completed quest limit exceeded.") + if not candidate._replace_metrics(initial_metrics, true): + return _fail(candidate.last_error) + for raw_quest_id: Variant in initial_quests: + if not _is_string_variant(raw_quest_id): + return _fail("Quest IDs must be strings.") + var quest_id := StringName(raw_quest_id) + if not _is_stable_id(String(quest_id)) or candidate.completed_quest_ids.has(quest_id): + return _fail("Quest IDs must be unique stable IDs.") + candidate.completed_quest_ids.append(quest_id) + if candidate.completed_quest_ids.size() > MAX_COMPLETED_QUESTS: + return _fail("Completed quest limit exceeded.") + candidate.completed_quest_ids.sort_custom(_name_less) + _copy_from(candidate) + last_error = "" + return true + + +func update_metrics(patch: Dictionary) -> bool: + if _transaction_active: + return _fail("Progression facts cannot change during a rank transaction.") + if quarantined: + return _fail("Progression is quarantined until a valid snapshot is restored.") + if patch.size() > METRIC_IDS.size(): + return _fail("Metric patch is too large.") + var candidate := metrics.duplicate(true) + var seen: Dictionary = {} + for raw_metric_id: Variant in patch: + if not _is_string_variant(raw_metric_id): + return _fail("Metric IDs must be strings.") + var metric_id := StringName(raw_metric_id) + if not METRIC_IDS.has(metric_id) or seen.has(metric_id): + return _fail("Metric patch contains an unknown or duplicate canonical ID.") + seen[metric_id] = true + var raw_value: Variant = patch[raw_metric_id] + if not _is_integer_variant(raw_value): + return _fail("Progression metrics must be integers.") + var value := int(raw_value) + if value < 0 or value > MAX_SAFE_INTEGER \ + or (PERCENT_METRICS.has(metric_id) and value > 100): + return _fail("Progression metric is outside its valid range.") + if LIFETIME_METRICS.has(metric_id) and value < int(metrics[metric_id]): + return _fail("Lifetime progression metrics cannot decrease.") + candidate[metric_id] = value + metrics = candidate + last_error = "" + return true + + +func complete_quest(quest_id: StringName) -> bool: + if _transaction_active: + return _fail("Progression facts cannot change during a rank transaction.") + if quarantined: + return _fail("Progression is quarantined until a valid snapshot is restored.") + if not _is_stable_id(String(quest_id)): + return _fail("Quest ID must be a stable lowercase ID.") + if completed_quest_ids.has(quest_id): + last_error = "" + return true + if completed_quest_ids.size() >= MAX_COMPLETED_QUESTS: + return _fail("Completed quest limit exceeded.") + completed_quest_ids.append(quest_id) + completed_quest_ids.sort_custom(_name_less) + last_error = "" + return true + + +func evaluate_rank(target_rank: int = -1) -> Dictionary: + var target := current_rank + 1 if target_rank < 0 else target_rank + var requirements: Array[Dictionary] = [] + if target != current_rank + 1 or target < 2 or target > MAX_RANK: + return { + &"eligible": false, + &"current_rank": current_rank, + &"target_rank": target, + &"requirements": requirements, + &"reason": &"TARGET_MUST_BE_NEXT_RANK", + } + + match target: + 2: + _require_metric(requirements, &"reputation", 25) + _require_metric(requirements, &"asset_gold", 10_000) + 3: + _require_metric(requirements, &"faith_lifetime", 100) + _require_metric(requirements, &"guest_lifetime", 300) + 4: + _require_metric(requirements, &"reputation", 45) + _require_metric(requirements, &"asset_gold", 50_000) + 5: + _require_metric(requirements, &"faith_lifetime", 500) + _require_quest(requirements, &"important_rank_5") + 6: + _require_either_metric( + requirements, + &"reputation_or_underground_influence", + &"reputation", + &"underground_influence", + 65 + ) + 7: + _require_metric(requirements, &"faith_lifetime", 1_500) + _require_metric(requirements, &"reputation", 60) + _require_metric(requirements, &"underground_influence", 40) + _require_quest(requirements, &"main_chapter_complete") + if target >= 4: + _require_metric(requirements, &"active_above_ground_facilities", 1) + _require_metric(requirements, &"active_underground_facilities", 1) + + var eligible := not quarantined + for requirement: Dictionary in requirements: + if not bool(requirement[&"met"]): + eligible = false + return { + &"eligible": eligible, + &"current_rank": current_rank, + &"target_rank": target, + &"requirements": requirements, + &"reason": &"OK" if eligible else (&"QUARANTINED" if quarantined else &"REQUIREMENTS_NOT_MET"), + &"bounds": get_rank_bounds(target), + } + + +func request_rank_up( + request_id: StringName, + target_rank: int, + capture_callback: Callable = Callable(), + apply_callback: Callable = Callable(), + rollback_callback: Callable = Callable() +) -> CommandResult: + if not _is_stable_id(String(request_id)): + return _failure(CommandResult.Code.INVALID_PAYLOAD, "Rank request ID must be a stable lowercase ID.") + var fingerprint := "rank:%d" % target_rank + if _processed_requests.has(request_id): + var previous: Dictionary = _processed_requests[request_id] + if String(previous[&"fingerprint"]) != fingerprint: + return _failure( + CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, + "Rank request ID was already used for different input." + ) + var replay := CommandResult.succeeded(0, previous[&"details"]) + replay.is_replay = true + last_error = "" + return replay + if _transaction_active: + return _failure(CommandResult.Code.TRANSACTION_CLOSED, "Another rank transaction is active.") + if quarantined: + return _failure( + CommandResult.Code.TRANSACTION_CLOSED, + "Progression is quarantined until a valid snapshot is restored." + ) + if _processed_requests.size() >= MAX_PROCESSED_REQUESTS: + return _failure(CommandResult.Code.VALIDATION_FAILED, "Processed rank request limit exceeded.") + var callback_count := int(capture_callback.is_valid()) + int(apply_callback.is_valid()) + int(rollback_callback.is_valid()) + if callback_count not in [0, 3]: + return _failure( + CommandResult.Code.INVALID_PAYLOAD, + "External rank mutation requires capture, apply, and rollback callbacks together." + ) + var eligibility := evaluate_rank(target_rank) + if not bool(eligibility[&"eligible"]): + return _failure( + CommandResult.Code.VALIDATION_FAILED, + "World Tree rank requirements are not met.", + eligibility + ) + + if callback_count == 3: + var internal_before := ProgressionService.new() + if not internal_before.restore_snapshot(create_snapshot()): + return _failure(CommandResult.Code.TRANSACTION_CLOSED, "Progression could not capture its internal transaction state.") + _transaction_active = true + var capture_result: Variant = capture_callback.call(current_rank, target_rank) + var capture_ok: Variant = ( + capture_result.get(&"ok", capture_result.get("ok", null)) + if capture_result is Dictionary else null + ) + if not capture_result is Dictionary \ + or not capture_ok is bool \ + or not bool(capture_ok) \ + or (not capture_result.has(&"snapshot") and not capture_result.has("snapshot")): + var capture_tampered := create_snapshot() != internal_before.create_snapshot() + _transaction_active = false + _copy_from(internal_before) + if capture_tampered: + quarantined = true + quarantine_reason = &"RANK_CALLBACK_TAMPERED" + return _failure(CommandResult.Code.TRANSACTION_CLOSED, "Rank capture mutated progression and the external state is uncertain.") + return _failure(CommandResult.Code.TRANSACTION_APPLY_FAILED, "Rank mutation capture failed.") + if create_snapshot() != internal_before.create_snapshot(): + var capture_rollback_result: Variant = rollback_callback.call( + capture_result.get(&"snapshot", capture_result.get("snapshot")) + ) + _transaction_active = false + _copy_from(internal_before) + if not _callback_succeeded(capture_rollback_result): + quarantined = true + quarantine_reason = &"RANK_ROLLBACK_FAILED" + return _failure( + CommandResult.Code.TRANSACTION_CLOSED, + "Rank capture mutated progression and rollback could not restore external state." + ) + return _failure( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + "Rank capture mutated progression and was rolled back before apply." + ) + var apply_result: Variant = apply_callback.call(target_rank, get_rank_bounds(target_rank)) + var callback_tampered := create_snapshot() != internal_before.create_snapshot() + if not _callback_succeeded(apply_result) or callback_tampered: + var rollback_result: Variant = rollback_callback.call( + capture_result.get(&"snapshot", capture_result.get("snapshot")) + ) + _transaction_active = false + _copy_from(internal_before) + if not _callback_succeeded(rollback_result): + quarantined = true + quarantine_reason = &"RANK_ROLLBACK_FAILED" + return _failure( + CommandResult.Code.TRANSACTION_CLOSED, + "Rank mutation failed and rollback could not restore external state." + ) + return _failure( + CommandResult.Code.TRANSACTION_APPLY_FAILED, + "Rank callback mutated progression and was rolled back." if callback_tampered \ + else "Rank mutation failed and was rolled back." + ) + _transaction_active = false + + current_rank = target_rank + var details := { + &"rank": current_rank, + &"bounds": get_rank_bounds(current_rank), + &"eligibility": eligibility.duplicate(true), + &"request_id": request_id, + } + _processed_requests[request_id] = { + &"fingerprint": fingerprint, + &"details": details.duplicate(true), + } + last_error = "" + return CommandResult.succeeded(0, details) + + +func get_rank_bounds(rank: int = current_rank) -> Dictionary: + var source: Variant = RANK_BOUNDS.get(rank, {}) + return source.duplicate(true) if source is Dictionary else {} + + +func create_snapshot() -> Dictionary: + var requests: Array[Dictionary] = [] + var request_ids: Array[StringName] = [] + for raw_request_id: Variant in _processed_requests: + request_ids.append(StringName(raw_request_id)) + request_ids.sort_custom(_name_less) + for request_id: StringName in request_ids: + var record: Dictionary = _processed_requests[request_id] + requests.append({ + &"request_id": String(request_id), + &"fingerprint": String(record[&"fingerprint"]), + &"details": record[&"details"].duplicate(true), + }) + return { + &"version": SNAPSHOT_VERSION, + &"current_rank": current_rank, + &"metrics": _string_key_dictionary(metrics), + &"completed_quest_ids": _names_to_strings(completed_quest_ids), + &"processed_requests": requests, + &"quarantined": quarantined, + &"quarantine_reason": String(quarantine_reason), + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _transaction_active: + return _fail("Progression cannot restore during a rank transaction.") + if not _keys_match(snapshot, [ + &"completed_quest_ids", &"current_rank", &"metrics", &"processed_requests", + &"quarantine_reason", &"quarantined", &"version", + ]): + return _fail("Progression snapshot root fields are invalid.") + var version_value: Variant = snapshot.get(&"version", null) + if not _is_integer_variant(version_value) \ + or int(version_value) not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + return _fail("Unsupported Progression snapshot version.") + var snapshot_version := int(version_value) + if not snapshot.get(&"metrics", null) is Dictionary \ + or not snapshot.get(&"completed_quest_ids", null) is Array \ + or not snapshot.get(&"processed_requests", null) is Array \ + or not snapshot.get(&"quarantined", null) is bool \ + or not _is_string_variant(snapshot.get(&"quarantine_reason", null)): + return _fail("Progression snapshot shape is invalid.") + var rank_value: Variant = snapshot.get(&"current_rank", null) + if not _is_integer_variant(rank_value): + return _fail("Progression snapshot rank is invalid.") + var candidate := ProgressionService.new() + candidate.current_rank = int(rank_value) + if candidate.current_rank < MIN_RANK or candidate.current_rank > MAX_RANK: + return _fail("Progression snapshot rank is outside 1 through 7.") + if not candidate._replace_metrics(snapshot[&"metrics"], false): + return _fail(candidate.last_error) + if snapshot[&"completed_quest_ids"].size() > MAX_COMPLETED_QUESTS: + return _fail("Progression snapshot quest limit exceeded.") + for raw_quest_id: Variant in snapshot[&"completed_quest_ids"]: + if not _is_string_variant(raw_quest_id): + return _fail("Progression snapshot quest ID is invalid.") + var quest_id := StringName(raw_quest_id) + if not _is_stable_id(String(quest_id)) or candidate.completed_quest_ids.has(quest_id): + return _fail("Progression snapshot contains duplicate or invalid quest IDs.") + candidate.completed_quest_ids.append(quest_id) + var sorted_quests := candidate.completed_quest_ids.duplicate() + sorted_quests.sort_custom(_name_less) + if sorted_quests != candidate.completed_quest_ids: + return _fail("Progression snapshot quest IDs are not canonical.") + if snapshot[&"processed_requests"].size() > MAX_PROCESSED_REQUESTS: + return _fail("Progression snapshot request limit exceeded.") + if snapshot_version == LEGACY_SNAPSHOT_VERSION: + if candidate.current_rank != MIN_RANK or not snapshot[&"processed_requests"].is_empty(): + return _fail("Legacy progressed snapshots lack verifiable promotion evidence.") + candidate.quarantined = bool(snapshot[&"quarantined"]) + candidate.quarantine_reason = StringName(snapshot[&"quarantine_reason"]) + if not _quarantine_state_is_valid(candidate.quarantined, candidate.quarantine_reason): + return _fail("Progression quarantine state is inconsistent.") + _copy_from(candidate) + last_error = "" + return true + var previous_request_id := "" + var recorded_target_ranks: Dictionary = {} + for raw_record: Variant in snapshot[&"processed_requests"]: + if not raw_record is Dictionary: + return _fail("Progression snapshot request record is invalid.") + var record := raw_record as Dictionary + if not _keys_match(record, [&"details", &"fingerprint", &"request_id"]): + return _fail("Progression snapshot request fields are invalid.") + if not _is_string_variant(record.get(&"request_id", null)) \ + or not _is_string_variant(record.get(&"fingerprint", null)) \ + or not record.get(&"details", null) is Dictionary: + return _fail("Progression snapshot request record shape is invalid.") + var request_id := StringName(record[&"request_id"]) + var request_text := String(request_id) + if not _is_stable_id(request_text) \ + or request_text <= previous_request_id \ + or candidate._processed_requests.has(request_id): + return _fail("Progression snapshot request IDs are not canonical.") + var fingerprint := String(record[&"fingerprint"]) + var details: Dictionary = record[&"details"] + if not _keys_match(details, [&"bounds", &"eligibility", &"rank", &"request_id"]): + return _fail("Progression snapshot request detail fields are invalid.") + var detail_rank: Variant = details.get(&"rank", null) + var detail_request_id: Variant = details.get(&"request_id", null) + var detail_bounds: Variant = details.get(&"bounds", null) + var detail_eligibility: Variant = details.get(&"eligibility", null) + if not _is_integer_variant(detail_rank) \ + or not _is_string_variant(detail_request_id) \ + or not detail_bounds is Dictionary \ + or not detail_eligibility is Dictionary \ + or not _bounds_shape_is_strict(detail_bounds) \ + or fingerprint != "rank:%d" % int(detail_rank) \ + or int(detail_rank) < 2 \ + or int(detail_rank) > candidate.current_rank \ + or StringName(detail_request_id) != request_id \ + or not _bounds_equal_expected(detail_bounds, candidate.get_rank_bounds(int(detail_rank))) \ + or recorded_target_ranks.has(int(detail_rank)): + return _fail("Progression snapshot request details are inconsistent.") + if not candidate._eligibility_evidence_is_strict(detail_eligibility, int(detail_rank)): + return _fail(candidate.last_error) + recorded_target_ranks[int(detail_rank)] = true + var canonical_details := candidate._canonical_promotion_details( + request_id, + int(detail_rank), + detail_eligibility + ) + candidate._processed_requests[request_id] = { + &"fingerprint": fingerprint, + &"details": canonical_details, + } + previous_request_id = request_text + if recorded_target_ranks.size() != candidate.current_rank - MIN_RANK: + return _fail("Progression snapshot promotion history is incomplete.") + for expected_rank in range(MIN_RANK + 1, candidate.current_rank + 1): + if not recorded_target_ranks.has(expected_rank): + return _fail("Progression snapshot promotion history is not contiguous.") + if not candidate._historical_evidence_matches_monotonic_facts(): + return _fail(candidate.last_error) + candidate.quarantined = bool(snapshot[&"quarantined"]) + candidate.quarantine_reason = StringName(snapshot[&"quarantine_reason"]) + if not _quarantine_state_is_valid(candidate.quarantined, candidate.quarantine_reason): + return _fail("Progression quarantine state is inconsistent.") + _copy_from(candidate) + last_error = "" + return true + + +func _replace_metrics(source: Dictionary, allow_partial: bool) -> bool: + if source.size() > METRIC_IDS.size(): + return _fail("Metric set is too large.") + var candidate := DEFAULT_METRICS.duplicate(true) if allow_partial else {} + var seen: Dictionary = {} + for raw_metric_id: Variant in source: + if not _is_string_variant(raw_metric_id): + return _fail("Metric IDs must be strings.") + var metric_id := StringName(raw_metric_id) + if not METRIC_IDS.has(metric_id) or seen.has(metric_id): + return _fail("Metrics contain an unknown or duplicate canonical ID.") + seen[metric_id] = true + var raw_value: Variant = source[raw_metric_id] + if not _is_integer_variant(raw_value): + return _fail("Progression metrics must be integers.") + var value := int(raw_value) + if value < 0 or value > MAX_SAFE_INTEGER \ + or (PERCENT_METRICS.has(metric_id) and value > 100): + return _fail("Progression metric is outside its valid range.") + candidate[metric_id] = value + if not allow_partial and seen.size() != METRIC_IDS.size(): + return _fail("Progression snapshot must contain every metric exactly once.") + metrics = candidate + last_error = "" + return true + + +func _eligibility_evidence_is_strict(evidence: Dictionary, target_rank: int) -> bool: + if not _keys_match(evidence, [ + &"bounds", &"current_rank", &"eligible", &"reason", &"requirements", &"target_rank", + ]): + return _fail("Progression promotion evidence fields are invalid.") + var evidence_current_rank: Variant = evidence.get(&"current_rank", null) + var evidence_target_rank: Variant = evidence.get(&"target_rank", null) + var evidence_eligible: Variant = evidence.get(&"eligible", null) + var evidence_reason: Variant = evidence.get(&"reason", null) + var evidence_bounds: Variant = evidence.get(&"bounds", null) + var raw_requirements: Variant = evidence.get(&"requirements", null) + if not _is_integer_variant(evidence_current_rank) \ + or not _is_integer_variant(evidence_target_rank) \ + or not evidence_eligible is bool \ + or not _is_string_variant(evidence_reason) \ + or not evidence_bounds is Dictionary \ + or not raw_requirements is Array: + return _fail("Progression promotion evidence shape is invalid.") + if int(evidence_current_rank) != target_rank - 1 \ + or int(evidence_target_rank) != target_rank \ + or not bool(evidence_eligible) \ + or StringName(evidence_reason) != &"OK" \ + or not _bounds_shape_is_strict(evidence_bounds) \ + or not _bounds_equal_expected(evidence_bounds, get_rank_bounds(target_rank)): + return _fail("Progression promotion evidence header is inconsistent.") + var expected := _expected_requirement_descriptors(target_rank) + var requirements := raw_requirements as Array + if requirements.size() != expected.size(): + return _fail("Progression promotion evidence requirements are incomplete.") + for index in requirements.size(): + var raw_requirement: Variant = requirements[index] + if not raw_requirement is Dictionary: + return _fail("Progression promotion evidence requirement is invalid.") + var requirement := raw_requirement as Dictionary + var descriptor: Dictionary = expected[index] + var kind := StringName(descriptor[&"kind"]) + var expected_fields: Array = [&"comparison", &"current", &"id", &"met", &"required"] + if kind == &"either": + expected_fields.append(&"values") + if not _keys_match(requirement, expected_fields): + return _fail("Progression promotion evidence requirement fields are invalid.") + var raw_id: Variant = requirement.get(&"id", null) + var raw_comparison: Variant = requirement.get(&"comparison", null) + var raw_current: Variant = requirement.get(&"current", null) + var raw_required: Variant = requirement.get(&"required", null) + var raw_met: Variant = requirement.get(&"met", null) + if not _is_string_variant(raw_id) \ + or not _is_string_variant(raw_comparison) \ + or not _is_integer_variant(raw_current) \ + or not _is_integer_variant(raw_required) \ + or not raw_met is bool: + return _fail("Progression promotion evidence requirement shape is invalid.") + var current := int(raw_current) + if StringName(raw_id) != StringName(descriptor[&"id"]) \ + or StringName(raw_comparison) != StringName(descriptor[&"comparison"]) \ + or int(raw_required) != int(descriptor[&"required"]) \ + or not bool(raw_met) \ + or current < 0 \ + or current > MAX_SAFE_INTEGER: + return _fail("Progression promotion evidence requirement values are inconsistent.") + match kind: + &"quest": + if current != 1: + return _fail("Progression promotion quest evidence is not complete.") + &"either": + var raw_values: Variant = requirement.get(&"values", null) + if not raw_values is Dictionary \ + or not _keys_match(raw_values, [&"reputation", &"underground_influence"]): + return _fail("Progression alternative evidence values are invalid.") + var first: Variant = raw_values.get("reputation", raw_values.get(&"reputation", null)) + var second: Variant = raw_values.get("underground_influence", raw_values.get(&"underground_influence", null)) + if not _is_integer_variant(first) or not _is_integer_variant(second) \ + or int(first) < 0 or int(first) > 100 \ + or int(second) < 0 or int(second) > 100 \ + or current != maxi(int(first), int(second)) \ + or (int(first) < int(raw_required) and int(second) < int(raw_required)): + return _fail("Progression alternative evidence does not meet its threshold.") + _: + if current < int(raw_required): + return _fail("Progression metric evidence does not meet its threshold.") + var metric_id := StringName(descriptor[&"id"]) + if PERCENT_METRICS.has(metric_id) and current > 100: + return _fail("Progression percentage evidence is outside its valid range.") + last_error = "" + return true + + +func _canonical_promotion_details( + request_id: StringName, + target_rank: int, + evidence: Dictionary +) -> Dictionary: + var canonical_requirements: Array[Dictionary] = [] + for raw_requirement: Variant in evidence.get(&"requirements", evidence.get("requirements", [])): + var requirement := raw_requirement as Dictionary + var canonical := { + &"id": StringName(requirement.get(&"id", requirement.get("id", ""))), + &"comparison": StringName(requirement.get(&"comparison", requirement.get("comparison", ""))), + &"current": int(requirement.get(&"current", requirement.get("current", 0))), + &"required": int(requirement.get(&"required", requirement.get("required", 0))), + &"met": bool(requirement.get(&"met", requirement.get("met", false))), + } + var raw_values: Variant = requirement.get(&"values", requirement.get("values", null)) + if raw_values is Dictionary: + canonical[&"values"] = { + "reputation": int(raw_values.get(&"reputation", raw_values.get("reputation", 0))), + "underground_influence": int(raw_values.get(&"underground_influence", raw_values.get("underground_influence", 0))), + } + canonical_requirements.append(canonical) + return { + &"rank": target_rank, + &"bounds": get_rank_bounds(target_rank), + &"eligibility": { + &"eligible": true, + &"current_rank": target_rank - 1, + &"target_rank": target_rank, + &"requirements": canonical_requirements, + &"reason": &"OK", + &"bounds": get_rank_bounds(target_rank), + }, + &"request_id": request_id, + } + + +func _historical_evidence_matches_monotonic_facts() -> bool: + for raw_record: Variant in _processed_requests.values(): + var record := raw_record as Dictionary + var details := record[&"details"] as Dictionary + var evidence := details[&"eligibility"] as Dictionary + for raw_requirement: Variant in evidence[&"requirements"]: + var requirement := raw_requirement as Dictionary + var comparison := StringName(requirement[&"comparison"]) + var requirement_id := StringName(requirement[&"id"]) + if comparison == &"QUEST_COMPLETED" and not completed_quest_ids.has(requirement_id): + return _fail("Progression snapshot lost a quest used by promotion history.") + if LIFETIME_METRICS.has(requirement_id) \ + and int(metrics[requirement_id]) < int(requirement[&"current"]): + return _fail("Progression snapshot lifetime metric is below promotion history.") + last_error = "" + return true + + +func _expected_requirement_descriptors(target_rank: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + match target_rank: + 2: + result.append(_metric_descriptor(&"reputation", 25)) + result.append(_metric_descriptor(&"asset_gold", 10_000)) + 3: + result.append(_metric_descriptor(&"faith_lifetime", 100)) + result.append(_metric_descriptor(&"guest_lifetime", 300)) + 4: + result.append(_metric_descriptor(&"reputation", 45)) + result.append(_metric_descriptor(&"asset_gold", 50_000)) + 5: + result.append(_metric_descriptor(&"faith_lifetime", 500)) + result.append({ + &"id": &"important_rank_5", &"comparison": &"QUEST_COMPLETED", + &"required": 1, &"kind": &"quest", + }) + 6: + result.append({ + &"id": &"reputation_or_underground_influence", + &"comparison": &"EITHER_AT_LEAST", &"required": 65, &"kind": &"either", + }) + 7: + result.append(_metric_descriptor(&"faith_lifetime", 1_500)) + result.append(_metric_descriptor(&"reputation", 60)) + result.append(_metric_descriptor(&"underground_influence", 40)) + result.append({ + &"id": &"main_chapter_complete", &"comparison": &"QUEST_COMPLETED", + &"required": 1, &"kind": &"quest", + }) + if target_rank >= 4: + result.append(_metric_descriptor(&"active_above_ground_facilities", 1)) + result.append(_metric_descriptor(&"active_underground_facilities", 1)) + return result + + +static func _metric_descriptor(metric_id: StringName, required: int) -> Dictionary: + return { + &"id": metric_id, + &"comparison": &"AT_LEAST", + &"required": required, + &"kind": &"metric", + } + + +func _require_metric(requirements: Array[Dictionary], metric_id: StringName, required: int) -> void: + var current := int(metrics[metric_id]) + requirements.append({ + &"id": metric_id, + &"comparison": &"AT_LEAST", + &"current": current, + &"required": required, + &"met": current >= required, + }) + + +func _require_quest(requirements: Array[Dictionary], quest_id: StringName) -> void: + requirements.append({ + &"id": quest_id, + &"comparison": &"QUEST_COMPLETED", + &"current": int(completed_quest_ids.has(quest_id)), + &"required": 1, + &"met": completed_quest_ids.has(quest_id), + }) + + +func _require_either_metric( + requirements: Array[Dictionary], + requirement_id: StringName, + first_metric_id: StringName, + second_metric_id: StringName, + required: int +) -> void: + var first := int(metrics[first_metric_id]) + var second := int(metrics[second_metric_id]) + requirements.append({ + &"id": requirement_id, + &"comparison": &"EITHER_AT_LEAST", + &"current": maxi(first, second), + &"required": required, + &"met": first >= required or second >= required, + &"values": {String(first_metric_id): first, String(second_metric_id): second}, + }) + + +func _copy_from(source: ProgressionService) -> void: + current_rank = source.current_rank + metrics = source.metrics.duplicate(true) + completed_quest_ids.assign(source.completed_quest_ids) + _processed_requests = source._processed_requests.duplicate(true) + quarantined = source.quarantined + quarantine_reason = source.quarantine_reason + + +func _failure(code: CommandResult.Code, message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResult.failed(code, 0, message, details) + + +func _fail(message: String) -> bool: + last_error = message + return false + + +static func _callback_succeeded(value: Variant) -> bool: + if value is CommandResult: + return (value as CommandResult).is_success() + if value is Dictionary: + var ok_value: Variant = value.get(&"ok", value.get("ok", null)) + return ok_value is bool and bool(ok_value) + return value is bool and bool(value) + + +static func _is_integer_variant(value: Variant) -> bool: + return value is int or (value is float and is_finite(value) and value == floor(value)) + + +static func _is_string_variant(value: Variant) -> bool: + return value is String or value is StringName + + +static func _is_stable_id(value: String) -> bool: + if value.is_empty() or value.length() > 128 or value[0] < "a" or value[0] > "z": + return false + for index in value.length(): + var character := value[index] + if not ((character >= "a" and character <= "z") or character == "_" or (character >= "0" and character <= "9")): + return false + return not value.ends_with("_") and not "__" in value + + +static func _name_less(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + + +static func _quarantine_state_is_valid(value: bool, reason: StringName) -> bool: + return (value and QUARANTINE_REASONS.has(reason)) or (not value and reason.is_empty()) + + +static func _keys_match(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + var seen: Dictionary = {} + for raw_key: Variant in source: + if not _is_string_variant(raw_key): + return false + var key := StringName(raw_key) + if seen.has(key) or not expected.has(key): + return false + seen[key] = true + return seen.size() == expected.size() + + +static func _bounds_shape_is_strict(bounds: Dictionary) -> bool: + if not _keys_match(bounds, [&"max_x", &"max_y", &"min_x", &"min_y"]): + return false + for field_id: StringName in [&"max_x", &"max_y", &"min_x", &"min_y"]: + if not _is_integer_variant(bounds.get(field_id, bounds.get(String(field_id), null))): + return false + return true + + +static func _bounds_equal_expected(bounds: Dictionary, expected: Dictionary) -> bool: + if not _bounds_shape_is_strict(bounds) or not _bounds_shape_is_strict(expected): + return false + for field_id: StringName in [&"max_x", &"max_y", &"min_x", &"min_y"]: + var actual: Variant = bounds.get(field_id, bounds.get(String(field_id), null)) + var wanted: Variant = expected.get(field_id, expected.get(String(field_id), null)) + if int(actual) != int(wanted): + return false + return true + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + for key: StringName in METRIC_IDS: + result[String(key)] = int(source[key]) + return result diff --git a/scripts/progression/progression_service.gd.uid b/scripts/progression/progression_service.gd.uid new file mode 100644 index 0000000..86151df --- /dev/null +++ b/scripts/progression/progression_service.gd.uid @@ -0,0 +1 @@ +uid://minvgfo8gkdj diff --git a/scripts/relationship/placement_risk_preview_service.gd b/scripts/relationship/placement_risk_preview_service.gd new file mode 100644 index 0000000..62fe3db --- /dev/null +++ b/scripts/relationship/placement_risk_preview_service.gd @@ -0,0 +1,1118 @@ +class_name PlacementRiskPreviewService +extends RefCounted + +## Pure, fail-closed preview for relationship risks caused by proposed shifts. +## +## The service owns no state and invokes no callback. Serialized dictionaries +## are deliberately used at this boundary so UI previews cannot mutate live +## ShiftInstance, RelationshipState, or EventDefinition objects. + +const SCHEMA_VERSION := 1 +const MINUTES_PER_DAY := 1_440 +const MAX_PERIOD_MINUTES := 14 * MINUTES_PER_DAY +const MAX_ABSOLUTE_MINUTE := 1_000_000_000 +const MAX_ACTORS := 64 +const MAX_SHIFTS_PER_ACTOR := 64 +const MAX_RELATIONSHIPS := 32 +const MAX_EVENTS := 256 +const MAX_CONTACT_INTERVALS := 4_096 +const MAX_ID_LENGTH := 128 +const MIN_SHIELD_CONTACT_MINUTES := 30 +const SHIELD_ATTACK_INTERVAL_MINUTES := 60 +const MAX_ATTACK_DAMAGE := 100 + +const ROLE_HEROINE := "HEROINE" +const ROLE_RIVAL := "RIVAL" +const ROLE_OTHER := "OTHER" + +const FILTER_MODES: Array[String] = ["NONE", "FADE", "REPLACE", "DISABLE"] +const CONTENT_RATINGS: Array[String] = ["GENERAL", "MATURE", "RESTRICTED"] +const ROUTE_LOCKS: Array[String] = [ + "NONE", "SOFT", "EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED", "CLOSED", +] + + +func preview(request: Variant) -> Dictionary: + var parsed := _parse_request(request) + if not bool(parsed.get(&"ok", false)): + return parsed + return _build_preview(parsed[&"value"], true) + + +func _parse_request(request: Variant) -> Dictionary: + if not request is Dictionary: + return _error("request", "INVALID_TYPE", "Preview request must be a Dictionary.") + if not _has_exact_keys(request, [ + &"schema_version", &"period", &"actors", &"relationships", &"events", &"content_filter", + ]): + return _error("request", "INVALID_SCHEMA", "Preview request fields must match schema v1 exactly.") + if not request[&"schema_version"] is int or int(request[&"schema_version"]) != SCHEMA_VERSION: + return _error("schema_version", "UNSUPPORTED_SCHEMA", "Preview schema version must be integer 1.") + + var period_result := _parse_period(request[&"period"]) + if not bool(period_result[&"ok"]): + return period_result + var filter_result := _parse_content_filter(request[&"content_filter"]) + if not bool(filter_result[&"ok"]): + return filter_result + var actors_result := _parse_actors(request[&"actors"]) + if not bool(actors_result[&"ok"]): + return actors_result + var actors: Array[Dictionary] = actors_result[&"values"] + var actor_index: Dictionary = actors_result[&"index"] + var relationships_result := _parse_relationships(request[&"relationships"], actor_index) + if not bool(relationships_result[&"ok"]): + return relationships_result + var relationships: Array[Dictionary] = relationships_result[&"values"] + var relationship_ids: Dictionary = relationships_result[&"ids"] + var events_result := _parse_events(request[&"events"], relationship_ids) + if not bool(events_result[&"ok"]): + return events_result + var events: Array[Dictionary] = events_result[&"values"] + var replacement_result := _validate_replacements(events, filter_result[&"value"]) + if not bool(replacement_result[&"ok"]): + return replacement_result + return { + &"ok": true, + &"value": { + &"period": period_result[&"value"], + &"actors": actors, + &"actor_index": actor_index, + &"relationships": relationships, + &"events": events, + &"content_filter": filter_result[&"value"], + }, + } + + +func _parse_period(raw: Variant) -> Dictionary: + if not raw is Dictionary or not _has_exact_keys(raw, [&"start_at", &"end_at"]): + return _error("period", "INVALID_SCHEMA", "Period requires only start_at and end_at.") + if not raw[&"start_at"] is int or not raw[&"end_at"] is int: + return _error("period", "INVALID_TYPE", "Period bounds must be integers.") + var start_at := int(raw[&"start_at"]) + var end_at := int(raw[&"end_at"]) + if ( + start_at < 0 + or end_at <= start_at + or end_at > MAX_ABSOLUTE_MINUTE + or end_at - start_at > MAX_PERIOD_MINUTES + ): + return _error("period", "OUT_OF_RANGE", "Period must be a positive half-open interval of at most 14 days.") + return {&"ok": true, &"value": {&"start_at": start_at, &"end_at": end_at}} + + +func _parse_content_filter(raw: Variant) -> Dictionary: + if not raw is Dictionary or not _has_exact_keys(raw, [&"mode", &"maximum_rating", &"disabled_tags"]): + return _error("content_filter", "INVALID_SCHEMA", "Content Filter fields must match exactly.") + if not raw[&"mode"] is String or String(raw[&"mode"]) not in FILTER_MODES: + return _error("content_filter.mode", "INVALID_ENUM", "Unknown Content Filter mode.") + if not raw[&"maximum_rating"] is String or String(raw[&"maximum_rating"]) not in CONTENT_RATINGS: + return _error("content_filter.maximum_rating", "INVALID_ENUM", "Unknown maximum content rating.") + var tags_result := _strict_string_array(raw[&"disabled_tags"], 64, "content_filter.disabled_tags") + if not bool(tags_result[&"ok"]): + return tags_result + return { + &"ok": true, + &"value": { + &"mode": String(raw[&"mode"]), + &"maximum_rating": String(raw[&"maximum_rating"]), + &"disabled_tags": tags_result[&"values"], + }, + } + + +func _parse_actors(raw: Variant) -> Dictionary: + if not raw is Array or raw.is_empty() or raw.size() > MAX_ACTORS: + return _error("actors", "COLLECTION_LIMIT", "Actors must contain 1 to 64 entries.") + var actors: Array[Dictionary] = [] + var actor_index: Dictionary = {} + for index: int in range(raw.size()): + var actor_raw: Variant = raw[index] + var path := "actors[%d]" % index + if not actor_raw is Dictionary or not _has_exact_keys(actor_raw, [ + &"actor_id", &"role", &"existing_shifts", &"proposed_shifts", &"mechanical_effects", + ]): + return _error(path, "INVALID_SCHEMA", "Actor fields must match exactly.") + if not _valid_id(actor_raw[&"actor_id"]): + return _error(path + ".actor_id", "INVALID_ID", "Actor ID must be a bounded non-empty string.") + var actor_id := String(actor_raw[&"actor_id"]) + if actor_index.has(actor_id): + return _error(path + ".actor_id", "DUPLICATE_ID", "Actor IDs must be unique.") + if not actor_raw[&"role"] is String or String(actor_raw[&"role"]) not in [ROLE_HEROINE, ROLE_RIVAL, ROLE_OTHER]: + return _error(path + ".role", "INVALID_ENUM", "Unknown actor relationship role.") + var effects_result := _parse_mechanical_effects(actor_raw[&"mechanical_effects"], path + ".mechanical_effects") + if not bool(effects_result[&"ok"]): + return effects_result + var existing_result := _parse_shifts(actor_raw[&"existing_shifts"], path + ".existing_shifts") + if not bool(existing_result[&"ok"]): + return existing_result + var proposed_result := _parse_shifts(actor_raw[&"proposed_shifts"], path + ".proposed_shifts") + if not bool(proposed_result[&"ok"]): + return proposed_result + var existing: Array[Dictionary] = existing_result[&"values"] + var proposed: Array[Dictionary] = proposed_result[&"values"] + var existing_valid := _validate_single_placement(existing, path + ".existing_shifts") + if not bool(existing_valid[&"ok"]): + return existing_valid + var proposed_valid := _validate_single_placement(proposed, path + ".proposed_shifts") + if not bool(proposed_valid[&"ok"]): + return proposed_valid + var effective := _effective_shifts(existing, proposed) + if effective.size() > MAX_SHIFTS_PER_ACTOR: + return _error(path + ".effective_shifts", "COLLECTION_LIMIT", "Each actor supports at most 64 effective shifts.") + var effective_valid := _validate_single_placement(effective, path + ".effective_shifts") + if not bool(effective_valid[&"ok"]): + return effective_valid + var actor := { + &"actor_id": actor_id, + &"role": String(actor_raw[&"role"]), + &"existing_shifts": existing, + &"proposed_shifts": proposed, + &"effective_shifts": effective, + &"mechanical_effects": effects_result[&"value"], + } + actors.append(actor) + actor_index[actor_id] = actor + actors.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + return String(left[&"actor_id"]) < String(right[&"actor_id"]) + ) + return {&"ok": true, &"values": actors, &"index": actor_index} + + +func _parse_mechanical_effects(raw: Variant, path: String) -> Dictionary: + if not raw is Dictionary or not _has_exact_keys(raw, [ + &"base_shield_attack", &"night_shield_attack_bonus", + ]): + return _error(path, "INVALID_SCHEMA", "Mechanical effect fields must match exactly.") + for key: StringName in [&"base_shield_attack", &"night_shield_attack_bonus"]: + if not raw[key] is int or int(raw[key]) < 0 or int(raw[key]) > MAX_ATTACK_DAMAGE: + return _error(path + "." + String(key), "OUT_OF_RANGE", "Shield attack effects must be integers from 0 to 100.") + return { + &"ok": true, + &"value": { + &"base_shield_attack": int(raw[&"base_shield_attack"]), + &"night_shield_attack_bonus": int(raw[&"night_shield_attack_bonus"]), + }, + } + + +func _parse_shifts(raw: Variant, path: String) -> Dictionary: + if not raw is Array or raw.size() > MAX_SHIFTS_PER_ACTOR: + return _error(path, "COLLECTION_LIMIT", "Each actor supports at most 64 shifts.") + var shifts: Array[Dictionary] = [] + var ids: Dictionary = {} + for index: int in range(raw.size()): + var value: Variant = raw[index] + var item_path := "%s[%d]" % [path, index] + if not value is Dictionary or not _has_exact_keys(value, [ + &"shift_id", &"facility_id", &"start_at", &"end_at", + ]): + return _error(item_path, "INVALID_SCHEMA", "Shift fields must match exactly.") + if not _valid_id(value[&"shift_id"]) or not _valid_id(value[&"facility_id"]): + return _error(item_path, "INVALID_ID", "Shift and facility IDs must be bounded strings.") + var shift_id := String(value[&"shift_id"]) + if ids.has(shift_id): + return _error(item_path + ".shift_id", "DUPLICATE_ID", "Shift IDs must be unique within one list.") + if not value[&"start_at"] is int or not value[&"end_at"] is int: + return _error(item_path, "INVALID_TYPE", "Shift bounds must be integers.") + var start_at := int(value[&"start_at"]) + var end_at := int(value[&"end_at"]) + if ( + start_at < 0 + or end_at <= start_at + or end_at > MAX_ABSOLUTE_MINUTE + or end_at - start_at > MINUTES_PER_DAY + ): + return _error(item_path, "OUT_OF_RANGE", "Shift must be a positive half-open interval no longer than one day.") + ids[shift_id] = true + shifts.append({ + &"shift_id": shift_id, + &"facility_id": String(value[&"facility_id"]), + &"start_at": start_at, + &"end_at": end_at, + }) + _sort_shifts(shifts) + return {&"ok": true, &"values": shifts} + + +func _parse_relationships(raw: Variant, actors: Dictionary) -> Dictionary: + if not raw is Array or raw.is_empty() or raw.size() > MAX_RELATIONSHIPS: + return _error("relationships", "COLLECTION_LIMIT", "Relationships must contain 1 to 32 entries.") + var values: Array[Dictionary] = [] + var ids: Dictionary = {} + var actor_pairs: Dictionary = {} + for index: int in range(raw.size()): + var value: Variant = raw[index] + var path := "relationships[%d]" % index + if not value is Dictionary or not _has_exact_keys(value, [ + &"relationship_id", &"heroine_id", &"rival_id", &"affection", &"rival_bond", + &"shield_current", &"shield_max", &"route_lock", &"route_id", + &"target_resistance", &"monitoring_bonus", + ]): + return _error(path, "INVALID_SCHEMA", "Relationship fields must match exactly.") + for id_key: StringName in [&"relationship_id", &"heroine_id", &"rival_id"]: + if not _valid_id(value[id_key]): + return _error(path + "." + String(id_key), "INVALID_ID", "Relationship IDs must be bounded strings.") + var relationship_id := String(value[&"relationship_id"]) + var heroine_id := String(value[&"heroine_id"]) + var rival_id := String(value[&"rival_id"]) + if ids.has(relationship_id): + return _error(path + ".relationship_id", "DUPLICATE_ID", "Relationship IDs must be unique.") + if heroine_id == rival_id or not actors.has(heroine_id) or not actors.has(rival_id): + return _error(path, "MISSING_ACTOR", "Relationship actors must be distinct registered actors.") + if actors[heroine_id][&"role"] != ROLE_HEROINE or actors[rival_id][&"role"] != ROLE_RIVAL: + return _error(path, "ROLE_MISMATCH", "Relationship heroine and rival roles must match their actor records.") + if actor_pairs.has(heroine_id) and actor_pairs[heroine_id].has(rival_id): + return _error(path, "DUPLICATE_PAIR", "The same heroine and rival pair cannot be previewed twice.") + for metric_key: StringName in [ + &"affection", &"rival_bond", &"shield_current", &"shield_max", + &"target_resistance", &"monitoring_bonus", + ]: + if not value[metric_key] is int or int(value[metric_key]) < 0 or int(value[metric_key]) > 100: + return _error(path + "." + String(metric_key), "OUT_OF_RANGE", "Relationship values must be integers from 0 to 100.") + if int(value[&"shield_current"]) > int(value[&"shield_max"]): + return _error(path + ".shield_current", "INVALID_DERIVED_VALUE", "Current Shield cannot exceed Shield Max.") + if not value[&"route_lock"] is String or String(value[&"route_lock"]) not in ROUTE_LOCKS: + return _error(path + ".route_lock", "INVALID_ENUM", "Unknown Route Lock state.") + if not value[&"route_id"] is String or String(value[&"route_id"]).length() > MAX_ID_LENGTH: + return _error(path + ".route_id", "INVALID_ID", "Route ID must be a bounded string.") + if String(value[&"route_lock"]) in ["EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED"] and String(value[&"route_id"]).is_empty(): + return _error(path + ".route_id", "MISSING_ROUTE", "Exclusive Route Lock requires a route ID.") + ids[relationship_id] = true + if not actor_pairs.has(heroine_id): + actor_pairs[heroine_id] = {} + actor_pairs[heroine_id][rival_id] = true + values.append({ + &"relationship_id": relationship_id, + &"heroine_id": heroine_id, + &"rival_id": rival_id, + &"affection": int(value[&"affection"]), + &"rival_bond": int(value[&"rival_bond"]), + &"shield_current": int(value[&"shield_current"]), + &"shield_max": int(value[&"shield_max"]), + &"route_lock": String(value[&"route_lock"]), + &"route_id": String(value[&"route_id"]), + &"target_resistance": int(value[&"target_resistance"]), + &"monitoring_bonus": int(value[&"monitoring_bonus"]), + }) + values.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + return String(left[&"relationship_id"]) < String(right[&"relationship_id"]) + ) + return {&"ok": true, &"values": values, &"ids": ids} + + +func _parse_events(raw: Variant, relationship_ids: Dictionary) -> Dictionary: + if not raw is Array or raw.size() > MAX_EVENTS: + return _error("events", "COLLECTION_LIMIT", "At most 256 preview Event definitions are supported.") + var values: Array[Dictionary] = [] + var event_ids: Dictionary = {} + for index: int in range(raw.size()): + var value: Variant = raw[index] + var path := "events[%d]" % index + if not value is Dictionary or not _has_exact_keys(value, [ + &"event_id", &"relationship_id", &"priority", &"min_contact_minutes", + &"max_shield_current", &"min_rival_bond", &"max_rival_bond", &"required_facility_id", + &"shield_damage", &"rival_bond_delta", &"cost_gold", &"cooldown_minutes", + &"route_lock", &"route_id", &"content_rating", &"content_tags", + &"filter_behavior", &"replacement_event_id", &"display_name", + ]): + return _error(path, "INVALID_SCHEMA", "Preview Event fields must match exactly.") + if not _valid_id(value[&"event_id"]): + return _error(path + ".event_id", "INVALID_ID", "Event ID must be a bounded string.") + var event_id := String(value[&"event_id"]) + if event_ids.has(event_id): + return _error(path + ".event_id", "DUPLICATE_ID", "Event IDs must be unique.") + if not value[&"relationship_id"] is String: + return _error(path + ".relationship_id", "INVALID_TYPE", "Event relationship ID must be a string.") + var relationship_id := String(value[&"relationship_id"]) + if relationship_id != "*" and not relationship_ids.has(relationship_id): + return _error(path + ".relationship_id", "MISSING_RELATIONSHIP", "Event references an unknown relationship.") + for integer_key: StringName in [ + &"priority", &"min_contact_minutes", &"max_shield_current", &"min_rival_bond", + &"max_rival_bond", &"shield_damage", &"rival_bond_delta", &"cost_gold", &"cooldown_minutes", + ]: + if not value[integer_key] is int: + return _error(path + "." + String(integer_key), "INVALID_TYPE", "Event mechanical fields must be integers.") + if int(value[&"priority"]) < -1_000 or int(value[&"priority"]) > 1_000: + return _error(path + ".priority", "OUT_OF_RANGE", "Event priority is out of range.") + if int(value[&"min_contact_minutes"]) < 0 or int(value[&"min_contact_minutes"]) > MAX_PERIOD_MINUTES: + return _error(path + ".min_contact_minutes", "OUT_OF_RANGE", "Contact threshold is out of range.") + if int(value[&"max_shield_current"]) < 0 or int(value[&"max_shield_current"]) > 100: + return _error(path + ".max_shield_current", "OUT_OF_RANGE", "Shield threshold is out of range.") + if ( + int(value[&"min_rival_bond"]) < 0 + or int(value[&"max_rival_bond"]) > 100 + or int(value[&"min_rival_bond"]) > int(value[&"max_rival_bond"]) + ): + return _error(path + ".rival_bond", "OUT_OF_RANGE", "Rival Bond range is invalid.") + if int(value[&"shield_damage"]) < 0 or int(value[&"shield_damage"]) > 100: + return _error(path + ".shield_damage", "OUT_OF_RANGE", "Event Shield damage is out of range.") + if int(value[&"rival_bond_delta"]) < -100 or int(value[&"rival_bond_delta"]) > 100: + return _error(path + ".rival_bond_delta", "OUT_OF_RANGE", "Event Rival Bond delta is out of range.") + if int(value[&"cost_gold"]) < 0 or int(value[&"cost_gold"]) > 1_000_000_000: + return _error(path + ".cost_gold", "OUT_OF_RANGE", "Event cost is out of range.") + if int(value[&"cooldown_minutes"]) < 0 or int(value[&"cooldown_minutes"]) > MAX_PERIOD_MINUTES: + return _error(path + ".cooldown_minutes", "OUT_OF_RANGE", "Event cooldown is out of range.") + for id_key: StringName in [&"required_facility_id", &"route_id", &"replacement_event_id"]: + if not value[id_key] is String or String(value[id_key]).length() > MAX_ID_LENGTH: + return _error(path + "." + String(id_key), "INVALID_ID", "Optional Event IDs must be bounded strings.") + if not value[&"route_lock"] is String or String(value[&"route_lock"]) not in ROUTE_LOCKS: + return _error(path + ".route_lock", "INVALID_ENUM", "Unknown Event Route Lock.") + if String(value[&"route_lock"]) in ["EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED"] and String(value[&"route_id"]).is_empty(): + return _error(path + ".route_id", "MISSING_ROUTE", "Exclusive Event Route Lock requires a route ID.") + if not value[&"content_rating"] is String or String(value[&"content_rating"]) not in CONTENT_RATINGS: + return _error(path + ".content_rating", "INVALID_ENUM", "Unknown Event content rating.") + var tags_result := _strict_string_array(value[&"content_tags"], 32, path + ".content_tags") + if not bool(tags_result[&"ok"]): + return tags_result + if not value[&"filter_behavior"] is String or String(value[&"filter_behavior"]) not in FILTER_MODES: + return _error(path + ".filter_behavior", "INVALID_ENUM", "Unknown Event filter behavior.") + if String(value[&"filter_behavior"]) == "REPLACE" and String(value[&"replacement_event_id"]).is_empty(): + return _error(path + ".replacement_event_id", "MISSING_REPLACEMENT", "REPLACE Event requires a replacement Event ID.") + if not value[&"display_name"] is String or String(value[&"display_name"]).is_empty() or String(value[&"display_name"]).length() > 256: + return _error(path + ".display_name", "INVALID_TEXT", "Event display name must be a bounded non-empty string.") + event_ids[event_id] = true + values.append({ + &"event_id": event_id, + &"relationship_id": relationship_id, + &"priority": int(value[&"priority"]), + &"min_contact_minutes": int(value[&"min_contact_minutes"]), + &"max_shield_current": int(value[&"max_shield_current"]), + &"min_rival_bond": int(value[&"min_rival_bond"]), + &"max_rival_bond": int(value[&"max_rival_bond"]), + &"required_facility_id": String(value[&"required_facility_id"]), + &"shield_damage": int(value[&"shield_damage"]), + &"rival_bond_delta": int(value[&"rival_bond_delta"]), + &"cost_gold": int(value[&"cost_gold"]), + &"cooldown_minutes": int(value[&"cooldown_minutes"]), + &"route_lock": String(value[&"route_lock"]), + &"route_id": String(value[&"route_id"]), + &"content_rating": String(value[&"content_rating"]), + &"content_tags": tags_result[&"values"], + &"filter_behavior": String(value[&"filter_behavior"]), + &"replacement_event_id": String(value[&"replacement_event_id"]), + &"display_name": String(value[&"display_name"]), + }) + values.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + if int(left[&"priority"]) != int(right[&"priority"]): + return int(left[&"priority"]) > int(right[&"priority"]) + return String(left[&"event_id"]) < String(right[&"event_id"]) + ) + return {&"ok": true, &"values": values} + + +func _validate_replacements(events: Array[Dictionary], content_filter: Dictionary) -> Dictionary: + var event_index: Dictionary = {} + for event: Dictionary in events: + event_index[event[&"event_id"]] = event + for event: Dictionary in events: + var restricted := _event_is_restricted(event, content_filter) + if not restricted: + continue + var behavior := String(event[&"filter_behavior"]) + if behavior == "NONE": + behavior = String(content_filter[&"mode"]) + if behavior != "REPLACE": + continue + var replacement_id := String(event[&"replacement_event_id"]) + if replacement_id.is_empty() or replacement_id == event[&"event_id"] or not event_index.has(replacement_id): + return _error("events." + String(event[&"event_id"]), "REPLACEMENT_MISSING", "Filtered Event replacement is missing or cyclic.") + if _event_is_restricted(event_index[replacement_id], content_filter): + return _error("events." + replacement_id, "REPLACEMENT_UNSAFE", "Replacement Event is also restricted.") + return {&"ok": true} + + +func _build_preview(parsed: Dictionary, include_suggestions: bool) -> Dictionary: + var relationship_previews: Array[Dictionary] = [] + var total_contact := 0 + var total_attacks := 0 + var total_events := 0 + for relationship: Dictionary in parsed[&"relationships"]: + var relationship_preview := _build_relationship_preview(parsed, relationship, include_suggestions) + relationship_previews.append(relationship_preview) + total_contact += int(relationship_preview[&"total_contact_minutes"]) + total_attacks += relationship_preview[&"shield_attack_candidates"].size() + total_events += relationship_preview[&"triggerable_events"].size() + return { + &"ok": true, + &"schema_version": SCHEMA_VERSION, + &"period": parsed[&"period"].duplicate(true), + &"relationships": relationship_previews, + &"totals": { + &"contact_minutes": total_contact, + &"shield_attack_candidates": total_attacks, + &"triggerable_events": total_events, + }, + } + + +func _build_relationship_preview( + parsed: Dictionary, + relationship: Dictionary, + include_suggestions: bool +) -> Dictionary: + var heroine: Dictionary = parsed[&"actor_index"][relationship[&"heroine_id"]] + var rival: Dictionary = parsed[&"actor_index"][relationship[&"rival_id"]] + var contact := _contact_summary( + heroine[&"effective_shifts"], + rival[&"effective_shifts"], + parsed[&"period"] + ) + var attacks_result := _shield_attack_candidates( + relationship, + rival[&"mechanical_effects"], + contact[&"contacts_by_facility"] + ) + var projected_shield := int(attacks_result[&"projected_shield"]) + var projected_bond := int(attacks_result[&"projected_rival_bond"]) + var triggerable := _triggerable_events( + parsed[&"events"], + parsed[&"content_filter"], + relationship, + contact, + projected_shield, + projected_bond + ) + var route_warning := _route_lock_warning(relationship, triggerable) + var risk_level := _risk_level( + int(contact[&"total_contact_minutes"]), + attacks_result[&"candidates"].size(), + triggerable.size(), + projected_shield, + projected_bond, + bool(route_warning[&"confirmation_required"]) + ) + var suggestions: Array[Dictionary] = [] + if include_suggestions and int(contact[&"total_contact_minutes"]) > 0: + suggestions = _avoidance_suggestions( + parsed[&"period"], + relationship, + heroine[&"effective_shifts"], + rival[&"effective_shifts"], + contact + ) + var total_event_cost := 0 + var max_cooldown := 0 + for candidate: Dictionary in triggerable: + total_event_cost += int(candidate[&"mechanics"][&"cost_gold"]) + max_cooldown = maxi(max_cooldown, int(candidate[&"mechanics"][&"cooldown_minutes"])) + return { + &"relationship_id": relationship[&"relationship_id"], + &"heroine_id": relationship[&"heroine_id"], + &"rival_id": relationship[&"rival_id"], + &"contacts_by_facility": contact[&"contacts_by_facility"], + &"total_contact_minutes": contact[&"total_contact_minutes"], + &"shield_attack_candidates": attacks_result[&"candidates"], + &"triggerable_events": triggerable, + &"current_values": { + &"affection": relationship[&"affection"], + &"rival_bond": relationship[&"rival_bond"], + &"shield_current": relationship[&"shield_current"], + &"shield_max": relationship[&"shield_max"], + }, + &"projected_values": { + &"rival_bond": projected_bond, + &"shield_current": projected_shield, + }, + &"risk_level": risk_level, + &"route_lock_warning": route_warning, + &"mechanical_summary": { + &"risk_level": risk_level, + &"contact_minutes": contact[&"total_contact_minutes"], + &"attack_candidate_count": attacks_result[&"candidates"].size(), + &"event_candidate_count": triggerable.size(), + &"event_cost_gold": total_event_cost, + &"maximum_cooldown_minutes": max_cooldown, + &"projected_shield": projected_shield, + &"projected_rival_bond": projected_bond, + &"projected_route_lock": route_warning[&"projected_route_lock"], + &"route_confirmation_required": route_warning[&"confirmation_required"], + }, + &"avoidance_suggestions": suggestions, + } + + +func _contact_summary( + heroine_shifts: Array, + rival_shifts: Array, + period: Dictionary +) -> Dictionary: + var raw_overlaps: Array[Dictionary] = [] + for heroine_shift: Dictionary in heroine_shifts: + for rival_shift: Dictionary in rival_shifts: + if heroine_shift[&"facility_id"] != rival_shift[&"facility_id"]: + continue + var start_at := maxi( + maxi(int(period[&"start_at"]), int(heroine_shift[&"start_at"])), + int(rival_shift[&"start_at"]) + ) + var end_at := mini( + mini(int(period[&"end_at"]), int(heroine_shift[&"end_at"])), + int(rival_shift[&"end_at"]) + ) + # Half-open intervals touch only when start < end. Adjacent shifts + # therefore contribute zero phantom minutes. + if start_at >= end_at: + continue + if raw_overlaps.size() >= MAX_CONTACT_INTERVALS: + break + raw_overlaps.append({ + &"facility_id": heroine_shift[&"facility_id"], + &"start_at": start_at, + &"end_at": end_at, + &"heroine_shift_id": heroine_shift[&"shift_id"], + &"rival_shift_id": rival_shift[&"shift_id"], + }) + raw_overlaps.sort_custom(_overlap_less) + var facility_ids: Array[String] = [] + var by_facility: Dictionary = {} + for overlap: Dictionary in raw_overlaps: + var facility_id := String(overlap[&"facility_id"]) + if not by_facility.has(facility_id): + by_facility[facility_id] = [] + facility_ids.append(facility_id) + by_facility[facility_id].append(overlap) + facility_ids.sort() + var contacts: Array[Dictionary] = [] + var total_contact := 0 + for facility_id: String in facility_ids: + var merged := _merge_contact_intervals(by_facility[facility_id]) + var facility_minutes := 0 + for interval: Dictionary in merged: + facility_minutes += int(interval[&"minutes"]) + total_contact += facility_minutes + contacts.append({ + &"facility_id": facility_id, + &"intervals": merged, + &"contact_minutes": facility_minutes, + }) + return { + &"contacts_by_facility": contacts, + &"total_contact_minutes": total_contact, + &"raw_overlaps": raw_overlaps, + } + + +func _merge_contact_intervals(raw_intervals: Array) -> Array[Dictionary]: + var merged: Array[Dictionary] = [] + for raw: Dictionary in raw_intervals: + if merged.is_empty() or int(raw[&"start_at"]) > int(merged[-1][&"end_at"]): + merged.append({ + &"start_at": int(raw[&"start_at"]), + &"end_at": int(raw[&"end_at"]), + &"minutes": int(raw[&"end_at"]) - int(raw[&"start_at"]), + &"heroine_shift_ids": [String(raw[&"heroine_shift_id"])], + &"rival_shift_ids": [String(raw[&"rival_shift_id"])], + }) + continue + var current: Dictionary = merged[-1] + current[&"end_at"] = maxi(int(current[&"end_at"]), int(raw[&"end_at"])) + current[&"minutes"] = int(current[&"end_at"]) - int(current[&"start_at"]) + _append_unique_string(current[&"heroine_shift_ids"], String(raw[&"heroine_shift_id"])) + _append_unique_string(current[&"rival_shift_ids"], String(raw[&"rival_shift_id"])) + current[&"heroine_shift_ids"].sort() + current[&"rival_shift_ids"].sort() + return merged + + +func _shield_attack_candidates( + relationship: Dictionary, + effects: Dictionary, + contacts: Array +) -> Dictionary: + var attack_points: Array[Dictionary] = [] + for facility: Dictionary in contacts: + for interval: Dictionary in facility[&"intervals"]: + if int(interval[&"minutes"]) < MIN_SHIELD_CONTACT_MINUTES: + continue + var trigger_at := int(interval[&"start_at"]) + MIN_SHIELD_CONTACT_MINUTES - 1 + while trigger_at < int(interval[&"end_at"]): + attack_points.append({ + &"facility_id": facility[&"facility_id"], + &"contact_interval": { + &"start_at": interval[&"start_at"], + &"end_at": interval[&"end_at"], + }, + &"trigger_at": trigger_at, + &"overlap_minutes": interval[&"minutes"], + }) + trigger_at += SHIELD_ATTACK_INTERVAL_MINUTES + attack_points.sort_custom(_attack_point_less) + + var candidates: Array[Dictionary] = [] + var projected_shield := int(relationship[&"shield_current"]) + var projected_bond := int(relationship[&"rival_bond"]) + for index: int in range(attack_points.size()): + var point: Dictionary = attack_points[index] + var night := _is_night_minute(int(point[&"trigger_at"])) + var raw_damage := ( + int(effects[&"base_shield_attack"]) + + (int(effects[&"night_shield_attack_bonus"]) if night else 0) + - int(relationship[&"target_resistance"]) + - int(relationship[&"monitoring_bonus"]) + ) + var damage := clampi(maxi(raw_damage, 1), 1, MAX_ATTACK_DAMAGE) + var shield_before := projected_shield + var bond_before := projected_bond + var bond_delta := 0 + if projected_shield > 0: + projected_shield = maxi(projected_shield - damage, 0) + else: + bond_delta = mini(3, 100 - projected_bond) + projected_bond += bond_delta + candidates.append({ + &"candidate_id": "shield_attack:%s:%04d" % [relationship[&"relationship_id"], index + 1], + &"facility_id": point[&"facility_id"], + &"contact_interval": point[&"contact_interval"], + &"trigger_at": point[&"trigger_at"], + &"night_bonus_applied": night, + &"mechanics": { + &"raw_damage": raw_damage, + &"damage": damage if shield_before > 0 else 0, + &"shield_before": shield_before, + &"shield_after": projected_shield, + &"rival_bond_before": bond_before, + &"rival_bond_delta": bond_delta, + &"rival_bond_after": projected_bond, + &"cost_gold": 0, + &"cooldown_minutes": SHIELD_ATTACK_INTERVAL_MINUTES, + }, + &"reasons": [ + { + &"code": "SHARED_FACILITY_CONTACT", + &"facility_id": point[&"facility_id"], + &"overlap_minutes": point[&"overlap_minutes"], + }, + ], + }) + return { + &"candidates": candidates, + &"projected_shield": projected_shield, + &"projected_rival_bond": projected_bond, + } + + +func _triggerable_events( + events: Array, + content_filter: Dictionary, + relationship: Dictionary, + contact: Dictionary, + projected_shield: int, + projected_bond: int +) -> Array[Dictionary]: + var event_index: Dictionary = {} + for event: Dictionary in events: + event_index[event[&"event_id"]] = event + var result: Array[Dictionary] = [] + for event: Dictionary in events: + if event[&"relationship_id"] not in ["*", relationship[&"relationship_id"]]: + continue + if int(contact[&"total_contact_minutes"]) < int(event[&"min_contact_minutes"]): + continue + if projected_shield > int(event[&"max_shield_current"]): + continue + if projected_bond < int(event[&"min_rival_bond"]) or projected_bond > int(event[&"max_rival_bond"]): + continue + var facility_id := String(event[&"required_facility_id"]) + var facility_contact := _facility_contact_minutes(contact[&"contacts_by_facility"], facility_id) + if not facility_id.is_empty() and facility_contact <= 0: + continue + var requested_lock := String(event[&"route_lock"]) + var preview_lock := "EXCLUSIVE_PENDING" if requested_lock == "EXCLUSIVE_LOCKED" else requested_lock + result.append({ + &"event_id": event[&"event_id"], + &"priority": event[&"priority"], + &"mechanics": { + &"shield_damage": event[&"shield_damage"], + &"rival_bond_delta": event[&"rival_bond_delta"], + &"cost_gold": event[&"cost_gold"], + &"cooldown_minutes": event[&"cooldown_minutes"], + &"route_lock_requested": requested_lock, + &"route_lock_preview": preview_lock, + &"route_id": event[&"route_id"], + &"route_confirmation_required": requested_lock in ["EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED"], + &"route_lock_confirmed": false, + }, + &"reasons": [ + { + &"code": "CONTACT_THRESHOLD_MET", + &"actual_minutes": contact[&"total_contact_minutes"], + &"required_minutes": event[&"min_contact_minutes"], + }, + { + &"code": "SHIELD_THRESHOLD_MET", + &"projected_shield": projected_shield, + &"maximum_shield": event[&"max_shield_current"], + }, + { + &"code": "RIVAL_BOND_RANGE_MET", + &"projected_rival_bond": projected_bond, + &"minimum": event[&"min_rival_bond"], + &"maximum": event[&"max_rival_bond"], + }, + { + &"code": "SHARED_FACILITY", + &"facility_id": facility_id, + &"overlap_minutes": ( + facility_contact if not facility_id.is_empty() else contact[&"total_contact_minutes"] + ), + }, + ], + &"presentation": _event_presentation(event, content_filter, event_index), + }) + return result + + +func _event_presentation( + event: Dictionary, + content_filter: Dictionary, + event_index: Dictionary +) -> Dictionary: + if not _event_is_restricted(event, content_filter) or content_filter[&"mode"] == "NONE": + return { + &"mode": "FULL", + &"visible": true, + &"display_event_id": event[&"event_id"], + &"display_name": event[&"display_name"], + } + var behavior := String(event[&"filter_behavior"]) + if behavior == "NONE": + behavior = String(content_filter[&"mode"]) + match behavior: + "FADE": + return { + &"mode": "FADE", + &"visible": true, + &"display_event_id": event[&"event_id"], + &"display_name": "Filtered relationship scene", + } + "REPLACE": + var replacement: Dictionary = event_index[event[&"replacement_event_id"]] + return { + &"mode": "REPLACE", + &"visible": true, + &"display_event_id": replacement[&"event_id"], + &"display_name": replacement[&"display_name"], + } + "DISABLE": + return { + &"mode": "DISABLE", + &"visible": false, + &"display_event_id": "", + &"display_name": "", + } + return { + &"mode": "FULL", + &"visible": true, + &"display_event_id": event[&"event_id"], + &"display_name": event[&"display_name"], + } + + +func _route_lock_warning(relationship: Dictionary, events: Array[Dictionary]) -> Dictionary: + var candidate_ids: Array[String] = [] + var route_id := String(relationship[&"route_id"]) + var confirmation_required: bool = relationship[&"route_lock"] == "EXCLUSIVE_PENDING" + var projected_lock := String(relationship[&"route_lock"]) + for event: Dictionary in events: + var event_lock := String(event[&"mechanics"][&"route_lock_preview"]) + if _route_lock_rank(event_lock) > _route_lock_rank(projected_lock): + projected_lock = event_lock + if not String(event[&"mechanics"][&"route_id"]).is_empty(): + route_id = String(event[&"mechanics"][&"route_id"]) + if not bool(event[&"mechanics"][&"route_confirmation_required"]): + continue + candidate_ids.append(String(event[&"event_id"])) + confirmation_required = true + if route_id.is_empty(): + route_id = String(event[&"mechanics"][&"route_id"]) + candidate_ids.sort() + if confirmation_required and projected_lock not in ["EXCLUSIVE_LOCKED", "CLOSED"]: + projected_lock = "EXCLUSIVE_PENDING" + return { + &"confirmation_required": confirmation_required, + &"preview_confirms_lock": false, + &"current_route_lock": relationship[&"route_lock"], + &"projected_route_lock": projected_lock, + &"route_id": route_id, + &"candidate_event_ids": candidate_ids, + &"message": ( + "Explicit confirmation Command is required before an exclusive Route Lock." + if confirmation_required + else "No new exclusive Route Lock is projected." + ), + } + + +func _risk_level( + contact_minutes: int, + attack_count: int, + event_count: int, + projected_shield: int, + projected_bond: int, + route_confirmation: bool +) -> String: + if route_confirmation: + return "CRITICAL" + var has_placement_risk := contact_minutes > 0 or attack_count > 0 or event_count > 0 + if not has_placement_risk: + return "NONE" + if projected_bond >= 80: + return "CRITICAL" + if projected_shield == 0 or contact_minutes >= 180 or event_count >= 2: + return "HIGH" + if attack_count > 0 or event_count > 0 or contact_minutes >= 30: + return "MEDIUM" + if contact_minutes > 0: + return "LOW" + return "NONE" + + +func _avoidance_suggestions( + period: Dictionary, + relationship: Dictionary, + heroine_shifts: Array, + rival_shifts: Array, + contact: Dictionary +) -> Array[Dictionary]: + var raw: Array = contact[&"raw_overlaps"] + if raw.is_empty(): + return [] + var first: Dictionary = raw[0] + var rival_shift := _find_shift(rival_shifts, String(first[&"rival_shift_id"])) + if rival_shift.is_empty(): + return [] + var result: Array[Dictionary] = [] + var separated_facility := _unused_preview_facility(heroine_shifts, rival_shifts) + var facility_proposal := rival_shift.duplicate(true) + facility_proposal[&"facility_id"] = separated_facility + var facility_schedule := _replace_shift(rival_shifts, facility_proposal) + var facility_after := _contact_summary(heroine_shifts, facility_schedule, period) + if int(facility_after[&"total_contact_minutes"]) < int(contact[&"total_contact_minutes"]): + result.append({ + &"suggestion_id": "avoid:%s:facility:0001" % relationship[&"relationship_id"], + &"kind": "CHANGE_FACILITY", + &"actor_id": relationship[&"rival_id"], + &"reason": "Place the rival in a different facility during the conflicting Shift.", + &"proposed_shift": facility_proposal, + &"contact_minutes_before": contact[&"total_contact_minutes"], + &"contact_minutes_after": facility_after[&"total_contact_minutes"], + &"reduction_minutes": int(contact[&"total_contact_minutes"]) - int(facility_after[&"total_contact_minutes"]), + }) + + var time_proposal := _time_separated_shift(rival_shift, rival_shifts, heroine_shifts, period) + if not time_proposal.is_empty(): + var time_schedule := _replace_shift(rival_shifts, time_proposal) + var time_after := _contact_summary(heroine_shifts, time_schedule, period) + if int(time_after[&"total_contact_minutes"]) < int(contact[&"total_contact_minutes"]): + result.append({ + &"suggestion_id": "avoid:%s:time:0001" % relationship[&"relationship_id"], + &"kind": "SEPARATE_TIME", + &"actor_id": relationship[&"rival_id"], + &"reason": "Move the rival Shift to the earliest non-contact time window.", + &"proposed_shift": time_proposal, + &"contact_minutes_before": contact[&"total_contact_minutes"], + &"contact_minutes_after": time_after[&"total_contact_minutes"], + &"reduction_minutes": int(contact[&"total_contact_minutes"]) - int(time_after[&"total_contact_minutes"]), + }) + return result + + +func _time_separated_shift( + target: Dictionary, + rival_shifts: Array, + heroine_shifts: Array, + period: Dictionary +) -> Dictionary: + var duration := int(target[&"end_at"]) - int(target[&"start_at"]) + var blocked: Array[Dictionary] = [] + for shift: Dictionary in heroine_shifts: + if shift[&"facility_id"] != target[&"facility_id"]: + continue + var start_at := maxi(int(period[&"start_at"]), int(shift[&"start_at"])) + var end_at := mini(int(period[&"end_at"]), int(shift[&"end_at"])) + if start_at < end_at: + blocked.append({&"start_at": start_at, &"end_at": end_at}) + blocked.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + return int(left[&"start_at"]) < int(right[&"start_at"]) + ) + var candidate_starts: Array[int] = [int(period[&"start_at"])] + for interval: Dictionary in blocked: + candidate_starts.append(int(interval[&"end_at"])) + for shift: Dictionary in rival_shifts: + if shift[&"shift_id"] != target[&"shift_id"]: + candidate_starts.append(int(shift[&"end_at"])) + candidate_starts.sort() + var unique_starts: Array[int] = [] + for candidate_start: int in candidate_starts: + if not unique_starts.has(candidate_start): + unique_starts.append(candidate_start) + for candidate_start: int in unique_starts: + var candidate_end := candidate_start + duration + if candidate_end > int(period[&"end_at"]): + continue + var proposal := target.duplicate(true) + proposal[&"start_at"] = candidate_start + proposal[&"end_at"] = candidate_end + var schedule := _replace_shift(rival_shifts, proposal) + if not bool(_validate_single_placement(schedule, "avoidance.time")[&"ok"]): + continue + if int(_contact_summary(heroine_shifts, [proposal], period)[&"total_contact_minutes"]) == 0: + return proposal + return {} + + +func _unused_preview_facility(heroine_shifts: Array, rival_shifts: Array) -> String: + var used: Dictionary = {} + for shift: Dictionary in heroine_shifts: + used[String(shift[&"facility_id"])] = true + for shift: Dictionary in rival_shifts: + used[String(shift[&"facility_id"])] = true + for index: int in range(1, MAX_SHIFTS_PER_ACTOR * 2 + 2): + var candidate := "preview_separate_%04d" % index + if not used.has(candidate): + return candidate + # The bounded actor schedules make this fallback unreachable, but it keeps + # the helper total if limits change without updating this loop. + return "preview_separate_fallback" + + +func _effective_shifts(existing: Array[Dictionary], proposed: Array[Dictionary]) -> Array[Dictionary]: + var replacements: Dictionary = {} + for shift: Dictionary in proposed: + replacements[shift[&"shift_id"]] = true + var result: Array[Dictionary] = [] + for shift: Dictionary in existing: + if not replacements.has(shift[&"shift_id"]): + result.append(shift.duplicate(true)) + for shift: Dictionary in proposed: + result.append(shift.duplicate(true)) + _sort_shifts(result) + return result + + +func _replace_shift(shifts: Array, replacement: Dictionary) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for shift: Dictionary in shifts: + if shift[&"shift_id"] != replacement[&"shift_id"]: + result.append(shift.duplicate(true)) + result.append(replacement.duplicate(true)) + _sort_shifts(result) + return result + + +func _validate_single_placement(shifts: Array, path: String) -> Dictionary: + var previous_end := -1 + for shift: Dictionary in shifts: + if int(shift[&"start_at"]) < previous_end: + return _error(path, "ACTOR_DOUBLE_BOOKED", "One actor cannot occupy overlapping Shift intervals.") + previous_end = int(shift[&"end_at"]) + return {&"ok": true} + + +func _find_shift(shifts: Array, shift_id: String) -> Dictionary: + for shift: Dictionary in shifts: + if shift[&"shift_id"] == shift_id: + return shift.duplicate(true) + return {} + + +func _facility_contact_minutes(contacts: Array, facility_id: String) -> int: + if facility_id.is_empty(): + return 0 + for contact: Dictionary in contacts: + if contact[&"facility_id"] == facility_id: + return int(contact[&"contact_minutes"]) + return 0 + + +func _event_is_restricted(event: Dictionary, content_filter: Dictionary) -> bool: + if CONTENT_RATINGS.find(String(event[&"content_rating"])) > CONTENT_RATINGS.find(String(content_filter[&"maximum_rating"])): + return true + for tag: String in event[&"content_tags"]: + if content_filter[&"disabled_tags"].has(tag): + return true + return false + + +static func _is_night_minute(absolute_minute: int) -> bool: + var minute_of_day := posmod(absolute_minute, MINUTES_PER_DAY) + return minute_of_day >= 1_080 or minute_of_day < 360 + + +static func _route_lock_rank(value: String) -> int: + return ROUTE_LOCKS.find(value) + + +static func _valid_id(value: Variant) -> bool: + return value is String and not String(value).is_empty() and String(value).length() <= MAX_ID_LENGTH + + +static func _strict_string_array(raw: Variant, maximum: int, path: String) -> Dictionary: + if not raw is Array or raw.size() > maximum: + return _error(path, "COLLECTION_LIMIT", "String collection exceeds its bound.") + var result: Array[String] = [] + for value: Variant in raw: + if not value is String or String(value).is_empty() or String(value).length() > MAX_ID_LENGTH: + return _error(path, "INVALID_TYPE", "Collection entries must be bounded non-empty strings.") + if result.has(String(value)): + return _error(path, "DUPLICATE_VALUE", "String collection values must be unique.") + result.append(String(value)) + result.sort() + return {&"ok": true, &"values": result} + + +static func _has_exact_keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + for key: Variant in expected: + if not source.has(key): + return false + return true + + +static func _sort_shifts(shifts: Array[Dictionary]) -> void: + shifts.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + if int(left[&"start_at"]) != int(right[&"start_at"]): + return int(left[&"start_at"]) < int(right[&"start_at"]) + if int(left[&"end_at"]) != int(right[&"end_at"]): + return int(left[&"end_at"]) < int(right[&"end_at"]) + return String(left[&"shift_id"]) < String(right[&"shift_id"]) + ) + + +static func _overlap_less(left: Dictionary, right: Dictionary) -> bool: + if left[&"facility_id"] != right[&"facility_id"]: + return String(left[&"facility_id"]) < String(right[&"facility_id"]) + if int(left[&"start_at"]) != int(right[&"start_at"]): + return int(left[&"start_at"]) < int(right[&"start_at"]) + if int(left[&"end_at"]) != int(right[&"end_at"]): + return int(left[&"end_at"]) < int(right[&"end_at"]) + if left[&"heroine_shift_id"] != right[&"heroine_shift_id"]: + return String(left[&"heroine_shift_id"]) < String(right[&"heroine_shift_id"]) + return String(left[&"rival_shift_id"]) < String(right[&"rival_shift_id"]) + + +static func _attack_point_less(left: Dictionary, right: Dictionary) -> bool: + if int(left[&"trigger_at"]) != int(right[&"trigger_at"]): + return int(left[&"trigger_at"]) < int(right[&"trigger_at"]) + if left[&"facility_id"] != right[&"facility_id"]: + return String(left[&"facility_id"]) < String(right[&"facility_id"]) + if int(left[&"contact_interval"][&"start_at"]) != int(right[&"contact_interval"][&"start_at"]): + return int(left[&"contact_interval"][&"start_at"]) < int(right[&"contact_interval"][&"start_at"]) + return int(left[&"contact_interval"][&"end_at"]) < int(right[&"contact_interval"][&"end_at"]) + + +static func _append_unique_string(values: Array, value: String) -> void: + if not values.has(value): + values.append(value) + + +static func _error(path: String, code: String, message: String) -> Dictionary: + return { + &"ok": false, + &"error_code": code, + &"error_path": path, + &"message": message, + } diff --git a/scripts/relationship/placement_risk_preview_service.gd.uid b/scripts/relationship/placement_risk_preview_service.gd.uid new file mode 100644 index 0000000..2f4b9d4 --- /dev/null +++ b/scripts/relationship/placement_risk_preview_service.gd.uid @@ -0,0 +1 @@ +uid://bgo08wt4s4iwj diff --git a/scripts/relationship/relationship_service.gd b/scripts/relationship/relationship_service.gd new file mode 100644 index 0000000..1d1e093 --- /dev/null +++ b/scripts/relationship/relationship_service.gd @@ -0,0 +1,515 @@ +class_name RelationshipService +extends RefCounted + +signal relationship_registered(state: RelationshipState) +signal relationship_value_changed(relationship_id: StringName, metric_id: StringName, old_value: int, new_value: int) +signal threshold_candidate_registered(candidate: Dictionary) +signal shield_changed(relationship_id: StringName, old_value: int, new_value: int) +signal route_lock_changed(relationship_id: StringName, old_lock: int, new_lock: int, route_id: StringName) + +const RelationshipStateType = preload("res://scripts/relationship/relationship_state.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_VERSION := 1 +const DAILY_AUTOMATIC_DELTA_LIMIT := 10 +const GATED_THRESHOLDS: Array[int] = [50, 80] +const STAGE_THRESHOLDS: Array[int] = [20, 50, 80, 100] + +var last_error := "" + +var _relationships: Dictionary = {} +var _pair_index: Dictionary = {} +var _threshold_candidates: Array[Dictionary] = [] +var _next_candidate_sequence := 1 +var _candidate_callback := Callable() + + +func configure_candidate_callback(callback: Callable = Callable()) -> RelationshipService: + _candidate_callback = callback + return self + + +func register_relationship(value: Variant) -> CommandResult: + if value == null or not value is RelationshipState: + return _invalid("RelationshipState is missing or invalid.") + var state: RelationshipState = value + if not state.is_valid(): + return _invalid("RelationshipState fails invariant validation.") + if _relationships.has(state.relationship_id): + return _duplicate("Relationship", state.relationship_id) + var key := state.pair_key() + if _pair_index.has(key): + return _invalid("The normalized actor pair already has a Relationship.", { + &"existing_relationship_id": _pair_index[key], + }) + _relationships[state.relationship_id] = state + _pair_index[key] = state.relationship_id + relationship_registered.emit(state) + last_error = "" + return _success({&"relationship_id": state.relationship_id}) + + +func create_relationship( + relationship_id: StringName, + actor_one_id: StringName, + actor_two_id: StringName, + options: Dictionary = {} +) -> CommandResult: + return register_relationship( + RelationshipStateType.new().configure( + relationship_id, + actor_one_id, + actor_two_id, + options + ) + ) + + +func has_relationship(relationship_id: StringName) -> bool: + return _relationships.has(relationship_id) + + +func get_relationship(relationship_id: StringName) -> RelationshipState: + return _relationships.get(relationship_id) as RelationshipState + + +func find_relationship(actor_one_id: StringName, actor_two_id: StringName) -> RelationshipState: + var ids: Array[String] = [String(actor_one_id), String(actor_two_id)] + ids.sort() + var relationship_id: StringName = _pair_index.get("%s|%s" % [ids[0], ids[1]], &"") + return get_relationship(relationship_id) + + +func get_relationships() -> Array[RelationshipState]: + var result: Array[RelationshipState] = [] + for relationship_id: StringName in _sorted_name_keys(_relationships): + result.append(_relationships[relationship_id]) + return result + + +func apply_value_change( + relationship_id: StringName, + metric_id: StringName, + delta: int, + absolute_minute: int, + options: Dictionary = {} +) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + if not RelationshipStateType.METRIC_IDS.has(metric_id): + return _invalid("Unknown relationship metric: %s" % metric_id) + if absolute_minute < 0: + return _invalid("Relationship changes require a non-negative minute.") + var important := bool(_value(options, &"important", false)) + var allow_threshold_crossing := bool( + _value(options, &"allow_threshold_crossing", false) + ) + _reset_metric_day_if_needed(state, absolute_minute / 1_440) + var requested_delta := delta + var applied_delta := delta + if not important: + var used := int(state.daily_metric_deltas.get(metric_id, 0)) + applied_delta = clampi(delta, -DAILY_AUTOMATIC_DELTA_LIMIT - used, DAILY_AUTOMATIC_DELTA_LIMIT - used) + var old_value := state.get_metric(metric_id) + var desired_value := clampi(old_value + applied_delta, 0, 100) + var gated_threshold := 0 + if desired_value > old_value and not allow_threshold_crossing: + for threshold: int in GATED_THRESHOLDS: + if old_value < threshold and desired_value >= threshold: + desired_value = threshold - 1 + gated_threshold = threshold + break + var actual_delta := desired_value - old_value + state.metrics[metric_id] = desired_value + if not important: + state.daily_metric_deltas[metric_id] = int( + state.daily_metric_deltas.get(metric_id, 0) + ) + actual_delta + var crossed: Array[int] = [] + if desired_value > old_value: + for threshold: int in STAGE_THRESHOLDS: + if old_value < threshold and desired_value >= threshold: + crossed.append(threshold) + _record_crossed_threshold(state, metric_id, threshold) + _register_threshold_candidate(state, metric_id, threshold, absolute_minute, false) + if gated_threshold > 0: + _register_threshold_candidate( + state, + metric_id, + gated_threshold, + absolute_minute, + true + ) + if old_value != desired_value: + relationship_value_changed.emit( + relationship_id, + metric_id, + old_value, + desired_value + ) + last_error = "" + return _success({ + &"relationship_id": relationship_id, + &"metric_id": metric_id, + &"old_value": old_value, + &"new_value": desired_value, + &"requested_delta": requested_delta, + &"applied_delta": actual_delta, + &"gated_threshold": gated_threshold, + &"crossed_thresholds": crossed, + }) + + +func set_flag(relationship_id: StringName, flag_id: StringName, enabled: bool) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + if flag_id.is_empty(): + return _invalid("Relationship flag ID cannot be empty.") + state.flags[flag_id] = enabled + last_error = "" + return _success({&"relationship_id": relationship_id, &"flag_id": flag_id}) + + +func set_shield_max( + relationship_id: StringName, + new_maximum: int, + preserve_ratio: bool = false +) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + var old_maximum := state.shield_max + var old_current := state.shield_current + state.shield_max = clampi(new_maximum, 0, 100) + if preserve_ratio and old_maximum > 0: + state.shield_current = clampi( + (old_current * state.shield_max) / old_maximum, + 0, + state.shield_max + ) + else: + state.shield_current = mini(old_current, state.shield_max) + if old_current != state.shield_current: + shield_changed.emit(relationship_id, old_current, state.shield_current) + last_error = "" + return _success({ + &"old_maximum": old_maximum, + &"new_maximum": state.shield_max, + &"old_current": old_current, + &"new_current": state.shield_current, + }) + + +func restore_shield(relationship_id: StringName, amount: int) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + if amount < 0: + return _invalid("Shield restoration amount cannot be negative.") + var old_value := state.shield_current + state.shield_current = clampi(old_value + amount, 0, state.shield_max) + if old_value != state.shield_current: + shield_changed.emit(relationship_id, old_value, state.shield_current) + last_error = "" + return _success({ + &"old_value": old_value, + &"new_value": state.shield_current, + &"restored": state.shield_current - old_value, + }) + + +func apply_shield_attack( + relationship_id: StringName, + action_id: StringName, + absolute_minute: int, + terms: Dictionary +) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + if action_id.is_empty() or absolute_minute < 0: + return _invalid("ShieldAttack requires an Action ID and non-negative minute.") + var day := absolute_minute / 1_440 + _reset_attack_day_if_needed(state, day) + var uses_before := int(state.action_counts.get(action_id, 0)) + var daily_limit := maxi(int(_value(terms, &"daily_limit", 4)), 1) + if uses_before >= daily_limit: + return _invalid("ShieldAction daily use limit reached.", { + &"action_id": action_id, + &"uses": uses_before, + &"daily_limit": daily_limit, + }) + var penalty_percent := _repetition_penalty_percent(uses_before) + var raw_damage := ( + int(_value(terms, &"base_damage", 0)) + + int(_value(terms, &"owner_stat_bonus", 0)) + + int(_value(terms, &"intel_bonus", 0)) + + int(_value(terms, &"event_bonus", 0)) + - int(_value(terms, &"target_resistance", 0)) + ) + var max_damage := maxi(int(_value(terms, &"max_damage", raw_damage)), 0) + var damage := clampi((maxi(raw_damage, 0) * (100 - penalty_percent)) / 100, 0, max_damage) + var old_value := state.shield_current + state.shield_current = maxi(old_value - damage, 0) + state.action_counts[action_id] = uses_before + 1 + state.daily_attack_count += 1 + if old_value != state.shield_current: + shield_changed.emit(relationship_id, old_value, state.shield_current) + last_error = "" + return _success({ + &"relationship_id": relationship_id, + &"action_id": action_id, + &"uses_before": uses_before, + &"penalty_percent": penalty_percent, + &"raw_damage": raw_damage, + &"damage": damage, + &"old_shield": old_value, + &"new_shield": state.shield_current, + &"shield_broken": old_value > 0 and state.shield_current == 0, + }) + + +func request_route_lock( + relationship_id: StringName, + requested_lock: RelationshipState.RouteLock, + route_id: StringName = &"", + irreversible_event: bool = false +) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + if requested_lock < RelationshipStateType.RouteLock.NONE or requested_lock > RelationshipStateType.RouteLock.CLOSED: + return _invalid("Unknown Route Lock state.") + if requested_lock in [ + RelationshipStateType.RouteLock.EXCLUSIVE_PENDING, + RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED, + ] and route_id.is_empty(): + return _invalid("Exclusive Route Lock requires a route ID.") + if requested_lock == RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED and not irreversible_event: + return _invalid("Exclusive Route cannot lock before explicit confirmation.", { + &"required_state": &"EXCLUSIVE_PENDING", + }) + var old_lock := state.route_lock + state.route_lock = requested_lock + state.route_id = route_id if requested_lock != RelationshipStateType.RouteLock.NONE else &"" + route_lock_changed.emit(relationship_id, old_lock, state.route_lock, state.route_id) + last_error = "" + return _success({&"old_lock": old_lock, &"new_lock": state.route_lock}) + + +func confirm_route_lock(relationship_id: StringName, route_id: StringName) -> CommandResult: + var state := get_relationship(relationship_id) + if state == null: + return _not_found("Relationship", relationship_id) + if state.route_lock != RelationshipStateType.RouteLock.EXCLUSIVE_PENDING: + return _invalid("Route is not awaiting exclusive confirmation.") + if route_id.is_empty() or state.route_id != route_id: + return _invalid("Route confirmation does not match the pending route.") + var old_lock := state.route_lock + state.route_lock = RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED + route_lock_changed.emit(relationship_id, old_lock, state.route_lock, state.route_id) + last_error = "" + return _success({&"route_id": route_id, &"new_lock": state.route_lock}) + + +func get_threshold_candidates() -> Array[Dictionary]: + return _threshold_candidates.duplicate(true) + + +func consume_threshold_candidate(candidate_id: StringName) -> Dictionary: + for index: int in range(_threshold_candidates.size()): + if StringName(_threshold_candidates[index].get(&"candidate_id", &"")) == candidate_id: + var candidate: Dictionary = _threshold_candidates[index].duplicate(true) + _threshold_candidates.remove_at(index) + return candidate + return {} + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + var seen_pairs: Dictionary = {} + for relationship_id: StringName in _sorted_name_keys(_relationships): + var state := get_relationship(relationship_id) + if state == null or not state.is_valid(): + errors.append("Invalid RelationshipState: %s" % relationship_id) + continue + var pair := state.pair_key() + if seen_pairs.has(pair): + errors.append("Duplicate actor pair: %s" % pair) + seen_pairs[pair] = relationship_id + if _pair_index.get(pair, &"") != relationship_id: + errors.append("Pair index mismatch: %s" % pair) + return {&"ok": errors.is_empty(), &"errors": errors} + + +func create_snapshot() -> Dictionary: + var relationships: Array[Dictionary] = [] + for state: RelationshipState in get_relationships(): + relationships.append(state.create_snapshot()) + var candidates := _threshold_candidates.duplicate(true) + candidates.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: + return String(a.get(&"candidate_id", "")) < String(b.get(&"candidate_id", "")) + ) + return { + &"version": SNAPSHOT_VERSION, + &"relationships": relationships, + &"threshold_candidates": candidates, + &"next_candidate_sequence": _next_candidate_sequence, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + var parsed := _parse_snapshot(snapshot) + if not bool(parsed.get(&"ok", false)): + last_error = String(parsed.get(&"error", "Invalid Relationship snapshot.")) + return false + _relationships = parsed[&"relationships"] + _pair_index = parsed[&"pair_index"] + _threshold_candidates = parsed[&"threshold_candidates"] + _next_candidate_sequence = int(parsed[&"next_candidate_sequence"]) + last_error = "" + return true + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION: + return {&"ok": false, &"error": "Unsupported Relationship snapshot version."} + var raw_relationships: Variant = _value(snapshot, &"relationships", null) + var raw_candidates: Variant = _value(snapshot, &"threshold_candidates", null) + if not raw_relationships is Array or not raw_candidates is Array: + return {&"ok": false, &"error": "Relationship snapshot collections are malformed."} + var relationships: Dictionary = {} + var pair_index: Dictionary = {} + for raw_state: Variant in raw_relationships: + if not raw_state is Dictionary: + return {&"ok": false, &"error": "Relationship snapshot entry is malformed."} + var state := RelationshipStateType.from_snapshot(raw_state) + if not state.is_valid() or relationships.has(state.relationship_id): + return {&"ok": false, &"error": "Relationship snapshot contains an invalid or duplicate state."} + if pair_index.has(state.pair_key()): + return {&"ok": false, &"error": "Relationship snapshot duplicates an actor pair."} + relationships[state.relationship_id] = state + pair_index[state.pair_key()] = state.relationship_id + var candidates: Array[Dictionary] = [] + var candidate_ids: Dictionary = {} + for raw_candidate: Variant in raw_candidates: + if not raw_candidate is Dictionary: + return {&"ok": false, &"error": "Threshold candidate is malformed."} + var candidate: Dictionary = raw_candidate.duplicate(true) + var candidate_id := StringName(candidate.get(&"candidate_id", candidate.get("candidate_id", ""))) + var relationship_id := StringName(candidate.get(&"relationship_id", candidate.get("relationship_id", ""))) + if candidate_id.is_empty() or candidate_ids.has(candidate_id) or not relationships.has(relationship_id): + return {&"ok": false, &"error": "Threshold candidate reference is invalid."} + candidate_ids[candidate_id] = true + candidates.append(candidate) + var next_sequence := int(_value(snapshot, &"next_candidate_sequence", 0)) + if next_sequence < 1: + return {&"ok": false, &"error": "Relationship candidate sequence is invalid."} + return { + &"ok": true, + &"relationships": relationships, + &"pair_index": pair_index, + &"threshold_candidates": candidates, + &"next_candidate_sequence": next_sequence, + } + + +func _register_threshold_candidate( + state: RelationshipState, + metric_id: StringName, + threshold: int, + absolute_minute: int, + requires_unlock_event: bool +) -> void: + var deduplication_key := "%s:%s:%d" % [state.relationship_id, metric_id, threshold] + for candidate: Dictionary in _threshold_candidates: + if String(candidate.get(&"deduplication_key", "")) == deduplication_key: + return + var candidate := { + &"candidate_id": StringName("relationship_candidate_%08d" % _next_candidate_sequence), + &"deduplication_key": deduplication_key, + &"relationship_id": state.relationship_id, + &"actor_ids": [state.actor_a_id, state.actor_b_id], + &"metric_id": metric_id, + &"threshold": threshold, + &"requires_unlock_event": requires_unlock_event, + &"created_at": absolute_minute, + } + _next_candidate_sequence += 1 + _threshold_candidates.append(candidate) + threshold_candidate_registered.emit(candidate.duplicate(true)) + if _candidate_callback.is_valid(): + _candidate_callback.call(candidate.duplicate(true)) + + +func _record_crossed_threshold(state: RelationshipState, metric_id: StringName, threshold: int) -> void: + var thresholds: Array = state.crossed_thresholds.get(metric_id, []) + if not thresholds.has(threshold): + thresholds.append(threshold) + thresholds.sort() + state.crossed_thresholds[metric_id] = thresholds + + +func _reset_attack_day_if_needed(state: RelationshipState, day: int) -> void: + if state.attack_day == day: + return + state.attack_day = day + state.daily_attack_count = 0 + state.action_counts.clear() + + +func _reset_metric_day_if_needed(state: RelationshipState, day: int) -> void: + if state.metric_delta_day == day: + return + state.metric_delta_day = day + for metric_id: StringName in RelationshipStateType.METRIC_IDS: + state.daily_metric_deltas[metric_id] = 0 + + +static func _repetition_penalty_percent(previous_uses: int) -> int: + if previous_uses <= 0: + return 0 + if previous_uses == 1: + return 25 + if previous_uses == 2: + return 50 + return 75 + + +static func _sorted_name_keys(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_key: Variant in source: + result.append(StringName(raw_key)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + return source.get(String(key), fallback) + + +func _success(details: Dictionary = {}) -> CommandResult: + return CommandResultType.succeeded(0, details) + + +func _invalid(message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed( + CommandResultType.Code.VALIDATION_FAILED, + 0, + message, + details + ) + + +func _not_found(kind: String, entity_id: StringName) -> CommandResult: + return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id}) + + +func _duplicate(kind: String, entity_id: StringName) -> CommandResult: + return _invalid("Duplicate %s ID: %s" % [kind, entity_id], {&"entity_id": entity_id}) diff --git a/scripts/relationship/relationship_service.gd.uid b/scripts/relationship/relationship_service.gd.uid new file mode 100644 index 0000000..98fa509 --- /dev/null +++ b/scripts/relationship/relationship_service.gd.uid @@ -0,0 +1 @@ +uid://qucsy50p5eef diff --git a/scripts/relationship/relationship_state.gd b/scripts/relationship/relationship_state.gd new file mode 100644 index 0000000..4e9d7cc --- /dev/null +++ b/scripts/relationship/relationship_state.gd @@ -0,0 +1,253 @@ +class_name RelationshipState +extends RefCounted + +## Authoritative, direction-neutral relationship state for one normalized actor pair. +## Directional facts belong in flags or a higher-level actor-specific projection. + +enum RouteLock { + NONE, + SOFT, + EXCLUSIVE_PENDING, + EXCLUSIVE_LOCKED, + CLOSED, +} + +const METRIC_IDS: Array[StringName] = [ + &"trust", + &"respect", + &"affection", + &"fear", + &"dependence", +] + +var relationship_id: StringName = &"" +var actor_a_id: StringName = &"" +var actor_b_id: StringName = &"" +var metrics: Dictionary = {} +var shield_current := 0 +var shield_max := 0 +var route_lock: RouteLock = RouteLock.NONE +var route_id: StringName = &"" +var last_event_at := -1 +var daily_attack_count := 0 +var attack_day := -1 +var action_counts: Dictionary = {} +var daily_metric_deltas: Dictionary = {} +var metric_delta_day := -1 +var flags: Dictionary = {} +var crossed_thresholds: Dictionary = {} + + +func configure( + p_relationship_id: StringName, + p_actor_one_id: StringName, + p_actor_two_id: StringName, + options: Dictionary = {} +) -> RelationshipState: + relationship_id = p_relationship_id + var first := String(p_actor_one_id) + var second := String(p_actor_two_id) + if first <= second: + actor_a_id = p_actor_one_id + actor_b_id = p_actor_two_id + else: + actor_a_id = p_actor_two_id + actor_b_id = p_actor_one_id + metrics = _normalize_metrics(_value(options, &"metrics", options)) + shield_max = clampi(int(_value(options, &"shield_max", 0)), 0, 100) + shield_current = clampi( + int(_value(options, &"shield_current", shield_max)), + 0, + shield_max + ) + route_lock = _route_lock_from_variant(_value(options, &"route_lock", RouteLock.NONE)) + route_id = StringName(_value(options, &"route_id", &"")) + last_event_at = int(_value(options, &"last_event_at", -1)) + daily_attack_count = maxi(int(_value(options, &"daily_attack_count", 0)), 0) + attack_day = int(_value(options, &"attack_day", -1)) + action_counts = _normalize_non_negative_dictionary( + _value(options, &"action_counts", {}) + ) + daily_metric_deltas = _normalize_metric_deltas( + _value(options, &"daily_metric_deltas", {}) + ) + metric_delta_day = int(_value(options, &"metric_delta_day", -1)) + flags = _normalize_flags(_value(options, &"flags", {})) + crossed_thresholds = _normalize_crossed_thresholds( + _value(options, &"crossed_thresholds", {}) + ) + return self + + +func is_valid() -> bool: + if relationship_id.is_empty() or actor_a_id.is_empty() or actor_b_id.is_empty(): + return false + if actor_a_id == actor_b_id or String(actor_a_id) > String(actor_b_id): + return false + if shield_max < 0 or shield_max > 100: + return false + if shield_current < 0 or shield_current > shield_max: + return false + if route_lock < RouteLock.NONE or route_lock > RouteLock.CLOSED: + return false + if route_lock in [RouteLock.EXCLUSIVE_PENDING, RouteLock.EXCLUSIVE_LOCKED] and route_id.is_empty(): + return false + for metric_id: StringName in METRIC_IDS: + if not metrics.has(metric_id): + return false + var metric_value: Variant = metrics[metric_id] + if not metric_value is int or int(metric_value) < 0 or int(metric_value) > 100: + return false + for raw_action_id: Variant in action_counts: + if StringName(raw_action_id).is_empty() or int(action_counts[raw_action_id]) < 0: + return false + return true + + +func get_metric(metric_id: StringName) -> int: + return int(metrics.get(metric_id, 0)) + + +func has_flag(flag_id: StringName) -> bool: + return bool(flags.get(flag_id, false)) + + +func get_stage(metric_id: StringName) -> int: + var value := get_metric(metric_id) + if value >= 100: + return 4 + if value >= 80: + return 3 + if value >= 50: + return 2 + if value >= 20: + return 1 + return 0 + + +func pair_key() -> String: + return "%s|%s" % [actor_a_id, actor_b_id] + + +func create_snapshot() -> Dictionary: + return { + &"relationship_id": String(relationship_id), + &"actor_a_id": String(actor_a_id), + &"actor_b_id": String(actor_b_id), + &"metrics": _string_key_dictionary(metrics), + &"shield_current": shield_current, + &"shield_max": shield_max, + &"route_lock": RouteLock.keys()[route_lock], + &"route_id": String(route_id), + &"last_event_at": last_event_at, + &"daily_attack_count": daily_attack_count, + &"attack_day": attack_day, + &"action_counts": _string_key_dictionary(action_counts), + &"daily_metric_deltas": _string_key_dictionary(daily_metric_deltas), + &"metric_delta_day": metric_delta_day, + &"flags": _string_key_dictionary(flags), + &"crossed_thresholds": _crossed_threshold_snapshot(), + } + + +static func from_snapshot(snapshot: Dictionary) -> RelationshipState: + return RelationshipState.new().configure( + StringName(snapshot.get(&"relationship_id", snapshot.get("relationship_id", ""))), + StringName(snapshot.get(&"actor_a_id", snapshot.get("actor_a_id", ""))), + StringName(snapshot.get(&"actor_b_id", snapshot.get("actor_b_id", ""))), + snapshot + ) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + if source.has(key): + return source[key] + var string_key := String(key) + return source.get(string_key, fallback) + + +static func _normalize_metrics(raw_value: Variant) -> Dictionary: + var source: Dictionary = raw_value if raw_value is Dictionary else {} + var result: Dictionary = {} + for metric_id: StringName in METRIC_IDS: + var fallback := 0 + if metric_id == &"affection": + fallback = int(_value(source, &"affinity", 0)) + elif metric_id == &"dependence": + fallback = int(_value(source, &"obligation", 0)) + result[metric_id] = clampi(int(_value(source, metric_id, fallback)), 0, 100) + return result + + +static func _normalize_non_negative_dictionary(raw_value: Variant) -> Dictionary: + var result: Dictionary = {} + if not raw_value is Dictionary: + return result + for raw_key: Variant in raw_value: + var key := StringName(raw_key) + if not key.is_empty(): + result[key] = maxi(int(raw_value[raw_key]), 0) + return result + + +static func _normalize_metric_deltas(raw_value: Variant) -> Dictionary: + var source: Dictionary = raw_value if raw_value is Dictionary else {} + var result: Dictionary = {} + for metric_id: StringName in METRIC_IDS: + result[metric_id] = int(_value(source, metric_id, 0)) + return result + + +static func _normalize_flags(raw_value: Variant) -> Dictionary: + var result: Dictionary = {} + if not raw_value is Dictionary: + return result + for raw_key: Variant in raw_value: + var key := StringName(raw_key) + if not key.is_empty(): + result[key] = bool(raw_value[raw_key]) + return result + + +static func _normalize_crossed_thresholds(raw_value: Variant) -> Dictionary: + var result: Dictionary = {} + if not raw_value is Dictionary: + return result + for metric_id: StringName in METRIC_IDS: + var raw_thresholds: Variant = _value(raw_value, metric_id, []) + var thresholds: Array[int] = [] + if raw_thresholds is Array: + for raw_threshold: Variant in raw_thresholds: + var threshold := int(raw_threshold) + if threshold in [20, 50, 80, 100] and not thresholds.has(threshold): + thresholds.append(threshold) + thresholds.sort() + result[metric_id] = thresholds + return result + + +func _crossed_threshold_snapshot() -> Dictionary: + var result: Dictionary = {} + for metric_id: StringName in METRIC_IDS: + var values: Array = crossed_thresholds.get(metric_id, []) + result[String(metric_id)] = values.duplicate() + return result + + +static func _route_lock_from_variant(raw_value: Variant) -> RouteLock: + if raw_value is int: + return clampi(int(raw_value), RouteLock.NONE, RouteLock.CLOSED) as RouteLock + var index := RouteLock.keys().find(String(raw_value).to_upper()) + return (index if index >= 0 else RouteLock.NONE) as RouteLock + + +static func _string_key_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array[String] = [] + for raw_key: Variant in source: + keys.append(String(raw_key)) + keys.sort() + for key: String in keys: + var value: Variant = source.get(StringName(key), source.get(key)) + result[key] = value.duplicate(true) if value is Array or value is Dictionary else value + return result diff --git a/scripts/relationship/relationship_state.gd.uid b/scripts/relationship/relationship_state.gd.uid new file mode 100644 index 0000000..8e77789 --- /dev/null +++ b/scripts/relationship/relationship_state.gd.uid @@ -0,0 +1 @@ +uid://dya1fxgmkp6a2 diff --git a/scripts/staff/shift_instance.gd b/scripts/staff/shift_instance.gd new file mode 100644 index 0000000..4b082f1 --- /dev/null +++ b/scripts/staff/shift_instance.gd @@ -0,0 +1,240 @@ +class_name ShiftInstance +extends RefCounted + +## One concrete shift, identified by its start day even when it ends on the +## following day. Absolute integer minutes avoid every midnight ambiguity. + +enum Kind { + DAY, + NIGHT, + FLEX, +} + +const MINUTES_PER_DAY := 1_440 +const COMMUTE_LEAD_MINUTES := 30 +const DAY_START := 360 +const DAY_END := 1_080 +const NIGHT_START := 1_080 +const NIGHT_END := 360 + +var shift_instance_id: StringName = &"" +var staff_id: StringName = &"" +var template_id: StringName = &"FLEX" +var kind: Kind = Kind.FLEX +var start_day := 0 +var start_minute_of_day := 0 +var end_minute_of_day := 1 +var start_at := 0 +var end_at := 1 +var assigned_facility_id: StringName = &"" +var cancelled := false +var actual_started_at := -1 +var actual_ended_at := -1 +var worked_minutes := 0 +var overtime_minutes := 0 +var late_minutes := 0 +var late_reason: StringName = &"" + + +func configure( + p_shift_instance_id: StringName, + p_staff_id: StringName, + p_template_id: StringName, + p_start_day: int, + p_start_minute_of_day: int, + p_end_minute_of_day: int, + p_assigned_facility_id: StringName = &"", + options: Dictionary = {} +) -> ShiftInstance: + shift_instance_id = p_shift_instance_id + staff_id = p_staff_id + template_id = p_template_id if not p_template_id.is_empty() else &"FLEX" + kind = _kind_from_template(template_id) + start_day = maxi(p_start_day, 0) + start_minute_of_day = p_start_minute_of_day + end_minute_of_day = p_end_minute_of_day + start_at = start_day * MINUTES_PER_DAY + start_minute_of_day + end_at = start_day * MINUTES_PER_DAY + end_minute_of_day + if end_minute_of_day <= start_minute_of_day: + end_at += MINUTES_PER_DAY + assigned_facility_id = p_assigned_facility_id + cancelled = bool(_value(options, &"cancelled", false)) + actual_started_at = int(_value(options, &"actual_started_at", -1)) + actual_ended_at = int(_value(options, &"actual_ended_at", -1)) + worked_minutes = maxi(int(_value(options, &"worked_minutes", 0)), 0) + overtime_minutes = maxi(int(_value(options, &"overtime_minutes", 0)), 0) + late_minutes = maxi(int(_value(options, &"late_minutes", 0)), 0) + late_reason = StringName(_value(options, &"late_reason", &"")) + return self + + +static func create_standard( + p_shift_instance_id: StringName, + p_staff_id: StringName, + p_template_id: StringName, + p_start_day: int, + p_assigned_facility_id: StringName = &"" +) -> ShiftInstance: + var normalized := String(p_template_id).to_upper() + if normalized == "DAY": + return ShiftInstance.new().configure( + p_shift_instance_id, + p_staff_id, + &"DAY", + p_start_day, + DAY_START, + DAY_END, + p_assigned_facility_id + ) + if normalized == "NIGHT": + return ShiftInstance.new().configure( + p_shift_instance_id, + p_staff_id, + &"NIGHT", + p_start_day, + NIGHT_START, + NIGHT_END, + p_assigned_facility_id + ) + return null + + +func is_valid() -> bool: + return ( + not shift_instance_id.is_empty() + and not staff_id.is_empty() + and start_day >= 0 + and start_minute_of_day >= 0 + and start_minute_of_day < MINUTES_PER_DAY + and end_minute_of_day >= 0 + and end_minute_of_day < MINUTES_PER_DAY + and end_at > start_at + and end_at - start_at <= MINUTES_PER_DAY + and worked_minutes >= 0 + and overtime_minutes >= 0 + and late_minutes >= 0 + ) + + +func crosses_midnight() -> bool: + return end_at / MINUTES_PER_DAY > start_at / MINUTES_PER_DAY + + +func contains_minute(absolute_minute: int) -> bool: + return not cancelled and absolute_minute >= start_at and absolute_minute < end_at + + +func is_commute_window(absolute_minute: int) -> bool: + return ( + not cancelled + and absolute_minute >= start_at - COMMUTE_LEAD_MINUTES + and absolute_minute < start_at + ) + + +func has_ended(absolute_minute: int) -> bool: + return cancelled or absolute_minute >= end_at + + +func record_duty_minute(absolute_minute: int, overtime := false) -> bool: + if actual_started_at < 0: + actual_started_at = absolute_minute + if overtime: + if absolute_minute < end_at: + return false + overtime_minutes += 1 + return true + if not contains_minute(absolute_minute): + return false + worked_minutes += 1 + return true + + +func close(absolute_minute: int) -> void: + if actual_ended_at < 0: + actual_ended_at = maxi(absolute_minute, start_at) + + +func mark_late(absolute_minute: int, reason: StringName) -> bool: + if absolute_minute < start_at + 15: + return false + late_minutes = maxi(absolute_minute - start_at, 15) + late_reason = reason + return true + + +func create_snapshot() -> Dictionary: + return { + &"shift_instance_id": String(shift_instance_id), + &"staff_id": String(staff_id), + &"template_id": String(template_id), + &"kind": Kind.keys()[kind], + &"start_day": start_day, + &"start_minute_of_day": start_minute_of_day, + &"end_minute_of_day": end_minute_of_day, + &"start_at": start_at, + &"end_at": end_at, + &"assigned_facility_id": String(assigned_facility_id), + &"cancelled": cancelled, + &"actual_started_at": actual_started_at, + &"actual_ended_at": actual_ended_at, + &"worked_minutes": worked_minutes, + &"overtime_minutes": overtime_minutes, + &"late_minutes": late_minutes, + &"late_reason": String(late_reason), + } + + +static func from_snapshot(snapshot: Dictionary) -> ShiftInstance: + if not _snapshot_shape_is_valid(snapshot): + return null + var shift := ShiftInstance.new().configure( + StringName(snapshot.get(&"shift_instance_id", "")), + StringName(snapshot.get(&"staff_id", "")), + StringName(snapshot.get(&"template_id", "FLEX")), + int(snapshot.get(&"start_day", 0)), + int(snapshot.get(&"start_minute_of_day", 0)), + int(snapshot.get(&"end_minute_of_day", 1)), + StringName(snapshot.get(&"assigned_facility_id", "")), + { + &"cancelled": snapshot.get(&"cancelled", false), + &"actual_started_at": snapshot.get(&"actual_started_at", -1), + &"actual_ended_at": snapshot.get(&"actual_ended_at", -1), + &"worked_minutes": snapshot.get(&"worked_minutes", 0), + &"overtime_minutes": snapshot.get(&"overtime_minutes", 0), + &"late_minutes": snapshot.get(&"late_minutes", 0), + &"late_reason": snapshot.get(&"late_reason", ""), + } + ) + if shift.start_at != int(snapshot.get(&"start_at", -1)): + return null + if shift.end_at != int(snapshot.get(&"end_at", -1)): + return null + var kind_index := Kind.keys().find(String(snapshot.get(&"kind", ""))) + if kind_index < 0 or shift.kind != kind_index: + return null + return shift if shift.is_valid() else null + + +static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool: + return ( + not String(snapshot.get(&"shift_instance_id", "")).is_empty() + and not String(snapshot.get(&"staff_id", "")).is_empty() + and int(snapshot.get(&"start_day", -1)) >= 0 + and int(snapshot.get(&"worked_minutes", -1)) >= 0 + and int(snapshot.get(&"overtime_minutes", -1)) >= 0 + ) + + +static func _kind_from_template(value: StringName) -> Kind: + match String(value).to_upper(): + "DAY": + return Kind.DAY + "NIGHT": + return Kind.NIGHT + _: + return Kind.FLEX + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) diff --git a/scripts/staff/shift_instance.gd.uid b/scripts/staff/shift_instance.gd.uid new file mode 100644 index 0000000..fe08cfe --- /dev/null +++ b/scripts/staff/shift_instance.gd.uid @@ -0,0 +1 @@ +uid://0blu3y4pvj30 diff --git a/scripts/staff/staff_runtime.gd b/scripts/staff/staff_runtime.gd new file mode 100644 index 0000000..b506f01 --- /dev/null +++ b/scripts/staff/staff_runtime.gd @@ -0,0 +1,493 @@ +class_name StaffRuntime +extends RefCounted + +## Mutable state for one employee. Static role definitions remain outside this +## object; the copied tags make dispatch deterministic even if content is hot +## reloaded later. + +enum State { + OFF_DUTY, + COMMUTING, + AVAILABLE, + MOVING_TO_TASK, + WORKING, + BREAKING, + EATING, + SLEEPING, + SEEKING_MEDICAL, + FLEEING, + INCAPACITATED, + STRIKE, + DETAINED, + CAPTIVE, + UNAVAILABLE, +} + +## EmploymentKind describes the commercial relationship only. Adult-content +## eligibility is deliberately validated by MvpContentCatalog before a hire is +## constructed; ordinary Staff fixtures therefore remain valid FREE_HIRE rows. +enum EmploymentKind { + FREE_HIRE, + ADULT_CONTRACT, + UNIQUE, +} + +const MAX_PSYCHOLOGY_VALUE := 100 +const MAX_SKILL_VALUE := 999 +const DANGEROUS_TASK_FATIGUE_LIMIT := 90 +const SNAPSHOT_VERSION := 1 + +var staff_id: StringName = &"" +var role_id: StringName = &"" +var current_cell := Vector3i.ZERO +var assigned_facility_id: StringName = &"" +var shift_id: StringName = &"" +var state: State = State.OFF_DUTY +var current_task_id: StringName = &"" +var skills: Dictionary = {} +var fatigue := 0 +var stress := 0 +var morale := 100 +var loyalty := 50 +var health := 100 +var carried_inventory_id: StringName = &"" +var path_handle: StringName = &"" +var absence_reason: StringName = &"" + +var permitted_task_tags: Array[StringName] = [] +var role_tags: Array[StringName] = [] +var legal_permissions: Array[StringName] = [] +var equipment_tags: Array[StringName] = [] +var required_equipment_tags: Array[StringName] = [] +var daily_wage := 0 +var employment_kind: EmploymentKind = EmploymentKind.FREE_HIRE +var employment_effective_minute := 0 +var unpaid_wages := 0 +var is_retiring := false +var is_overtime := false +var state_changed_at := 0 +var state_reason: StringName = &"" + +var _employment_configuration_valid := true + + +func configure( + p_staff_id: StringName, + p_role_id: StringName, + options: Dictionary = {} +) -> StaffRuntime: + staff_id = p_staff_id + role_id = p_role_id + current_cell = _vector3i(_value(options, &"current_cell", Vector3i.ZERO)) + assigned_facility_id = StringName(_value(options, &"assigned_facility_id", &"")) + shift_id = StringName(_value(options, &"shift_id", &"")) + state = _state_from_value(_value(options, &"state", State.OFF_DUTY), State.OFF_DUTY) + current_task_id = StringName(_value(options, &"current_task_id", &"")) + skills = _normalize_skills(_value(options, &"skills", {})) + fatigue = clampi(int(_value(options, &"fatigue", 0)), 0, MAX_PSYCHOLOGY_VALUE) + stress = clampi(int(_value(options, &"stress", 0)), 0, MAX_PSYCHOLOGY_VALUE) + morale = clampi(int(_value(options, &"morale", 100)), 0, MAX_PSYCHOLOGY_VALUE) + loyalty = clampi(int(_value(options, &"loyalty", 50)), 0, MAX_PSYCHOLOGY_VALUE) + health = clampi(int(_value(options, &"health", 100)), 0, MAX_PSYCHOLOGY_VALUE) + carried_inventory_id = StringName(_value(options, &"carried_inventory_id", &"")) + path_handle = StringName(_value(options, &"path_handle", &"")) + absence_reason = StringName(_value(options, &"absence_reason", &"")) + permitted_task_tags = _sorted_unique_names(_value(options, &"permitted_task_tags", [])) + role_tags = _sorted_unique_names(_value(options, &"role_tags", [])) + if not role_id.is_empty() and not role_tags.has(role_id): + role_tags.append(role_id) + role_tags.sort_custom(_name_less) + legal_permissions = _sorted_unique_names(_value(options, &"legal_permissions", [])) + equipment_tags = _sorted_unique_names(_value(options, &"equipment_tags", [])) + required_equipment_tags = _sorted_unique_names(_value(options, &"required_equipment_tags", [])) + _employment_configuration_valid = true + var raw_daily_wage: Variant = _value(options, &"daily_wage", 0) + if typeof(raw_daily_wage) != TYPE_INT or int(raw_daily_wage) < 0: + _employment_configuration_valid = false + daily_wage = 0 + else: + daily_wage = int(raw_daily_wage) + var parsed_employment := _parse_employment_kind( + _value(options, &"employment_kind", EmploymentKind.FREE_HIRE) + ) + if not bool(parsed_employment[&"ok"]): + _employment_configuration_valid = false + employment_kind = EmploymentKind.FREE_HIRE + else: + employment_kind = int(parsed_employment[&"value"]) as EmploymentKind + var raw_employment_effective_minute: Variant = _value( + options, &"employment_effective_minute", 0 + ) + if ( + typeof(raw_employment_effective_minute) != TYPE_INT + or int(raw_employment_effective_minute) < 0 + ): + _employment_configuration_valid = false + employment_effective_minute = 0 + else: + employment_effective_minute = int(raw_employment_effective_minute) + unpaid_wages = maxi(int(_value(options, &"unpaid_wages", 0)), 0) + is_retiring = bool(_value(options, &"is_retiring", false)) + is_overtime = bool(_value(options, &"is_overtime", false)) + state_changed_at = maxi(int(_value(options, &"state_changed_at", 0)), 0) + state_reason = StringName(_value(options, &"state_reason", &"")) + if health <= 0: + state = State.INCAPACITATED + return self + + +func is_valid() -> bool: + if staff_id.is_empty() or role_id.is_empty(): + return false + if fatigue < 0 or fatigue > MAX_PSYCHOLOGY_VALUE: + return false + if stress < 0 or stress > MAX_PSYCHOLOGY_VALUE: + return false + if morale < 0 or morale > MAX_PSYCHOLOGY_VALUE: + return false + if loyalty < 0 or loyalty > MAX_PSYCHOLOGY_VALUE: + return false + if health < 0 or health > MAX_PSYCHOLOGY_VALUE: + return false + if unpaid_wages < 0: + return false + if not _employment_configuration_valid or daily_wage < 0: + return false + if employment_effective_minute < 0: + return false + if int(employment_kind) < 0 or int(employment_kind) >= EmploymentKind.size(): + return false + for raw_skill_id: Variant in skills: + var skill_id := StringName(raw_skill_id) + var skill_value := int(skills[raw_skill_id]) + if skill_id.is_empty() or skill_value < 0 or skill_value > MAX_SKILL_VALUE: + return false + return true + + +func can_take_shift() -> bool: + return health > 0 and not is_retiring and state not in [ + State.INCAPACITATED, + State.STRIKE, + State.DETAINED, + State.CAPTIVE, + State.UNAVAILABLE, + ] + + +func has_required_equipment() -> bool: + for equipment_id: StringName in required_equipment_tags: + if not equipment_tags.has(equipment_id): + return false + return true + + +func is_available_for_dispatch() -> bool: + return can_take_shift() and has_required_equipment() and state == State.AVAILABLE and current_task_id.is_empty() + + +func meets_task_requirements(task: Variant) -> bool: + if task == null or not can_take_shift() or not has_required_equipment(): + return false + if bool(task.dangerous) and fatigue >= DANGEROUS_TASK_FATIGUE_LIMIT: + return false + for required_tag: StringName in task.required_role_tags: + if not role_tags.has(required_tag): + return false + for task_tag: StringName in task.task_tags: + if not permitted_task_tags.is_empty() and not permitted_task_tags.has(task_tag): + return false + for raw_skill_id: Variant in task.required_skill_min: + var skill_id := StringName(raw_skill_id) + if int(skills.get(skill_id, 0)) < int(task.required_skill_min[raw_skill_id]): + return false + return true + + +func role_fit_for(task: Variant) -> int: + if task == null: + return 0 + if task.required_role_tags.is_empty(): + return 0 + for required_tag: StringName in task.required_role_tags: + if not role_tags.has(required_tag): + return 0 + return 1 + + +func skill_fit_for(task: Variant) -> int: + if task == null or task.required_skill_min.is_empty(): + return 0 + var total := 0 + for raw_skill_id: Variant in task.required_skill_min: + var skill_id := StringName(raw_skill_id) + total += int(skills.get(skill_id, 0)) - int(task.required_skill_min[raw_skill_id]) + return maxi(total, 0) + + +func reserve_task(task_id: StringName, absolute_minute: int) -> bool: + if task_id.is_empty() or not current_task_id.is_empty(): + return false + if state != State.AVAILABLE: + return false + current_task_id = task_id + transition_to(State.MOVING_TO_TASK, &"TASK_RESERVED", absolute_minute) + return true + + +func begin_task(absolute_minute: int) -> bool: + if current_task_id.is_empty() or state not in [State.MOVING_TO_TASK, State.AVAILABLE]: + return false + transition_to(State.WORKING, &"TASK_STARTED", absolute_minute) + return true + + +func release_task(absolute_minute: int, reason: StringName = &"TASK_RELEASED") -> bool: + if current_task_id.is_empty(): + return false + current_task_id = &"" + if can_take_shift(): + transition_to(State.AVAILABLE, reason, absolute_minute) + else: + transition_to(State.OFF_DUTY, reason, absolute_minute) + return true + + +func transition_to(next_state: State, reason: StringName, absolute_minute: int) -> void: + state = next_state + state_reason = reason + state_changed_at = maxi(absolute_minute, 0) + + +func add_fatigue(amount: int) -> int: + fatigue = clampi(fatigue + amount, 0, MAX_PSYCHOLOGY_VALUE) + return fatigue + + +func add_stress(amount: int) -> int: + stress = clampi(stress + amount, 0, MAX_PSYCHOLOGY_VALUE) + return stress + + +func get_employment_kind_name() -> StringName: + return StringName(EmploymentKind.keys()[employment_kind]) + + +func create_snapshot() -> Dictionary: + return { + &"snapshot_version": SNAPSHOT_VERSION, + &"staff_id": String(staff_id), + &"role_id": String(role_id), + &"current_cell": [current_cell.x, current_cell.y, current_cell.z], + &"assigned_facility_id": String(assigned_facility_id), + &"shift_id": String(shift_id), + &"state": State.keys()[state], + &"current_task_id": String(current_task_id), + &"skills": _serialize_skills(skills), + &"fatigue": fatigue, + &"stress": stress, + &"morale": morale, + &"loyalty": loyalty, + &"health": health, + &"carried_inventory_id": String(carried_inventory_id), + &"path_handle": String(path_handle), + &"absence_reason": String(absence_reason), + &"permitted_task_tags": _names_to_strings(permitted_task_tags), + &"role_tags": _names_to_strings(role_tags), + &"legal_permissions": _names_to_strings(legal_permissions), + &"equipment_tags": _names_to_strings(equipment_tags), + &"required_equipment_tags": _names_to_strings(required_equipment_tags), + &"daily_wage": daily_wage, + &"employment_kind": String(get_employment_kind_name()), + &"employment_effective_minute": employment_effective_minute, + &"unpaid_wages": unpaid_wages, + &"is_retiring": is_retiring, + &"is_overtime": is_overtime, + &"state_changed_at": state_changed_at, + &"state_reason": String(state_reason), + } + + +static func from_snapshot(snapshot: Dictionary) -> StaffRuntime: + if not _snapshot_shape_is_valid(snapshot): + return null + var state_index := State.keys().find(String(snapshot.get(&"state", ""))) + if state_index < 0: + return null + var staff := StaffRuntime.new().configure( + StringName(snapshot.get(&"staff_id", "")), + StringName(snapshot.get(&"role_id", "")), + { + &"current_cell": snapshot.get(&"current_cell", []), + &"assigned_facility_id": snapshot.get(&"assigned_facility_id", ""), + &"shift_id": snapshot.get(&"shift_id", ""), + &"state": state_index, + &"current_task_id": snapshot.get(&"current_task_id", ""), + &"skills": snapshot.get(&"skills", {}), + &"fatigue": snapshot.get(&"fatigue", 0), + &"stress": snapshot.get(&"stress", 0), + &"morale": snapshot.get(&"morale", 100), + &"loyalty": snapshot.get(&"loyalty", 50), + &"health": snapshot.get(&"health", 100), + &"carried_inventory_id": snapshot.get(&"carried_inventory_id", ""), + &"path_handle": snapshot.get(&"path_handle", ""), + &"absence_reason": snapshot.get(&"absence_reason", ""), + &"permitted_task_tags": snapshot.get(&"permitted_task_tags", []), + &"role_tags": snapshot.get(&"role_tags", []), + &"legal_permissions": snapshot.get(&"legal_permissions", []), + &"equipment_tags": snapshot.get(&"equipment_tags", []), + &"required_equipment_tags": snapshot.get(&"required_equipment_tags", []), + # Snapshots written before payroll support omitted both fields. Their + # deterministic migration is a zero-wage ordinary hire. + &"daily_wage": snapshot.get(&"daily_wage", 0), + &"employment_kind": snapshot.get(&"employment_kind", "FREE_HIRE"), + # Rows written before the employment-time payroll gate represent Staff + # who already existed at simulation minute zero. + &"employment_effective_minute": snapshot.get(&"employment_effective_minute", 0), + &"unpaid_wages": snapshot.get(&"unpaid_wages", 0), + &"is_retiring": snapshot.get(&"is_retiring", false), + &"is_overtime": snapshot.get(&"is_overtime", false), + &"state_changed_at": snapshot.get(&"state_changed_at", 0), + &"state_reason": snapshot.get(&"state_reason", ""), + } + ) + return staff if staff.is_valid() else null + + +static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool: + var has_snapshot_version := snapshot.has(&"snapshot_version") or snapshot.has("snapshot_version") + var has_effective_minute := ( + snapshot.has(&"employment_effective_minute") + or snapshot.has("employment_effective_minute") + ) + if has_snapshot_version != has_effective_minute: + return false + if has_snapshot_version: + var raw_snapshot_version: Variant = snapshot.get( + &"snapshot_version", snapshot.get("snapshot_version", null) + ) + if typeof(raw_snapshot_version) != TYPE_INT or int(raw_snapshot_version) != SNAPSHOT_VERSION: + return false + # Versioned rows are current-schema data; omission is corruption rather + # than permission to silently reinterpret the employee as a legacy hire. + if not has_effective_minute: + return false + if has_effective_minute: + var effective_minute: Variant = snapshot.get( + &"employment_effective_minute", + snapshot.get("employment_effective_minute", null) + ) + if typeof(effective_minute) != TYPE_INT or int(effective_minute) < 0: + return false + var unpaid: Variant = snapshot.get(&"unpaid_wages", snapshot.get("unpaid_wages", null)) + if typeof(unpaid) != TYPE_INT or int(unpaid) < 0: + return false + if snapshot.has(&"daily_wage") or snapshot.has("daily_wage"): + var wage: Variant = snapshot.get(&"daily_wage", snapshot.get("daily_wage")) + if typeof(wage) != TYPE_INT or int(wage) < 0: + return false + if snapshot.has(&"employment_kind") or snapshot.has("employment_kind"): + var kind: Variant = snapshot.get( + &"employment_kind", snapshot.get("employment_kind") + ) + # The serialized contract is deliberately narrower than configure(): a + # current Save always writes one canonical enum name. + if typeof(kind) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + if not bool(_parse_employment_kind(kind)[&"ok"]): + return false + return ( + not String(snapshot.get(&"staff_id", "")).is_empty() + and not String(snapshot.get(&"role_id", "")).is_empty() + and snapshot.get(&"current_cell", null) is Array + and snapshot.get(&"skills", null) is Dictionary + and snapshot.get(&"permitted_task_tags", null) is Array + and snapshot.get(&"role_tags", null) is Array + and snapshot.get(&"legal_permissions", null) is Array + and snapshot.get(&"equipment_tags", null) is Array + and snapshot.get(&"required_equipment_tags", null) is Array + and int(snapshot.get(&"fatigue", -1)) in range(0, MAX_PSYCHOLOGY_VALUE + 1) + and int(snapshot.get(&"health", -1)) in range(0, MAX_PSYCHOLOGY_VALUE + 1) + ) + + +static func _parse_employment_kind(value: Variant) -> Dictionary: + if typeof(value) == TYPE_INT: + var numeric := int(value) + return { + &"ok": numeric >= 0 and numeric < EmploymentKind.size(), + &"value": numeric, + } + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"value": EmploymentKind.FREE_HIRE} + var index := EmploymentKind.keys().find(String(value)) + return { + &"ok": index >= 0, + &"value": index if index >= 0 else EmploymentKind.FREE_HIRE, + } + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _state_from_value(value: Variant, fallback: State) -> State: + if value is int: + var numeric := int(value) + if numeric >= 0 and numeric < State.size(): + return numeric as State + var index := State.keys().find(String(value)) + return index as State if index >= 0 else fallback + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() == 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + return Vector3i.ZERO + + +static func _normalize_skills(source: Variant) -> Dictionary: + var result: Dictionary = {} + if not source is Dictionary: + return result + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_skill_id: Variant in keys: + var skill_id := StringName(raw_skill_id) + if not skill_id.is_empty(): + result[skill_id] = clampi(int(source[raw_skill_id]), 0, MAX_SKILL_VALUE) + return result + + +static func _serialize_skills(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_skill_id: Variant in keys: + result[String(raw_skill_id)] = int(source[raw_skill_id]) + return result + + +static func _sorted_unique_names(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for value: Variant in source: + var name := StringName(value) + if not name.is_empty() and not result.has(name): + result.append(name) + result.sort_custom(_name_less) + return result + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result + + +static func _name_less(a: StringName, b: StringName) -> bool: + return String(a) < String(b) diff --git a/scripts/staff/staff_runtime.gd.uid b/scripts/staff/staff_runtime.gd.uid new file mode 100644 index 0000000..bb6e083 --- /dev/null +++ b/scripts/staff/staff_runtime.gd.uid @@ -0,0 +1 @@ +uid://d4d740ytn1ngs diff --git a/scripts/staff/staff_service.gd b/scripts/staff/staff_service.gd new file mode 100644 index 0000000..74fedd4 --- /dev/null +++ b/scripts/staff/staff_service.gd @@ -0,0 +1,1776 @@ +class_name StaffService +extends RefCounted + +signal staff_registered(staff: StaffRuntime) +signal staff_removed(staff_id: StringName, reason: StringName) +signal shift_registered(shift: ShiftInstance) +signal shift_state_changed(staff_id: StringName, shift_id: StringName, state: int) +signal task_registered(task: StaffTask) +signal task_assigned(task_id: StringName, staff_id: StringName) +signal task_reopened(task_id: StringName, reason: StringName) +signal task_completed(task_id: StringName, staff_id: StringName) + +const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd") +const ShiftInstanceType = preload("res://scripts/staff/shift_instance.gd") +const StaffTaskType = preload("res://scripts/staff/staff_task.gd") +const TaskDispatcherType = preload("res://scripts/staff/task_dispatcher.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const LEGACY_SNAPSHOT_VERSION := 1 +const PAYROLL_SNAPSHOT_VERSION := 2 +const SNAPSHOT_VERSION := 3 +const HIGH_PRIORITY_THRESHOLD := 90 +const DEFAULT_LATE_REASON := &"PERSONAL" +const MAX_PAYROLL_DAY := 1_000_000 +const MAX_PAYROLL_SETTLEMENTS := 10_000 +const MAX_PAYROLL_ROWS := 2_000 +const MAX_PAYROLL_AMOUNT := 9_000_000_000_000 +const MINUTES_PER_DAY := 1_440 + +var current_minute := 0 +var last_error := "" +var last_dispatch_report: Dictionary = {} +var last_recovery_repair_report: Dictionary = {} + +var _staff: Dictionary = {} +var _shifts: Dictionary = {} +var _tasks: Dictionary = {} +var _schedule_overrides: Dictionary = {} +var _departed_payroll: Dictionary = {} +var _payroll_settlements: Dictionary = {} +var _payroll_days: Dictionary = {} +var _next_shift_sequence := 1 +var _next_task_sequence := 1 +var _last_advanced_minute := -1 +var _dispatcher := TaskDispatcherType.new() +var _item_reserver := Callable() +var _item_releaser := Callable() +var _travel_estimator := Callable() + + +func configure_callbacks( + item_reserver: Callable = Callable(), + item_releaser: Callable = Callable(), + travel_estimator: Callable = Callable() +) -> StaffService: + _item_reserver = item_reserver + _item_releaser = item_releaser + _travel_estimator = travel_estimator + _dispatcher.configure_callbacks(item_reserver, item_releaser, travel_estimator) + return self + + +func register_staff(value: Variant) -> CommandResult: + if value == null or not value is StaffRuntime: + return _invalid("Staff runtime is missing or has an invalid type.") + var staff: StaffRuntime = value + if not staff.is_valid(): + return _invalid("Staff runtime is invalid.", {&"staff_id": staff.staff_id}) + if _staff.has(staff.staff_id): + return _duplicate("Staff", staff.staff_id) + if _departed_payroll.has(staff.staff_id): + return _conflict("Staff ID has an unsettled departed payroll obligation.") + if not staff.current_task_id.is_empty() or not staff.shift_id.is_empty(): + return _invalid("New Staff must register before Task or Shift assignment.") + _staff[staff.staff_id] = staff + staff_registered.emit(staff) + last_error = "" + return _success({&"staff_id": staff.staff_id}) + + +func create_staff( + staff_id: StringName, + role_id: StringName, + options: Dictionary = {} +) -> CommandResult: + var staff := StaffRuntimeType.new().configure(staff_id, role_id, options) + return register_staff(staff) + + +func has_staff(staff_id: StringName) -> bool: + return _staff.has(staff_id) + + +func get_staff(staff_id: StringName) -> StaffRuntime: + return _staff.get(staff_id) as StaffRuntime + + +func get_staff_members() -> Array[StaffRuntime]: + var result: Array[StaffRuntime] = [] + for staff_id: StringName in _sorted_keys(_staff): + result.append(_staff[staff_id]) + return result + + +## Stable input for the Operations daily-close adapter. Departed wage debt is +## intentionally not mixed into this daily expense: it remains in +## departed_payroll until a separate settlement transaction clears it. +func get_daily_payroll_rows() -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(_staff): + var staff: StaffRuntime = _staff[staff_id] + rows.append({ + &"staff_id": staff.staff_id, + &"employment_kind": staff.get_employment_kind_name(), + &"daily_wage": staff.daily_wage, + }) + return rows + + +func get_daily_payroll_total() -> int: + var total := 0 + for row: Dictionary in get_daily_payroll_rows(): + total += int(row[&"daily_wage"]) + return total + + +## Returns the currently carried wage liabilities without accruing a new day. +## Every returned row has the same shape and the whole result is ordered by +## Staff ID so Operations never needs access to the private payroll ledgers. +func get_payroll_obligations() -> Array[Dictionary]: + var by_staff_id: Dictionary = {} + for staff_id: StringName in _sorted_keys(_staff): + var staff: StaffRuntime = _staff[staff_id] + by_staff_id[staff_id] = { + &"staff_id": staff_id, + &"payroll_status": &"ACTIVE", + &"employment_kind": staff.get_employment_kind_name(), + &"daily_wage": staff.daily_wage, + &"unpaid_wages": staff.unpaid_wages, + } + for staff_id: StringName in _sorted_keys(_departed_payroll): + if int(_departed_payroll[staff_id]) <= 0: + continue + by_staff_id[staff_id] = { + &"staff_id": staff_id, + &"payroll_status": &"DEPARTED", + &"employment_kind": &"", + &"daily_wage": 0, + &"unpaid_wages": int(_departed_payroll[staff_id]), + } + var rows: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(by_staff_id): + rows.append((by_staff_id[staff_id] as Dictionary).duplicate(true)) + return rows + + +func get_departed_staff_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for staff_id: StringName in _sorted_keys(_departed_payroll): + if int(_departed_payroll[staff_id]) > 0: + result.append(staff_id) + return result + + +func get_payroll_settlement(settlement_id: StringName) -> Dictionary: + var record: Variant = _payroll_settlements.get(settlement_id, {}) + return record.duplicate(true) if record is Dictionary else {} + + +## Pure preview for one daily close. Active Staff accrue exactly one daily wage; +## departed Staff contribute only debt already preserved at departure. +func preview_daily_payroll(day: Variant) -> Dictionary: + if typeof(day) != TYPE_INT or int(day) <= 0 or int(day) > MAX_PAYROLL_DAY: + return _payroll_preview_error(&"INVALID_DAY", "Payroll day must be a positive integer.") + var payroll_day := int(day) + if _payroll_days.has(payroll_day): + return _payroll_preview_error( + &"DAY_ALREADY_SETTLED", + "Payroll day already has a settlement.", + {&"day": payroll_day, &"settlement_id": _payroll_days[payroll_day]} + ) + var built := _build_payroll_preview(payroll_day) + if not bool(built.get(&"ok", false)): + return built + return built.duplicate(true) + + +## Atomically accrues the day's wages and applies the caller's allocation. A +## settlement ID is an idempotency key: exact input replays, conflicting input +## or another settlement for the same day is rejected without mutation. +func apply_daily_payroll_settlement( + settlement_id: Variant, + day: Variant, + payments_by_staff: Variant +) -> CommandResult: + if not _stable_settlement_id_is_valid(settlement_id): + return _invalid("Payroll settlement ID is invalid.") + if typeof(day) != TYPE_INT or int(day) <= 0 or int(day) > MAX_PAYROLL_DAY: + return _invalid("Payroll day must be a positive integer.") + if not payments_by_staff is Dictionary: + return _invalid("Payroll payments must be a Dictionary.") + var stable_id := StringName(settlement_id) + var payroll_day := int(day) + var parsed_payments := _parse_payroll_payments(payments_by_staff) + if not bool(parsed_payments.get(&"ok", false)): + return _invalid(String(parsed_payments.get(&"message", "Payroll payments are invalid."))) + var input_payments: Array[Dictionary] = parsed_payments[&"payments"] + var fingerprint := _payroll_fingerprint(payroll_day, input_payments) + + if _payroll_settlements.has(stable_id): + var existing: Dictionary = _payroll_settlements[stable_id] + if int(existing[&"day"]) != payroll_day or String(existing[&"fingerprint"]) != fingerprint: + return _conflict("Payroll settlement ID conflicts with an earlier input.") + var replay_details := existing.duplicate(true) + replay_details[&"replayed"] = true + return _success(replay_details) + if _payroll_days.has(payroll_day): + return _conflict("Payroll day already has a different settlement.") + if _payroll_settlements.size() >= MAX_PAYROLL_SETTLEMENTS: + return _conflict("Payroll settlement history budget is exhausted.") + + var preview := _build_payroll_preview(payroll_day) + if not bool(preview.get(&"ok", false)): + return _invalid(String(preview.get(&"message", "Payroll preview is invalid."))) + var due_by_staff: Dictionary = {} + for row: Dictionary in preview[&"rows"]: + due_by_staff[row[&"staff_id"]] = int(row[&"total_due"]) + for payment: Dictionary in input_payments: + var staff_id: StringName = payment[&"staff_id"] + var amount := int(payment[&"amount"]) + if not due_by_staff.has(staff_id): + return _invalid("Payroll payment references an unknown Staff ID.", {&"staff_id": staff_id}) + if amount > int(due_by_staff[staff_id]): + return _invalid("Payroll payment exceeds the Staff obligation.", {&"staff_id": staff_id}) + + var payment_index: Dictionary = {} + for payment: Dictionary in input_payments: + payment_index[payment[&"staff_id"]] = int(payment[&"amount"]) + var settlement_rows: Array[Dictionary] = [] + var paid_total := 0 + var unpaid_after_total := 0 + for preview_row: Dictionary in preview[&"rows"]: + var staff_id: StringName = preview_row[&"staff_id"] + var payment := int(payment_index.get(staff_id, 0)) + var unpaid_after := int(preview_row[&"total_due"]) - payment + var row := preview_row.duplicate(true) + row[&"payment"] = payment + row[&"unpaid_after"] = unpaid_after + settlement_rows.append(row) + paid_total += payment + unpaid_after_total += unpaid_after + + # All validation and derived calculations finish before the first live write. + for row: Dictionary in settlement_rows: + var staff_id: StringName = row[&"staff_id"] + var unpaid_after := int(row[&"unpaid_after"]) + if row[&"payroll_status"] == &"ACTIVE": + (_staff[staff_id] as StaffRuntime).unpaid_wages = unpaid_after + elif unpaid_after > 0: + _departed_payroll[staff_id] = unpaid_after + else: + _departed_payroll.erase(staff_id) + var record := { + &"settlement_id": stable_id, + &"day": payroll_day, + &"fingerprint": fingerprint, + &"input_payments": input_payments.duplicate(true), + &"rows": settlement_rows, + &"new_wages_total": int(preview[&"new_wages_total"]), + &"prior_arrears_total": int(preview[&"prior_arrears_total"]), + &"total_due": int(preview[&"total_due"]), + &"paid_total": paid_total, + &"unpaid_after_total": unpaid_after_total, + } + _payroll_settlements[stable_id] = record + _payroll_days[payroll_day] = stable_id + var details := record.duplicate(true) + details[&"replayed"] = false + return _success(details) + + +func register_shift(value: Variant) -> CommandResult: + if value == null or not value is ShiftInstance: + return _invalid("Shift instance is missing or has an invalid type.") + var shift: ShiftInstance = value + if not shift.is_valid(): + return _invalid("Shift instance is invalid.", {&"shift_id": shift.shift_instance_id}) + if _shifts.has(shift.shift_instance_id): + return _duplicate("Shift", shift.shift_instance_id) + var staff := get_staff(shift.staff_id) + if staff == null: + return _not_found("Staff", shift.staff_id) + if not staff.can_take_shift(): + return _conflict("Staff cannot take a Shift in the current state.") + _apply_override_to_shift(shift) + for existing_shift_id: StringName in _sorted_keys(_shifts): + var existing: ShiftInstance = _shifts[existing_shift_id] + if existing.staff_id != shift.staff_id: + continue + if _shifts_overlap(existing, shift): + return _conflict("Staff already has an overlapping Shift.") + _shifts[shift.shift_instance_id] = shift + shift_registered.emit(shift) + # Future Shift instances are schedule records, not the Staff's active pointer. + # Reconciliation selects the correct one when its commute window begins. This + # lets non-overlapping future Shifts be registered in any order. + _update_shift_states(current_minute) + last_error = "" + return _success({&"shift_id": shift.shift_instance_id}) + + +func create_standard_shift( + staff_id: StringName, + template_id: StringName, + start_day: int, + assigned_facility_id: StringName = &"", + shift_id: StringName = &"" +) -> CommandResult: + var effective_id := shift_id + if effective_id.is_empty(): + effective_id = StringName( + "shift_%08d_d%05d" % [_next_shift_sequence, maxi(start_day, 0)] + ) + var shift := ShiftInstanceType.create_standard( + effective_id, + staff_id, + template_id, + start_day, + assigned_facility_id + ) + if shift == null: + return _invalid("Only DAY and NIGHT are standard Shift templates.") + var result := register_shift(shift) + if result.is_success() and shift_id.is_empty(): + _next_shift_sequence += 1 + return result + + +func create_flex_shift( + staff_id: StringName, + start_day: int, + start_minute_of_day: int, + end_minute_of_day: int, + assigned_facility_id: StringName = &"", + shift_id: StringName = &"" +) -> CommandResult: + var effective_id := shift_id + if effective_id.is_empty(): + effective_id = StringName( + "shift_%08d_d%05d" % [_next_shift_sequence, maxi(start_day, 0)] + ) + var shift := ShiftInstanceType.new().configure( + effective_id, + staff_id, + &"FLEX", + start_day, + start_minute_of_day, + end_minute_of_day, + assigned_facility_id + ) + var result := register_shift(shift) + if result.is_success() and shift_id.is_empty(): + _next_shift_sequence += 1 + return result + + +func get_shift(shift_id: StringName) -> ShiftInstance: + return _shifts.get(shift_id) as ShiftInstance + + +func get_shifts() -> Array[ShiftInstance]: + var result: Array[ShiftInstance] = [] + for shift_id: StringName in _sorted_keys(_shifts): + result.append(_shifts[shift_id]) + return result + + +func set_schedule_override( + staff_id: StringName, + start_day: int, + patch: Dictionary +) -> CommandResult: + if not _staff.has(staff_id): + return _not_found("Staff", staff_id) + if start_day < 0 or patch.is_empty(): + return _invalid("ScheduleOverride requires a day and a non-empty patch.") + var key := _override_key(staff_id, start_day) + _schedule_overrides[key] = { + &"staff_id": staff_id, + &"start_day": start_day, + &"patch": patch.duplicate(true), + } + last_error = "" + return _success({&"staff_id": staff_id, &"start_day": start_day}) + + +func get_schedule_override(staff_id: StringName, start_day: int) -> Dictionary: + var value: Variant = _schedule_overrides.get(_override_key(staff_id, start_day), {}) + return value.duplicate(true) if value is Dictionary else {} + + +func register_task(value: Variant) -> CommandResult: + if value == null or not value is StaffTask: + return _invalid("Task is missing or has an invalid type.") + var task: StaffTask = value + if not task.is_valid() or not task.is_dispatchable(): + return _invalid("A new Task must be a valid OPEN Task.") + if _tasks.has(task.task_id): + return _duplicate("Task", task.task_id) + _tasks[task.task_id] = task + task_registered.emit(task) + last_error = "" + return _success({&"task_id": task.task_id}) + + +func create_task( + task_type: StringName, + priority: int, + created_at: int, + options: Dictionary = {}, + task_id: StringName = &"" +) -> CommandResult: + var effective_id := task_id + if effective_id.is_empty(): + effective_id = StringName("task_%08d" % _next_task_sequence) + var task := StaffTaskType.new().configure( + effective_id, + task_type, + priority, + created_at, + options + ) + var result := register_task(task) + if result.is_success() and task_id.is_empty(): + _next_task_sequence += 1 + return result + + +func get_task(task_id: StringName) -> StaffTask: + return _tasks.get(task_id) as StaffTask + + +func get_tasks() -> Array[StaffTask]: + var result: Array[StaffTask] = [] + for task_id: StringName in _sorted_keys(_tasks): + result.append(_tasks[task_id]) + return result + + +func dispatch_now(absolute_minute: int = -1) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + last_dispatch_report = _dispatcher.dispatch(get_tasks(), _staff, minute) + for assignment: Dictionary in last_dispatch_report.get(&"assigned", []): + task_assigned.emit(assignment[&"task_id"], assignment[&"staff_id"]) + for interruption: Dictionary in last_dispatch_report.get(&"interruptions", []): + task_reopened.emit(interruption[&"interrupted_task_id"], &"INTERRUPTED") + last_error = "" + return last_dispatch_report.duplicate(true) + + +func advance_one_minute( + absolute_minute: int = -1, + force_dispatch := false +) -> Dictionary: + if absolute_minute < 0: + absolute_minute = current_minute + 1 + if absolute_minute < current_minute: + return {&"ok": false, &"reason": &"TIME_REGRESSION"} + if absolute_minute <= _last_advanced_minute: + return {&"ok": false, &"reason": &"MINUTE_ALREADY_APPLIED"} + current_minute = absolute_minute + var repairs := audit_orphan_tasks(current_minute) + _update_shift_states(current_minute) + var task_actions := _advance_assigned_tasks(current_minute) + var high_priority := _has_high_priority_open_task() + var dispatched := false + if force_dispatch or _dispatcher.should_dispatch(current_minute, high_priority): + dispatch_now(current_minute) + dispatched = true + _last_advanced_minute = current_minute + return { + &"ok": true, + &"minute": current_minute, + &"repairs": repairs, + &"task_actions": task_actions, + &"dispatched": dispatched, + &"dispatch_report": last_dispatch_report.duplicate(true) if dispatched else {}, + } + + +func begin_task(task_id: StringName, absolute_minute: int = -1) -> CommandResult: + var task := get_task(task_id) + if task == null: + return _not_found("Task", task_id) + var staff := get_staff(task.assigned_staff_id) + if staff == null or staff.current_task_id != task_id: + return _conflict("Task and Staff reservation do not match.") + var minute := current_minute if absolute_minute < 0 else absolute_minute + if task.state not in [StaffTaskType.State.RESERVED, StaffTaskType.State.MOVING]: + return _conflict("Task cannot begin from its current state.") + if staff.state not in [StaffRuntimeType.State.MOVING_TO_TASK, StaffRuntimeType.State.AVAILABLE]: + return _conflict("Staff cannot begin the reserved Task from its current state.") + # Both state preconditions are checked before either side is mutated. + var task_state_before: StaffTask.State = task.state + var task_changed_at_before := task.state_changed_at + if not task.begin_working(minute): + return _conflict("Task cannot begin from its current state.") + if not staff.begin_task(minute): + # Defensive rollback; this branch should be unreachable after the preflight. + task.state = task_state_before + task.state_changed_at = task_changed_at_before + return _conflict("Task cannot begin from its current state.") + last_error = "" + return _success({&"task_id": task_id, &"staff_id": staff.staff_id}) + + +func progress_task( + task_id: StringName, + minutes: int = 1, + absolute_minute: int = -1 +) -> CommandResult: + var task := get_task(task_id) + if task == null: + return _not_found("Task", task_id) + var staff := get_staff(task.assigned_staff_id) + if staff == null or staff.current_task_id != task_id: + return _conflict("Task and Staff reservation do not match.") + var minute := current_minute if absolute_minute < 0 else absolute_minute + if task.state != StaffTaskType.State.WORKING: + return _conflict("Only a WORKING Task can progress.") + if minutes <= 0: + return _invalid("Task progress minutes must be positive.") + if minute <= task.state_changed_at: + return _conflict("Task progress was already applied at this minute.") + var progress_before := task.progress_minutes + var changed_at_before := task.state_changed_at + var completed := task.advance_work(minutes, minute) + if completed: + var completed_by := staff.staff_id + var ids := task.reservation_ids.duplicate() + if not _release_reservations(ids, task, minute): + task.progress_minutes = progress_before + task.state_changed_at = changed_at_before + return _conflict("Completed Task inventory Reservations could not be released.") + if not task.complete(minute): + task.progress_minutes = progress_before + task.state_changed_at = changed_at_before + return _conflict("Task reached its duration but failed to complete.") + staff.release_task(minute, &"TASK_COMPLETED") + task_completed.emit(task_id, completed_by) + return _success({ + &"task_id": task_id, + &"completed": completed, + &"progress_minutes": task.progress_minutes, + }) + + +## Advances only work that existed before this minute. Dispatch runs after this +## pass, so a newly reserved Task cannot begin and consume work in the same tick. +## Runtime path travel can move MOVING Tasks independently; once their actor is +## at target, this service begins them on the next deterministic minute. +func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]: + var actions: Array[Dictionary] = [] + for task_id: StringName in _sorted_keys(_tasks): + var task: StaffTask = _tasks[task_id] + if task.assigned_staff_id.is_empty() or task.state_changed_at >= absolute_minute: + continue + var staff := get_staff(task.assigned_staff_id) + if staff == null or staff.current_task_id != task.task_id: + continue + match task.state: + StaffTaskType.State.RESERVED: + if staff.current_cell == task.target_cell: + var begin_result := begin_task(task.task_id, absolute_minute) + if begin_result.is_success(): + actions.append({ + &"task_id": task.task_id, + &"staff_id": staff.staff_id, + &"action": &"BEGAN_WORK", + }) + elif task.begin_moving(absolute_minute): + actions.append({ + &"task_id": task.task_id, + &"staff_id": staff.staff_id, + &"action": &"BEGAN_MOVING", + }) + StaffTaskType.State.MOVING: + if staff.current_cell == task.target_cell: + var begin_result := begin_task(task.task_id, absolute_minute) + if begin_result.is_success(): + actions.append({ + &"task_id": task.task_id, + &"staff_id": staff.staff_id, + &"action": &"BEGAN_WORK", + }) + StaffTaskType.State.WORKING: + var progress_result := progress_task(task.task_id, 1, absolute_minute) + if progress_result.is_success(): + actions.append({ + &"task_id": task.task_id, + &"staff_id": staff.staff_id, + &"action": ( + &"COMPLETED" + if bool(progress_result.details.get(&"completed", false)) + else &"PROGRESSED" + ), + &"progress_minutes": int(progress_result.details.get(&"progress_minutes", 0)), + }) + return actions + + +func cancel_task( + task_id: StringName, + reason: StringName = &"CANCELLED", + absolute_minute: int = -1 +) -> CommandResult: + var task := get_task(task_id) + if task == null: + return _not_found("Task", task_id) + if task.is_terminal(): + return _conflict("Terminal Task cannot be cancelled again.") + var minute := current_minute if absolute_minute < 0 else absolute_minute + var ids := task.reservation_ids.duplicate() + if not _release_reservations(ids, task, minute): + return _conflict("Task inventory Reservation could not be released.") + var staff := get_staff(task.assigned_staff_id) + if staff != null and staff.current_task_id == task_id: + staff.release_task(minute, reason) + task.cancel(reason, minute) + last_error = "" + return _success({&"task_id": task_id}) + + +func remove_staff( + staff_id: StringName, + reason: StringName = &"REMOVED", + preserve_unpaid_wages := true, + absolute_minute: int = -1 +) -> CommandResult: + var staff := get_staff(staff_id) + if staff == null: + return _not_found("Staff", staff_id) + var minute := current_minute if absolute_minute < 0 else absolute_minute + if not staff.current_task_id.is_empty(): + var task := get_task(staff.current_task_id) + if task != null: + var ids := task.reservation_ids.duplicate() + if not _release_reservations(ids, task, minute): + return _conflict("Staff removal could not release Task Reservations.") + task.reopen(&"STAFF_REMOVED", minute, task.preserve_progress_on_interrupt) + task_reopened.emit(task.task_id, &"STAFF_REMOVED") + if preserve_unpaid_wages and staff.unpaid_wages > 0: + _departed_payroll[staff_id] = int(_departed_payroll.get(staff_id, 0)) + staff.unpaid_wages + var removed_shift_ids: Array[StringName] = [] + for shift_id: StringName in _sorted_keys(_shifts): + var shift: ShiftInstance = _shifts[shift_id] + if shift.staff_id != staff_id: + continue + removed_shift_ids.append(shift_id) + _shifts.erase(shift_id) + var removed_override_days: Array[int] = [] + for override_id: StringName in _sorted_keys(_schedule_overrides): + var entry: Dictionary = _schedule_overrides[override_id] + if StringName(entry.get(&"staff_id", &"")) != staff_id: + continue + removed_override_days.append(int(entry.get(&"start_day", -1))) + _schedule_overrides.erase(override_id) + _staff.erase(staff_id) + staff_removed.emit(staff_id, reason) + last_error = "" + return _success({ + &"staff_id": staff_id, + &"unpaid_wages_preserved": int(_departed_payroll.get(staff_id, 0)), + &"removed_shift_ids": removed_shift_ids, + &"removed_override_days": removed_override_days, + }) + + +func retire_staff(staff_id: StringName, absolute_minute: int = -1) -> CommandResult: + var staff := get_staff(staff_id) + if staff == null: + return _not_found("Staff", staff_id) + staff.is_retiring = true + return remove_staff(staff_id, &"RETIRED", true, absolute_minute) + + +func get_departed_unpaid_wages(staff_id: StringName) -> int: + return int(_departed_payroll.get(staff_id, 0)) + + +func audit_orphan_tasks(absolute_minute: int = -1) -> Dictionary: + var minute := current_minute if absolute_minute < 0 else absolute_minute + var reopened: Array[StringName] = [] + var released_staff: Array[StringName] = [] + var release_failed: Array[StringName] = [] + for task_id: StringName in _sorted_keys(_tasks): + var task: StaffTask = _tasks[task_id] + if task.assigned_staff_id.is_empty(): + continue + var staff := get_staff(task.assigned_staff_id) + if staff != null and staff.current_task_id == task.task_id: + continue + var ids := task.reservation_ids.duplicate() + if not _release_reservations(ids, task, minute): + release_failed.append(task.task_id) + continue + task.reopen(&"ORPHAN_STAFF", minute, task.preserve_progress_on_interrupt) + reopened.append(task.task_id) + task_reopened.emit(task.task_id, &"ORPHAN_STAFF") + for staff_id: StringName in _sorted_keys(_staff): + var staff: StaffRuntime = _staff[staff_id] + if staff.current_task_id.is_empty(): + continue + var task := get_task(staff.current_task_id) + if task == null or task.assigned_staff_id != staff.staff_id: + if staff.release_task(minute, &"ORPHAN_TASK"): + released_staff.append(staff.staff_id) + return { + &"reopened_task_ids": reopened, + &"released_staff_ids": released_staff, + &"release_failed_task_ids": release_failed, + } + + +## Recovery-only repair used on an isolated candidate service. All orphan Task +## Reservation IDs are handed to the external releaser in one call, allowing +## the Inventory boundary to roll the entire release set back atomically. Staff +## and Task objects are mutated only after that one transaction succeeds. +func _audit_orphan_tasks_atomically(absolute_minute: int) -> Dictionary: + var orphan_tasks: Array[StaffTask] = [] + var orphan_staff: Array[StaffRuntime] = [] + var all_reservation_ids: Array[StringName] = [] + for task_id: StringName in _sorted_keys(_tasks): + var task: StaffTask = _tasks[task_id] + if task.assigned_staff_id.is_empty(): + continue + var assigned_staff := get_staff(task.assigned_staff_id) + if assigned_staff != null and assigned_staff.current_task_id == task.task_id: + continue + orphan_tasks.append(task) + for reservation_id: StringName in task.reservation_ids: + if not all_reservation_ids.has(reservation_id): + all_reservation_ids.append(reservation_id) + for staff_id: StringName in _sorted_keys(_staff): + var staff: StaffRuntime = _staff[staff_id] + if staff.current_task_id.is_empty(): + continue + var task := get_task(staff.current_task_id) + if task == null or task.assigned_staff_id != staff.staff_id: + orphan_staff.append(staff) + all_reservation_ids.sort_custom( + func(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + ) + if not all_reservation_ids.is_empty(): + if orphan_tasks.is_empty() or not _release_reservations( + all_reservation_ids, + orphan_tasks[0], + absolute_minute + ): + var failed_ids: Array[StringName] = [] + for task: StaffTask in orphan_tasks: + if not task.reservation_ids.is_empty(): + failed_ids.append(task.task_id) + return { + &"reopened_task_ids": [], + &"released_staff_ids": [], + &"release_failed_task_ids": failed_ids, + &"released_reservation_ids": [], + } + + var reopened: Array[StringName] = [] + for task: StaffTask in orphan_tasks: + if task.reopen(&"ORPHAN_STAFF", absolute_minute, task.preserve_progress_on_interrupt): + reopened.append(task.task_id) + var released_staff: Array[StringName] = [] + for staff: StaffRuntime in orphan_staff: + if staff.release_task(absolute_minute, &"ORPHAN_TASK"): + released_staff.append(staff.staff_id) + return { + &"reopened_task_ids": reopened, + &"released_staff_ids": released_staff, + &"release_failed_task_ids": [], + &"released_reservation_ids": all_reservation_ids, + } + + +func validate_invariants() -> Dictionary: + var core := _validate_candidate_invariants(_staff, _tasks, _shifts, _departed_payroll) + var payroll := _validate_payroll_history(_payroll_settlements) + var errors: Array[String] = [] + for error: String in core[&"errors"]: + errors.append(error) + for error: String in payroll[&"errors"]: + errors.append(error) + if _payroll_days != payroll.get(&"day_index", {}): + errors.append("Payroll day index does not match settlement history.") + return {&"ok": errors.is_empty(), &"errors": errors} + + +func create_snapshot() -> Dictionary: + var staff_snapshots: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(_staff): + staff_snapshots.append((_staff[staff_id] as StaffRuntime).create_snapshot()) + var shift_snapshots: Array[Dictionary] = [] + for shift_id: StringName in _sorted_keys(_shifts): + shift_snapshots.append((_shifts[shift_id] as ShiftInstance).create_snapshot()) + var task_snapshots: Array[Dictionary] = [] + for task_id: StringName in _sorted_keys(_tasks): + task_snapshots.append((_tasks[task_id] as StaffTask).create_snapshot()) + var override_snapshots: Array[Dictionary] = [] + for override_id: StringName in _sorted_keys(_schedule_overrides): + var entry: Dictionary = _schedule_overrides[override_id] + override_snapshots.append({ + &"staff_id": String(entry[&"staff_id"]), + &"start_day": int(entry[&"start_day"]), + &"patch": (entry[&"patch"] as Dictionary).duplicate(true), + }) + var departed_payroll: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(_departed_payroll): + departed_payroll.append({ + &"staff_id": String(staff_id), + &"unpaid_wages": int(_departed_payroll[staff_id]), + }) + var payroll_settlements: Array[Dictionary] = [] + var settlement_records: Array[Dictionary] = [] + for settlement_id: StringName in _sorted_keys(_payroll_settlements): + settlement_records.append(_payroll_settlements[settlement_id]) + settlement_records.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + if int(left[&"day"]) != int(right[&"day"]): + return int(left[&"day"]) < int(right[&"day"]) + return String(left[&"settlement_id"]) < String(right[&"settlement_id"]) + ) + for record: Dictionary in settlement_records: + payroll_settlements.append(_serialize_payroll_settlement(record)) + return { + &"version": SNAPSHOT_VERSION, + &"current_minute": current_minute, + &"last_advanced_minute": _last_advanced_minute, + &"next_shift_sequence": _next_shift_sequence, + &"next_task_sequence": _next_task_sequence, + &"staff": staff_snapshots, + &"shifts": shift_snapshots, + &"tasks": task_snapshots, + &"schedule_overrides": override_snapshots, + &"departed_payroll": departed_payroll, + &"payroll_settlements": payroll_settlements, + &"dispatcher": _dispatcher.create_snapshot(), + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + return _restore_snapshot_internal(snapshot, false) + + +func restore_snapshot_with_orphan_recovery(snapshot: Dictionary) -> bool: + # Parse and repair on an isolated service. No live Staff object, Dispatcher, + # or signal is touched until the candidate and its external Reservation release + # transaction have both completed and passed final invariants. + var candidate := StaffService.new() + candidate.configure_callbacks(_item_reserver, _item_releaser, _travel_estimator) + if not candidate._restore_snapshot_internal(snapshot, true): + last_error = candidate.last_error + last_recovery_repair_report = candidate.last_recovery_repair_report.duplicate(true) + return false + _staff = candidate._staff + _shifts = candidate._shifts + _tasks = candidate._tasks + _schedule_overrides = candidate._schedule_overrides + _departed_payroll = candidate._departed_payroll + _payroll_settlements = candidate._payroll_settlements + _payroll_days = candidate._payroll_days + current_minute = candidate.current_minute + _last_advanced_minute = candidate._last_advanced_minute + _next_shift_sequence = candidate._next_shift_sequence + _next_task_sequence = candidate._next_task_sequence + _dispatcher = candidate._dispatcher + _dispatcher.configure_callbacks(_item_reserver, _item_releaser, _travel_estimator) + last_dispatch_report = {} + last_recovery_repair_report = candidate.last_recovery_repair_report.duplicate(true) + last_error = "" + for task_id: StringName in last_recovery_repair_report.get(&"reopened_task_ids", []): + task_reopened.emit(task_id, &"ORPHAN_STAFF") + return true + + +func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool) -> bool: + last_error = "" + last_recovery_repair_report = { + &"automatic_repairs_applied": false, + &"reopened_task_ids": [], + &"released_staff_ids": [], + &"release_failed_task_ids": [], + } + var raw_version: Variant = snapshot.get(&"version", null) + if ( + typeof(raw_version) != TYPE_INT + or int(raw_version) not in [ + LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, SNAPSHOT_VERSION, + ] + ): + last_error = "Unsupported StaffService snapshot version." + return false + var snapshot_version := int(raw_version) + for key: StringName in [&"staff", &"shifts", &"tasks", &"schedule_overrides", &"departed_payroll"]: + if not snapshot.get(key, null) is Array: + last_error = "StaffService snapshot collection is malformed: %s" % key + return false + if ( + snapshot_version >= PAYROLL_SNAPSHOT_VERSION + and not snapshot.get(&"payroll_settlements", null) is Array + ): + last_error = "StaffService payroll settlement history is malformed." + return false + if ( + snapshot_version == LEGACY_SNAPSHOT_VERSION + and ( + snapshot.has(&"payroll_settlements") + or snapshot.has("payroll_settlements") + or snapshot.has(&"last_advanced_minute") + or snapshot.has("last_advanced_minute") + ) + ): + last_error = "Legacy StaffService snapshot contains fields from a later schema." + return false + if not snapshot.get(&"dispatcher", null) is Dictionary: + last_error = "StaffService dispatcher snapshot is malformed." + return false + + var next_staff: Dictionary = {} + for raw_staff: Variant in snapshot[&"staff"]: + if not raw_staff is Dictionary: + last_error = "Staff snapshot entry must be a Dictionary." + return false + var has_row_version: bool = ( + raw_staff.has(&"snapshot_version") or raw_staff.has("snapshot_version") + ) + var has_effective_minute: bool = ( + raw_staff.has(&"employment_effective_minute") + or raw_staff.has("employment_effective_minute") + ) + if snapshot_version == SNAPSHOT_VERSION: + if not has_row_version or not has_effective_minute: + last_error = "Current Staff snapshot entry is missing employment-time schema fields." + return false + elif has_row_version or has_effective_minute: + last_error = "Legacy Staff snapshot entry contains employment-time schema fields." + return false + var staff := StaffRuntimeType.from_snapshot(raw_staff) + if staff == null or next_staff.has(staff.staff_id): + last_error = "Staff snapshot entry is invalid or duplicated." + return false + next_staff[staff.staff_id] = staff + + var next_shifts: Dictionary = {} + for raw_shift: Variant in snapshot[&"shifts"]: + if not raw_shift is Dictionary: + last_error = "Shift snapshot entry must be a Dictionary." + return false + var shift := ShiftInstanceType.from_snapshot(raw_shift) + if shift == null or next_shifts.has(shift.shift_instance_id): + last_error = "Shift snapshot entry is invalid or duplicated." + return false + next_shifts[shift.shift_instance_id] = shift + + var next_tasks: Dictionary = {} + for raw_task: Variant in snapshot[&"tasks"]: + if not raw_task is Dictionary: + last_error = "Task snapshot entry must be a Dictionary." + return false + var task := StaffTaskType.from_snapshot(raw_task) + if task == null or next_tasks.has(task.task_id): + last_error = "Task snapshot entry is invalid or duplicated." + return false + next_tasks[task.task_id] = task + + var next_overrides: Dictionary = {} + for raw_override: Variant in snapshot[&"schedule_overrides"]: + if not raw_override is Dictionary or not raw_override.get(&"patch", null) is Dictionary: + last_error = "ScheduleOverride snapshot entry is malformed." + return false + var staff_id := StringName(raw_override.get(&"staff_id", "")) + var start_day := int(raw_override.get(&"start_day", -1)) + if staff_id.is_empty() or start_day < 0 or not next_staff.has(staff_id): + last_error = "ScheduleOverride references invalid Staff or day." + return false + var key := _override_key(staff_id, start_day) + if next_overrides.has(key): + last_error = "ScheduleOverride is duplicated." + return false + next_overrides[key] = { + &"staff_id": staff_id, + &"start_day": start_day, + &"patch": (raw_override[&"patch"] as Dictionary).duplicate(true), + } + + var next_payroll: Dictionary = {} + for raw_payroll: Variant in snapshot[&"departed_payroll"]: + if not raw_payroll is Dictionary: + last_error = "Departed payroll snapshot entry is malformed." + return false + var raw_staff_id: Variant = raw_payroll.get(&"staff_id", null) + if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + last_error = "Departed payroll Staff ID must be a string." + return false + var staff_id := StringName(raw_staff_id) + var raw_amount: Variant = raw_payroll.get(&"unpaid_wages", null) + if typeof(raw_amount) != TYPE_INT: + last_error = "Departed payroll amount must be a non-negative integer." + return false + var amount := int(raw_amount) + if staff_id.is_empty() or amount < 0 or next_payroll.has(staff_id): + last_error = "Departed payroll snapshot entry is invalid or duplicated." + return false + next_payroll[staff_id] = amount + + var next_settlements: Dictionary = {} + var next_payroll_days: Dictionary = {} + if snapshot_version >= PAYROLL_SNAPSHOT_VERSION: + var parsed_history := _parse_payroll_history_snapshot(snapshot[&"payroll_settlements"]) + if not bool(parsed_history.get(&"ok", false)): + last_error = String(parsed_history.get(&"message", "Payroll settlement history is invalid.")) + return false + next_settlements = parsed_history[&"settlements"] + next_payroll_days = parsed_history[&"day_index"] + + var invariant_report := _validate_candidate_invariants( + next_staff, next_tasks, next_shifts, next_payroll + ) + if not bool(invariant_report[&"ok"]): + if not recover_orphan_tasks: + last_error = "StaffService snapshot violates invariants: %s" % [str(invariant_report[&"errors"])] + return false + var recovery_scope := _validate_recovery_candidate_scope(next_staff, next_tasks, next_shifts) + if not bool(recovery_scope[&"ok"]): + last_error = "StaffService recovery contains non-repairable damage: %s" % [str(recovery_scope[&"errors"])] + return false + var next_dispatcher := TaskDispatcherType.new() + if not next_dispatcher.restore_snapshot(snapshot[&"dispatcher"]): + last_error = next_dispatcher.last_restore_error + return false + if ( + typeof(snapshot.get(&"current_minute", null)) != TYPE_INT + or typeof(snapshot.get(&"next_shift_sequence", null)) != TYPE_INT + or typeof(snapshot.get(&"next_task_sequence", null)) != TYPE_INT + ): + last_error = "StaffService snapshot counters must be integers." + return false + var next_current_minute := int(snapshot[&"current_minute"]) + var next_shift_sequence := int(snapshot[&"next_shift_sequence"]) + var next_task_sequence := int(snapshot[&"next_task_sequence"]) + if next_current_minute < 0 or next_shift_sequence <= 0 or next_task_sequence <= 0: + last_error = "StaffService snapshot counters are invalid." + return false + var next_last_advanced_minute := next_current_minute + if snapshot_version >= PAYROLL_SNAPSHOT_VERSION: + var raw_last_advanced: Variant = snapshot.get(&"last_advanced_minute", null) + if typeof(raw_last_advanced) != TYPE_INT: + last_error = "StaffService last advanced minute must be an integer." + return false + next_last_advanced_minute = int(raw_last_advanced) + if next_last_advanced_minute < -1 or next_last_advanced_minute > next_current_minute: + last_error = "StaffService last advanced minute is invalid." + return false + + _staff = next_staff + _shifts = next_shifts + _tasks = next_tasks + _schedule_overrides = next_overrides + _departed_payroll = next_payroll + _payroll_settlements = next_settlements + _payroll_days = next_payroll_days + current_minute = next_current_minute + _last_advanced_minute = next_last_advanced_minute + _next_shift_sequence = next_shift_sequence + _next_task_sequence = next_task_sequence + _dispatcher = next_dispatcher + _dispatcher.configure_callbacks(_item_reserver, _item_releaser, _travel_estimator) + if recover_orphan_tasks: + var repair_report := _audit_orphan_tasks_atomically(current_minute) + last_recovery_repair_report = repair_report.duplicate(true) + last_recovery_repair_report[&"automatic_repairs_applied"] = ( + not repair_report[&"reopened_task_ids"].is_empty() + or not repair_report[&"released_staff_ids"].is_empty() + ) + if not repair_report[&"release_failed_task_ids"].is_empty(): + last_error = "StaffService recovery could not release orphan Task reservations." + return false + var repaired_invariants := _validate_candidate_invariants( + _staff, _tasks, _shifts, _departed_payroll + ) + if not bool(repaired_invariants[&"ok"]): + last_error = "StaffService recovery left invalid state: %s" % [str(repaired_invariants[&"errors"])] + return false + last_dispatch_report = {} + last_error = "" + return true + + +func _update_shift_states(absolute_minute: int) -> void: + for staff_id: StringName in _sorted_keys(_staff): + var staff: StaffRuntime = _staff[staff_id] + var shift := get_shift(staff.shift_id) + if shift != null and shift.staff_id != staff.staff_id: + shift = null + staff.shift_id = &"" + if shift != null and shift.has_ended(absolute_minute): + var current_task := get_task(staff.current_task_id) + if current_task != null and current_task.interrupt_level == 0: + staff.is_overtime = true + shift.record_duty_minute(absolute_minute, true) + continue + if current_task != null: + var ids := current_task.reservation_ids.duplicate() + if not _release_reservations(ids, current_task, absolute_minute): + staff.is_overtime = true + shift.record_duty_minute(absolute_minute, true) + continue + current_task.reopen( + &"SHIFT_ENDED", + absolute_minute, + current_task.preserve_progress_on_interrupt + ) + task_reopened.emit(current_task.task_id, &"SHIFT_ENDED") + staff.current_task_id = &"" + staff.is_overtime = false + staff.shift_id = &"" + if staff.assigned_facility_id == shift.assigned_facility_id: + staff.assigned_facility_id = &"" + staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"SHIFT_ENDED", absolute_minute) + shift.close(absolute_minute) + shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) + shift = null + + # A large clock jump may skip an entire Shift. Close such records at their + # scheduled boundary so they cannot later be mistaken for pending work. + for skipped: ShiftInstance in _shifts_for_staff(staff.staff_id): + if skipped.actual_ended_at < 0 and skipped.has_ended(absolute_minute): + skipped.close(skipped.end_at) + + if shift == null: + shift = _find_relevant_shift(staff.staff_id, absolute_minute) + if shift != null and staff.can_take_shift(): + staff.shift_id = shift.shift_instance_id + else: + shift = null + if shift == null or not staff.can_take_shift(): + continue + if not shift.assigned_facility_id.is_empty(): + staff.assigned_facility_id = shift.assigned_facility_id + if shift.is_commute_window(absolute_minute): + if staff.current_task_id.is_empty() and staff.state == StaffRuntimeType.State.OFF_DUTY: + staff.transition_to(StaffRuntimeType.State.COMMUTING, &"SHIFT_COMMUTE", absolute_minute) + shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) + continue + if shift.contains_minute(absolute_minute): + staff.is_overtime = false + if staff.current_task_id.is_empty() and staff.state in [ + StaffRuntimeType.State.OFF_DUTY, + StaffRuntimeType.State.COMMUTING, + ]: + staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute) + shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) + shift.record_duty_minute(absolute_minute) + if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING: + shift.mark_late(absolute_minute, DEFAULT_LATE_REASON) + + +func _find_relevant_shift(staff_id: StringName, absolute_minute: int) -> ShiftInstance: + for shift: ShiftInstance in _shifts_for_staff(staff_id): + if shift.actual_ended_at >= 0: + continue + if shift.is_commute_window(absolute_minute) or shift.contains_minute(absolute_minute): + return shift + return null + + +func _shifts_for_staff(staff_id: StringName) -> Array[ShiftInstance]: + var result: Array[ShiftInstance] = [] + for shift_id: StringName in _sorted_keys(_shifts): + var shift: ShiftInstance = _shifts[shift_id] + if shift.staff_id == staff_id: + result.append(shift) + result.sort_custom( + func(a: ShiftInstance, b: ShiftInstance) -> bool: + if a.start_at != b.start_at: + return a.start_at < b.start_at + return String(a.shift_instance_id) < String(b.shift_instance_id) + ) + return result + + +static func _shifts_overlap(a: ShiftInstance, b: ShiftInstance) -> bool: + if a.cancelled or b.cancelled: + return false + return a.start_at < b.end_at and b.start_at < a.end_at + + +func _apply_override_to_shift(shift: ShiftInstance) -> void: + var entry := get_schedule_override(shift.staff_id, shift.start_day) + if entry.is_empty(): + return + var patch: Dictionary = entry[&"patch"] + var start_minute := int(patch.get(&"start_minute_of_day", shift.start_minute_of_day)) + var end_minute := int(patch.get(&"end_minute_of_day", shift.end_minute_of_day)) + shift.configure( + shift.shift_instance_id, + shift.staff_id, + shift.template_id, + shift.start_day, + start_minute, + end_minute, + StringName(patch.get(&"assigned_facility_id", shift.assigned_facility_id)), + {&"cancelled": patch.get(&"cancelled", shift.cancelled)} + ) + + +func _release_reservations(ids: Variant, task: StaffTask, absolute_minute: int) -> bool: + var reservation_ids: Array[StringName] = [] + if ids is Array: + for value: Variant in ids: + reservation_ids.append(StringName(value)) + if reservation_ids.is_empty(): + return true + if not _item_releaser.is_valid(): + return false + var response: Variant = _item_releaser.call(reservation_ids, task, absolute_minute) + if response is Dictionary: + return bool(response.get(&"ok", false)) + return bool(response) + + +func _has_high_priority_open_task() -> bool: + for task_id: StringName in _sorted_keys(_tasks): + var task: StaffTask = _tasks[task_id] + if task.is_dispatchable() and task.priority >= HIGH_PRIORITY_THRESHOLD: + return true + return false + + +func _build_payroll_preview(day: int) -> Dictionary: + var rows_by_staff: Dictionary = {} + var new_wages_total := 0 + var prior_arrears_total := 0 + var total_due := 0 + var period_end := day * MINUTES_PER_DAY + if _staff.size() + _departed_payroll.size() > MAX_PAYROLL_ROWS: + return _payroll_preview_error(&"ROW_BUDGET_EXCEEDED", "Payroll row budget is exceeded.") + for staff_id: StringName in _sorted_keys(_staff): + var staff: StaffRuntime = _staff[staff_id] + var prior := staff.unpaid_wages + if staff.employment_effective_minute < 0: + return _payroll_preview_error( + &"INVALID_EMPLOYMENT_EFFECTIVE_MINUTE", + "Staff employment effective minute is invalid." + ) + # Daily close covers [0, day * 1440). A hire exactly on the end + # boundary belongs to the next close; carried debt remains due now. + var newly_accrued := ( + staff.daily_wage if staff.employment_effective_minute < period_end else 0 + ) + if ( + prior < 0 + or newly_accrued < 0 + or prior > MAX_PAYROLL_AMOUNT - newly_accrued + ): + return _payroll_preview_error(&"AMOUNT_OUT_OF_RANGE", "Active payroll amount is out of range.") + var due := prior + newly_accrued + if ( + new_wages_total > MAX_PAYROLL_AMOUNT - newly_accrued + or prior_arrears_total > MAX_PAYROLL_AMOUNT - prior + or total_due > MAX_PAYROLL_AMOUNT - due + ): + return _payroll_preview_error(&"TOTAL_OUT_OF_RANGE", "Payroll total is out of range.") + rows_by_staff[staff_id] = { + &"staff_id": staff_id, + &"payroll_status": &"ACTIVE", + &"employment_kind": staff.get_employment_kind_name(), + &"daily_wage": newly_accrued, + &"prior_arrears": prior, + &"new_wages": newly_accrued, + &"total_due": due, + } + new_wages_total += newly_accrued + prior_arrears_total += prior + total_due += due + for staff_id: StringName in _sorted_keys(_departed_payroll): + var prior := int(_departed_payroll[staff_id]) + if prior <= 0: + continue + if rows_by_staff.has(staff_id): + return _payroll_preview_error(&"STAFF_ID_COLLISION", "Active and departed payroll IDs collide.") + if prior > MAX_PAYROLL_AMOUNT: + return _payroll_preview_error(&"AMOUNT_OUT_OF_RANGE", "Departed payroll amount is out of range.") + if prior_arrears_total > MAX_PAYROLL_AMOUNT - prior or total_due > MAX_PAYROLL_AMOUNT - prior: + return _payroll_preview_error(&"TOTAL_OUT_OF_RANGE", "Payroll total is out of range.") + rows_by_staff[staff_id] = { + &"staff_id": staff_id, + &"payroll_status": &"DEPARTED", + &"employment_kind": &"", + &"daily_wage": 0, + &"prior_arrears": prior, + &"new_wages": 0, + &"total_due": prior, + } + prior_arrears_total += prior + total_due += prior + var rows: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(rows_by_staff): + rows.append((rows_by_staff[staff_id] as Dictionary).duplicate(true)) + return { + &"ok": true, + &"day": day, + &"rows": rows, + &"new_wages_total": new_wages_total, + &"prior_arrears_total": prior_arrears_total, + &"total_due": total_due, + } + + +func _payroll_preview_error( + reason: StringName, + message: String, + details: Dictionary = {} +) -> Dictionary: + var result := { + &"ok": false, + &"reason": reason, + &"message": message, + &"rows": [], + &"new_wages_total": 0, + &"prior_arrears_total": 0, + &"total_due": 0, + } + for key: Variant in details: + result[key] = details[key] + return result + + +static func _stable_settlement_id_is_valid(value: Variant) -> bool: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var text := String(value) + if text.is_empty() or text.length() > 128: + return false + for index: int in range(text.length()): + var code := text.unicode_at(index) + var allowed := ( + code >= 48 and code <= 57 + or code >= 65 and code <= 90 + or code >= 97 and code <= 122 + or code in [45, 46, 58, 64, 95] + ) + if not allowed: + return false + return true + + +static func _parse_payroll_payments(source: Dictionary) -> Dictionary: + if source.size() > MAX_PAYROLL_ROWS: + return {&"ok": false, &"message": "Payroll payment row budget is exceeded."} + var payments_by_id: Dictionary = {} + for raw_staff_id: Variant in source: + if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Payroll Staff IDs must be strings."} + var staff_id := StringName(raw_staff_id) + var raw_amount: Variant = source[raw_staff_id] + if staff_id.is_empty() or payments_by_id.has(staff_id): + return {&"ok": false, &"message": "Payroll Staff ID is empty or duplicated."} + if typeof(raw_amount) != TYPE_INT or int(raw_amount) < 0 or int(raw_amount) > MAX_PAYROLL_AMOUNT: + return {&"ok": false, &"message": "Payroll payment must be a bounded non-negative integer."} + payments_by_id[staff_id] = int(raw_amount) + var payments: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(payments_by_id): + payments.append({&"staff_id": staff_id, &"amount": int(payments_by_id[staff_id])}) + return {&"ok": true, &"payments": payments} + + +static func _payroll_fingerprint(day: int, payments: Array[Dictionary]) -> String: + var canonical := "day=%d|" % day + for payment: Dictionary in payments: + var staff_text := String(payment[&"staff_id"]) + canonical += "%d:%s=%d;" % [staff_text.length(), staff_text, int(payment[&"amount"])] + return canonical.sha256_text() + + +static func _serialize_payroll_settlement(record: Dictionary) -> Dictionary: + var payments: Array[Dictionary] = [] + for payment: Dictionary in record[&"input_payments"]: + payments.append({ + &"staff_id": String(payment[&"staff_id"]), + &"amount": int(payment[&"amount"]), + }) + var rows: Array[Dictionary] = [] + for source_row: Dictionary in record[&"rows"]: + var row := source_row.duplicate(true) + row[&"staff_id"] = String(source_row[&"staff_id"]) + row[&"payroll_status"] = String(source_row[&"payroll_status"]) + row[&"employment_kind"] = String(source_row[&"employment_kind"]) + rows.append(row) + return { + &"settlement_id": String(record[&"settlement_id"]), + &"day": int(record[&"day"]), + &"fingerprint": String(record[&"fingerprint"]), + &"input_payments": payments, + &"rows": rows, + &"new_wages_total": int(record[&"new_wages_total"]), + &"prior_arrears_total": int(record[&"prior_arrears_total"]), + &"total_due": int(record[&"total_due"]), + &"paid_total": int(record[&"paid_total"]), + &"unpaid_after_total": int(record[&"unpaid_after_total"]), + } + + +static func _parse_payroll_history_snapshot(source: Array) -> Dictionary: + if source.size() > MAX_PAYROLL_SETTLEMENTS: + return {&"ok": false, &"message": "Payroll settlement history budget is exceeded."} + var settlements: Dictionary = {} + var day_index: Dictionary = {} + var previous_day := 0 + for raw_record: Variant in source: + if not raw_record is Dictionary: + return {&"ok": false, &"message": "Payroll settlement entry must be a Dictionary."} + var record: Dictionary = raw_record + if not _dictionary_has_exact_keys(record, [ + &"settlement_id", &"day", &"fingerprint", &"input_payments", &"rows", + &"new_wages_total", &"prior_arrears_total", &"total_due", + &"paid_total", &"unpaid_after_total", + ]): + return {&"ok": false, &"message": "Payroll settlement fields are malformed."} + if not _stable_settlement_id_is_valid(record[&"settlement_id"]): + return {&"ok": false, &"message": "Payroll settlement ID is invalid."} + if typeof(record[&"day"]) != TYPE_INT: + return {&"ok": false, &"message": "Payroll settlement day must be an integer."} + var settlement_id := StringName(record[&"settlement_id"]) + var day := int(record[&"day"]) + if day <= 0 or day > MAX_PAYROLL_DAY or day <= previous_day: + return {&"ok": false, &"message": "Payroll settlement days must be unique and strictly ordered."} + if settlements.has(settlement_id) or day_index.has(day): + return {&"ok": false, &"message": "Payroll settlement ID or day is duplicated."} + if not record[&"input_payments"] is Array or not record[&"rows"] is Array: + return {&"ok": false, &"message": "Payroll settlement collections are malformed."} + if record[&"input_payments"].size() > MAX_PAYROLL_ROWS or record[&"rows"].size() > MAX_PAYROLL_ROWS: + return {&"ok": false, &"message": "Payroll settlement row budget is exceeded."} + + var input_payments: Array[Dictionary] = [] + var payment_index: Dictionary = {} + var previous_payment_id := "" + for raw_payment: Variant in record[&"input_payments"]: + if not raw_payment is Dictionary or not _dictionary_has_exact_keys(raw_payment, [&"staff_id", &"amount"]): + return {&"ok": false, &"message": "Payroll input payment row is malformed."} + if typeof(raw_payment[&"staff_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Payroll input payment Staff ID is malformed."} + var staff_id := StringName(raw_payment[&"staff_id"]) + var staff_text := String(staff_id) + var raw_amount: Variant = raw_payment[&"amount"] + if ( + staff_id.is_empty() + or (not previous_payment_id.is_empty() and staff_text <= previous_payment_id) + or typeof(raw_amount) != TYPE_INT + or int(raw_amount) < 0 + or int(raw_amount) > MAX_PAYROLL_AMOUNT + ): + return {&"ok": false, &"message": "Payroll input payments are invalid or unordered."} + input_payments.append({&"staff_id": staff_id, &"amount": int(raw_amount)}) + payment_index[staff_id] = int(raw_amount) + previous_payment_id = staff_text + + var rows: Array[Dictionary] = [] + var row_ids: Dictionary = {} + var previous_row_id := "" + var derived_new := 0 + var derived_prior := 0 + var derived_due := 0 + var derived_paid := 0 + var derived_unpaid := 0 + for raw_row: Variant in record[&"rows"]: + if not raw_row is Dictionary or not _dictionary_has_exact_keys(raw_row, [ + &"staff_id", &"payroll_status", &"employment_kind", &"daily_wage", + &"prior_arrears", &"new_wages", &"total_due", &"payment", &"unpaid_after", + ]): + return {&"ok": false, &"message": "Payroll settlement result row is malformed."} + if typeof(raw_row[&"staff_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Payroll settlement row Staff ID is malformed."} + var staff_id := StringName(raw_row[&"staff_id"]) + var staff_text := String(staff_id) + if staff_id.is_empty() or (not previous_row_id.is_empty() and staff_text <= previous_row_id): + return {&"ok": false, &"message": "Payroll settlement rows are duplicated or unordered."} + var status_value: Variant = raw_row[&"payroll_status"] + var kind_value: Variant = raw_row[&"employment_kind"] + if typeof(status_value) not in [TYPE_STRING, TYPE_STRING_NAME] or typeof(kind_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Payroll settlement row labels are malformed."} + var status := StringName(status_value) + var employment_kind := StringName(kind_value) + if status not in [&"ACTIVE", &"DEPARTED"]: + return {&"ok": false, &"message": "Payroll settlement row status is invalid."} + if status == &"ACTIVE" and StaffRuntimeType.EmploymentKind.keys().find(String(employment_kind)) < 0: + return {&"ok": false, &"message": "Active payroll employment kind is invalid."} + if status == &"DEPARTED" and not employment_kind.is_empty(): + return {&"ok": false, &"message": "Departed payroll employment kind must be empty."} + var numeric_keys: Array[StringName] = [ + &"daily_wage", &"prior_arrears", &"new_wages", &"total_due", &"payment", &"unpaid_after", + ] + for numeric_key: StringName in numeric_keys: + var raw_number: Variant = raw_row[numeric_key] + if typeof(raw_number) != TYPE_INT or int(raw_number) < 0 or int(raw_number) > MAX_PAYROLL_AMOUNT: + return {&"ok": false, &"message": "Payroll settlement row amount is invalid."} + var daily_wage := int(raw_row[&"daily_wage"]) + var prior := int(raw_row[&"prior_arrears"]) + var newly_accrued := int(raw_row[&"new_wages"]) + var due := int(raw_row[&"total_due"]) + var payment := int(raw_row[&"payment"]) + var unpaid_after := int(raw_row[&"unpaid_after"]) + if ( + due != prior + newly_accrued + or payment > due + or unpaid_after != due - payment + or (status == &"ACTIVE" and newly_accrued != daily_wage) + or (status == &"DEPARTED" and (daily_wage != 0 or newly_accrued != 0)) + or payment != int(payment_index.get(staff_id, 0)) + ): + return {&"ok": false, &"message": "Payroll settlement row derivation is invalid."} + var row: Dictionary = (raw_row as Dictionary).duplicate(true) + row[&"staff_id"] = staff_id + row[&"payroll_status"] = status + row[&"employment_kind"] = employment_kind + rows.append(row) + row_ids[staff_id] = true + previous_row_id = staff_text + derived_new += newly_accrued + derived_prior += prior + derived_due += due + derived_paid += payment + derived_unpaid += unpaid_after + if ( + derived_new > MAX_PAYROLL_AMOUNT + or derived_prior > MAX_PAYROLL_AMOUNT + or derived_due > MAX_PAYROLL_AMOUNT + or derived_paid > MAX_PAYROLL_AMOUNT + or derived_unpaid > MAX_PAYROLL_AMOUNT + ): + return {&"ok": false, &"message": "Payroll settlement derived total is out of range."} + for payment: Dictionary in input_payments: + if not row_ids.has(payment[&"staff_id"]): + return {&"ok": false, &"message": "Payroll input payment references an unknown result row."} + var totals := { + &"new_wages_total": derived_new, + &"prior_arrears_total": derived_prior, + &"total_due": derived_due, + &"paid_total": derived_paid, + &"unpaid_after_total": derived_unpaid, + } + for total_key: StringName in totals: + if typeof(record[total_key]) != TYPE_INT or int(record[total_key]) != int(totals[total_key]): + return {&"ok": false, &"message": "Payroll settlement total does not match its rows."} + var expected_fingerprint := _payroll_fingerprint(day, input_payments) + if typeof(record[&"fingerprint"]) != TYPE_STRING or String(record[&"fingerprint"]) != expected_fingerprint: + return {&"ok": false, &"message": "Payroll settlement fingerprint is invalid."} + var parsed := { + &"settlement_id": settlement_id, + &"day": day, + &"fingerprint": expected_fingerprint, + &"input_payments": input_payments, + &"rows": rows, + &"new_wages_total": derived_new, + &"prior_arrears_total": derived_prior, + &"total_due": derived_due, + &"paid_total": derived_paid, + &"unpaid_after_total": derived_unpaid, + } + settlements[settlement_id] = parsed + day_index[day] = settlement_id + previous_day = day + return {&"ok": true, &"settlements": settlements, &"day_index": day_index} + + +static func _validate_payroll_history(settlements: Dictionary) -> Dictionary: + var records: Array[Dictionary] = [] + for settlement_id: StringName in _sorted_keys(settlements): + var value: Variant = settlements[settlement_id] + if not value is Dictionary: + return {&"ok": false, &"errors": ["Payroll settlement record is malformed."], &"day_index": {}} + records.append(value) + records.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + if int(left.get(&"day", -1)) != int(right.get(&"day", -1)): + return int(left.get(&"day", -1)) < int(right.get(&"day", -1)) + return String(left.get(&"settlement_id", "")) < String(right.get(&"settlement_id", "")) + ) + var parsed := _parse_payroll_history_snapshot(records) + if not bool(parsed.get(&"ok", false)): + return { + &"ok": false, + &"errors": [String(parsed.get(&"message", "Payroll settlement history is invalid."))], + &"day_index": {}, + } + return {&"ok": true, &"errors": [], &"day_index": parsed[&"day_index"]} + + +static func _dictionary_has_exact_keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + for key: Variant in expected: + if not source.has(key): + return false + return true + + +static func _validate_candidate_invariants( + staff_by_id: Dictionary, + tasks_by_id: Dictionary, + shifts_by_id: Dictionary, + departed_payroll: Dictionary = {} +) -> Dictionary: + var errors: Array[String] = [] + var reservation_owners: Dictionary = {} + for task_id: StringName in _sorted_keys(tasks_by_id): + var task: StaffTask = tasks_by_id[task_id] + if not task.is_valid(): + errors.append("Task %s is invalid." % task_id) + continue + for reservation_id: StringName in task.reservation_ids: + if reservation_owners.has(reservation_id): + errors.append( + "Reservation %s is claimed by Tasks %s and %s." + % [reservation_id, reservation_owners[reservation_id], task_id] + ) + else: + reservation_owners[reservation_id] = task_id + if task.assigned_staff_id.is_empty(): + continue + var staff: StaffRuntime = staff_by_id.get(task.assigned_staff_id) as StaffRuntime + if staff == null: + errors.append("Task %s references missing Staff." % task_id) + elif staff.current_task_id != task.task_id: + errors.append("Task %s and Staff %s do not reference each other." % [task_id, staff.staff_id]) + for staff_id: StringName in _sorted_keys(staff_by_id): + var staff: StaffRuntime = staff_by_id[staff_id] + if not staff.is_valid(): + errors.append("Staff %s is invalid." % staff_id) + elif staff.daily_wage > MAX_PAYROLL_AMOUNT or staff.unpaid_wages > MAX_PAYROLL_AMOUNT: + errors.append("Staff %s payroll amount exceeds the service budget." % staff_id) + if not staff.current_task_id.is_empty(): + var task: StaffTask = tasks_by_id.get(staff.current_task_id) as StaffTask + if task == null or task.assigned_staff_id != staff.staff_id: + errors.append("Staff %s references an orphan Task." % staff_id) + if not staff.shift_id.is_empty(): + var shift: ShiftInstance = shifts_by_id.get(staff.shift_id) as ShiftInstance + if shift == null or shift.staff_id != staff.staff_id: + errors.append("Staff %s references an invalid Shift." % staff_id) + for shift_id: StringName in _sorted_keys(shifts_by_id): + var shift: ShiftInstance = shifts_by_id[shift_id] + if not shift.is_valid(): + errors.append("Shift %s is invalid." % shift_id) + elif not staff_by_id.has(shift.staff_id): + errors.append("Shift %s references missing Staff." % shift_id) + for departed_staff_id: StringName in _sorted_keys(departed_payroll): + var raw_amount: Variant = departed_payroll[departed_staff_id] + if ( + departed_staff_id.is_empty() + or typeof(raw_amount) != TYPE_INT + or int(raw_amount) < 0 + or int(raw_amount) > MAX_PAYROLL_AMOUNT + ): + errors.append("Departed payroll %s is invalid." % departed_staff_id) + if staff_by_id.has(departed_staff_id): + errors.append("Staff %s exists in both active and departed payroll." % departed_staff_id) + var shift_ids := _sorted_keys(shifts_by_id) + for left_index: int in range(shift_ids.size()): + var left: ShiftInstance = shifts_by_id[shift_ids[left_index]] + for right_index: int in range(left_index + 1, shift_ids.size()): + var right: ShiftInstance = shifts_by_id[shift_ids[right_index]] + if left.staff_id == right.staff_id and _shifts_overlap(left, right): + errors.append( + "Shifts %s and %s overlap for Staff %s." + % [left.shift_instance_id, right.shift_instance_id, left.staff_id] + ) + return {&"ok": errors.is_empty(), &"errors": errors} + + +## Only bidirectional Staff/Task orphan links are recoverable. Invalid objects, +## Shift ownership, missing Shift staff, and overlaps remain hard corruption and +## are rejected before any external Reservation release is attempted. +static func _validate_recovery_candidate_scope( + staff_by_id: Dictionary, + tasks_by_id: Dictionary, + shifts_by_id: Dictionary +) -> Dictionary: + var errors: Array[String] = [] + var reservation_owners: Dictionary = {} + for task_id: StringName in _sorted_keys(tasks_by_id): + var task: StaffTask = tasks_by_id[task_id] + if not task.is_valid(): + errors.append("Task %s is invalid." % task_id) + continue + for reservation_id: StringName in task.reservation_ids: + if reservation_owners.has(reservation_id): + errors.append( + "Reservation %s is claimed by Tasks %s and %s." + % [reservation_id, reservation_owners[reservation_id], task_id] + ) + else: + reservation_owners[reservation_id] = task_id + for staff_id: StringName in _sorted_keys(staff_by_id): + var staff: StaffRuntime = staff_by_id[staff_id] + if not staff.is_valid(): + errors.append("Staff %s is invalid." % staff_id) + if not staff.shift_id.is_empty(): + var shift: ShiftInstance = shifts_by_id.get(staff.shift_id) as ShiftInstance + if shift == null or shift.staff_id != staff.staff_id: + errors.append("Staff %s references an invalid Shift." % staff_id) + for shift_id: StringName in _sorted_keys(shifts_by_id): + var shift: ShiftInstance = shifts_by_id[shift_id] + if not shift.is_valid(): + errors.append("Shift %s is invalid." % shift_id) + elif not staff_by_id.has(shift.staff_id): + errors.append("Shift %s references missing Staff." % shift_id) + var shift_ids := _sorted_keys(shifts_by_id) + for left_index: int in range(shift_ids.size()): + var left: ShiftInstance = shifts_by_id[shift_ids[left_index]] + for right_index: int in range(left_index + 1, shift_ids.size()): + var right: ShiftInstance = shifts_by_id[shift_ids[right_index]] + if left.staff_id == right.staff_id and _shifts_overlap(left, right): + errors.append( + "Shifts %s and %s overlap for Staff %s." + % [left.shift_instance_id, right.shift_instance_id, left.staff_id] + ) + return {&"ok": errors.is_empty(), &"errors": errors} + + +static func _override_key(staff_id: StringName, start_day: int) -> StringName: + return StringName("%s@%d" % [staff_id, start_day]) + + +static func _sorted_keys(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for value: Variant in source.keys(): + result.append(StringName(value)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +func _success(details: Dictionary = {}) -> CommandResult: + last_error = "" + return CommandResultType.succeeded(0, details) + + +func _invalid(message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed(CommandResultType.Code.VALIDATION_FAILED, 0, message, details) + + +func _not_found(label: String, entity_id: StringName) -> CommandResult: + var message := "%s was not found: %s" % [label, entity_id] + last_error = message + return CommandResultType.failed( + CommandResultType.Code.NOT_FOUND, + 0, + message, + {&"entity_id": entity_id} + ) + + +func _duplicate(label: String, entity_id: StringName) -> CommandResult: + var message := "%s ID already exists: %s" % [label, entity_id] + last_error = message + return CommandResultType.failed( + CommandResultType.Code.DUPLICATE_COMMAND_ID, + 0, + message, + {&"entity_id": entity_id} + ) + + +func _conflict(message: String) -> CommandResult: + last_error = message + return CommandResultType.failed(CommandResultType.Code.TRANSACTION_APPLY_FAILED, 0, message) diff --git a/scripts/staff/staff_service.gd.uid b/scripts/staff/staff_service.gd.uid new file mode 100644 index 0000000..c325a67 --- /dev/null +++ b/scripts/staff/staff_service.gd.uid @@ -0,0 +1 @@ +uid://sox5srfohstf diff --git a/scripts/staff/staff_task.gd b/scripts/staff/staff_task.gd new file mode 100644 index 0000000..7a5434b --- /dev/null +++ b/scripts/staff/staff_task.gd @@ -0,0 +1,361 @@ +class_name StaffTask +extends RefCounted + +enum State { + OPEN, + RESERVED, + MOVING, + WORKING, + COMPLETED, + BLOCKED, + CANCELLED, + FAILED, +} + +const MAX_PRIORITY := 100 +const MAX_INTERRUPT_LEVEL := 3 + +var task_id: StringName = &"" +var task_type: StringName = &"" +var priority := 0 +var created_at := 0 +var deadline_at := -1 +var target_entity_id: StringName = &"" +var target_cell := Vector3i.ZERO +var required_role_tags: Array[StringName] = [] +var required_skill_min: Dictionary = {} +var required_items: Dictionary = {} +var estimated_minutes := 1 +var interrupt_level := 3 +var state: State = State.OPEN +var assigned_staff_id: StringName = &"" +var retry_count := 0 + +var task_tags: Array[StringName] = [] +var reservation_ids: Array[StringName] = [] +var progress_minutes := 0 +var dangerous := false +var emergency := false +var preserve_progress_on_interrupt := true +var state_reason: StringName = &"" +var state_changed_at := 0 + + +func configure( + p_task_id: StringName, + p_task_type: StringName, + p_priority: int, + p_created_at: int, + options: Dictionary = {} +) -> StaffTask: + task_id = p_task_id + task_type = p_task_type + priority = clampi(p_priority, 0, MAX_PRIORITY) + created_at = maxi(p_created_at, 0) + deadline_at = int(_value(options, &"deadline_at", -1)) + target_entity_id = StringName(_value(options, &"target_entity_id", &"")) + target_cell = _vector3i(_value(options, &"target_cell", Vector3i.ZERO)) + required_role_tags = _sorted_unique_names(_value(options, &"required_role_tags", [])) + required_skill_min = _normalize_positive_dictionary(_value(options, &"required_skill_min", {}), 999) + required_items = _normalize_positive_dictionary(_value(options, &"required_items", {})) + estimated_minutes = maxi(int(_value(options, &"estimated_minutes", 1)), 1) + interrupt_level = clampi(int(_value(options, &"interrupt_level", 3)), 0, MAX_INTERRUPT_LEVEL) + state = _state_from_value(_value(options, &"state", State.OPEN), State.OPEN) + assigned_staff_id = StringName(_value(options, &"assigned_staff_id", &"")) + retry_count = maxi(int(_value(options, &"retry_count", 0)), 0) + task_tags = _sorted_unique_names(_value(options, &"task_tags", [])) + reservation_ids = _sorted_unique_names(_value(options, &"reservation_ids", [])) + progress_minutes = clampi( + int(_value(options, &"progress_minutes", 0)), + 0, + estimated_minutes + ) + dangerous = bool(_value(options, &"dangerous", false)) + emergency = bool(_value(options, &"emergency", priority >= 90)) + preserve_progress_on_interrupt = bool(_value(options, &"preserve_progress_on_interrupt", true)) + state_reason = StringName(_value(options, &"state_reason", &"")) + state_changed_at = maxi(int(_value(options, &"state_changed_at", created_at)), 0) + return self + + +func is_valid() -> bool: + if task_id.is_empty() or task_type.is_empty(): + return false + if priority < 0 or priority > MAX_PRIORITY: + return false + if estimated_minutes <= 0 or progress_minutes < 0 or progress_minutes > estimated_minutes: + return false + if interrupt_level < 0 or interrupt_level > MAX_INTERRUPT_LEVEL: + return false + if state in [State.RESERVED, State.MOVING, State.WORKING] and assigned_staff_id.is_empty(): + return false + if state in [State.OPEN, State.BLOCKED, State.COMPLETED, State.CANCELLED, State.FAILED] \ + and not assigned_staff_id.is_empty(): + return false + if state == State.OPEN and not reservation_ids.is_empty(): + return false + return true + + +func is_terminal() -> bool: + return state in [State.COMPLETED, State.CANCELLED, State.FAILED] + + +func is_dispatchable() -> bool: + return state == State.OPEN and assigned_staff_id.is_empty() + + +func reserve( + staff_id: StringName, + p_reservation_ids: Array[StringName], + absolute_minute: int +) -> bool: + if not is_dispatchable() or staff_id.is_empty(): + return false + if not required_items.is_empty() and p_reservation_ids.is_empty(): + return false + assigned_staff_id = staff_id + reservation_ids.assign(p_reservation_ids) + state = State.RESERVED + state_reason = &"" + state_changed_at = maxi(absolute_minute, 0) + return true + + +func begin_moving(absolute_minute: int) -> bool: + if state != State.RESERVED: + return false + state = State.MOVING + state_changed_at = maxi(absolute_minute, 0) + return true + + +func begin_working(absolute_minute: int) -> bool: + if state not in [State.RESERVED, State.MOVING]: + return false + state = State.WORKING + state_changed_at = maxi(absolute_minute, 0) + return true + + +func advance_work(minutes: int, absolute_minute: int) -> bool: + if state != State.WORKING or minutes <= 0: + return false + progress_minutes = mini(progress_minutes + minutes, estimated_minutes) + state_changed_at = maxi(absolute_minute, 0) + return progress_minutes >= estimated_minutes + + +func complete(absolute_minute: int) -> bool: + if state != State.WORKING or progress_minutes < estimated_minutes: + return false + assigned_staff_id = &"" + reservation_ids.clear() + state = State.COMPLETED + state_changed_at = maxi(absolute_minute, 0) + return true + + +func block(reason: StringName, absolute_minute: int) -> bool: + if is_terminal(): + return false + retry_count += 1 + assigned_staff_id = &"" + reservation_ids.clear() + state = State.BLOCKED + state_reason = reason + state_changed_at = maxi(absolute_minute, 0) + return true + + +func reopen(reason: StringName, absolute_minute: int, preserve_progress := true) -> bool: + if is_terminal(): + return false + assigned_staff_id = &"" + reservation_ids.clear() + if not preserve_progress: + progress_minutes = 0 + state = State.OPEN + state_reason = reason + state_changed_at = maxi(absolute_minute, 0) + return true + + +func cancel(reason: StringName, absolute_minute: int) -> bool: + if is_terminal(): + return false + assigned_staff_id = &"" + reservation_ids.clear() + state = State.CANCELLED + state_reason = reason + state_changed_at = maxi(absolute_minute, 0) + return true + + +func fail(reason: StringName, absolute_minute: int) -> bool: + if is_terminal(): + return false + assigned_staff_id = &"" + reservation_ids.clear() + state = State.FAILED + state_reason = reason + state_changed_at = maxi(absolute_minute, 0) + return true + + +func can_be_interrupted_by(candidate: StaffTask) -> bool: + if candidate == null or candidate.task_id == task_id: + return false + match interrupt_level: + 0: + return false + 1: + return candidate.emergency + 2: + return candidate.priority >= priority + _: + return true + + +func create_snapshot() -> Dictionary: + return { + &"task_id": String(task_id), + &"task_type": String(task_type), + &"priority": priority, + &"created_at": created_at, + &"deadline_at": deadline_at, + &"target_entity_id": String(target_entity_id), + &"target_cell": [target_cell.x, target_cell.y, target_cell.z], + &"required_role_tags": _names_to_strings(required_role_tags), + &"required_skill_min": _serialize_dictionary(required_skill_min), + &"required_items": _serialize_dictionary(required_items), + &"estimated_minutes": estimated_minutes, + &"interrupt_level": interrupt_level, + &"state": State.keys()[state], + &"assigned_staff_id": String(assigned_staff_id), + &"retry_count": retry_count, + &"task_tags": _names_to_strings(task_tags), + &"reservation_ids": _names_to_strings(reservation_ids), + &"progress_minutes": progress_minutes, + &"dangerous": dangerous, + &"emergency": emergency, + &"preserve_progress_on_interrupt": preserve_progress_on_interrupt, + &"state_reason": String(state_reason), + &"state_changed_at": state_changed_at, + } + + +static func from_snapshot(snapshot: Dictionary) -> StaffTask: + if not _snapshot_shape_is_valid(snapshot): + return null + var state_index := State.keys().find(String(snapshot.get(&"state", ""))) + if state_index < 0: + return null + var task := StaffTask.new().configure( + StringName(snapshot.get(&"task_id", "")), + StringName(snapshot.get(&"task_type", "")), + int(snapshot.get(&"priority", 0)), + int(snapshot.get(&"created_at", 0)), + { + &"deadline_at": snapshot.get(&"deadline_at", -1), + &"target_entity_id": snapshot.get(&"target_entity_id", ""), + &"target_cell": snapshot.get(&"target_cell", []), + &"required_role_tags": snapshot.get(&"required_role_tags", []), + &"required_skill_min": snapshot.get(&"required_skill_min", {}), + &"required_items": snapshot.get(&"required_items", {}), + &"estimated_minutes": snapshot.get(&"estimated_minutes", 1), + &"interrupt_level": snapshot.get(&"interrupt_level", 3), + &"state": state_index, + &"assigned_staff_id": snapshot.get(&"assigned_staff_id", ""), + &"retry_count": snapshot.get(&"retry_count", 0), + &"task_tags": snapshot.get(&"task_tags", []), + &"reservation_ids": snapshot.get(&"reservation_ids", []), + &"progress_minutes": snapshot.get(&"progress_minutes", 0), + &"dangerous": snapshot.get(&"dangerous", false), + &"emergency": snapshot.get(&"emergency", false), + &"preserve_progress_on_interrupt": snapshot.get(&"preserve_progress_on_interrupt", true), + &"state_reason": snapshot.get(&"state_reason", ""), + &"state_changed_at": snapshot.get(&"state_changed_at", 0), + } + ) + return task if task.is_valid() else null + + +static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool: + return ( + not String(snapshot.get(&"task_id", "")).is_empty() + and not String(snapshot.get(&"task_type", "")).is_empty() + and snapshot.get(&"target_cell", null) is Array + and snapshot.get(&"required_role_tags", null) is Array + and snapshot.get(&"required_skill_min", null) is Dictionary + and snapshot.get(&"required_items", null) is Dictionary + and snapshot.get(&"task_tags", null) is Array + and snapshot.get(&"reservation_ids", null) is Array + and int(snapshot.get(&"priority", -1)) >= 0 + and int(snapshot.get(&"priority", -1)) <= MAX_PRIORITY + and int(snapshot.get(&"estimated_minutes", 0)) > 0 + and int(snapshot.get(&"interrupt_level", -1)) in range(0, MAX_INTERRUPT_LEVEL + 1) + ) + + +static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: + return source.get(key, source.get(String(key), fallback)) + + +static func _state_from_value(value: Variant, fallback: State) -> State: + if value is int: + var numeric := int(value) + if numeric >= 0 and numeric < State.size(): + return numeric as State + var index := State.keys().find(String(value)) + return index as State if index >= 0 else fallback + + +static func _vector3i(value: Variant) -> Vector3i: + if value is Vector3i: + return value + if value is Array and value.size() == 3: + return Vector3i(int(value[0]), int(value[1]), int(value[2])) + return Vector3i.ZERO + + +static func _normalize_positive_dictionary(source: Variant, maximum := 2_147_483_647) -> Dictionary: + var result: Dictionary = {} + if not source is Dictionary: + return result + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_id: Variant in keys: + var item_id := StringName(raw_id) + var value := int(source[raw_id]) + if not item_id.is_empty() and value > 0: + result[item_id] = mini(value, maximum) + return result + + +static func _serialize_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + var keys: Array = source.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + for raw_id: Variant in keys: + result[String(raw_id)] = int(source[raw_id]) + return result + + +static func _sorted_unique_names(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for value: Variant in source: + var name := StringName(value) + if not name.is_empty() and not result.has(name): + result.append(name) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _names_to_strings(source: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in source: + result.append(String(value)) + return result diff --git a/scripts/staff/staff_task.gd.uid b/scripts/staff/staff_task.gd.uid new file mode 100644 index 0000000..e3be8d6 --- /dev/null +++ b/scripts/staff/staff_task.gd.uid @@ -0,0 +1 @@ +uid://bi15b1x11sq6v diff --git a/scripts/staff/task_dispatcher.gd b/scripts/staff/task_dispatcher.gd new file mode 100644 index 0000000..4d5fbdf --- /dev/null +++ b/scripts/staff/task_dispatcher.gd @@ -0,0 +1,419 @@ +class_name TaskDispatcher +extends RefCounted + +## Deterministic employee/task matcher. Inventory reservation is injected so +## this layer can keep the Task/Staff pair atomic without owning stock. + +const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd") +const StaffTaskType = preload("res://scripts/staff/staff_task.gd") + +const SNAPSHOT_VERSION := 1 +const DISPATCH_INTERVAL_MINUTES := 5 +const HIGH_PRIORITY_THRESHOLD := 90 +const PRIORITY_MULTIPLIER := 10 +const SKILL_FIT_MULTIPLIER := 4 +const ROLE_FIT_MULTIPLIER := 100 +const TRAVEL_MINUTE_PENALTY := 3 +const FATIGUE_PENALTY := 2 +const OVERTIME_PENALTY := 200 +const INTERRUPT_COST_LEVEL_1 := 300 +const INTERRUPT_COST_LEVEL_2 := 150 +const INTERRUPT_COST_LEVEL_3 := 25 +const MIN_SCORE := -2_147_483_648 + +var last_dispatch_minute := -1 +var last_report: Dictionary = {} +var last_restore_error := "" + +var _item_reserver := Callable() +var _item_releaser := Callable() +var _travel_estimator := Callable() +var _assignment_history: Array[Dictionary] = [] + + +func configure_callbacks( + item_reserver: Callable = Callable(), + item_releaser: Callable = Callable(), + travel_estimator: Callable = Callable() +) -> TaskDispatcher: + _item_reserver = item_reserver + _item_releaser = item_releaser + _travel_estimator = travel_estimator + return self + + +func should_dispatch(absolute_minute: int, has_high_priority_task := false) -> bool: + if has_high_priority_task: + return true + if last_dispatch_minute < 0: + return true + return absolute_minute - last_dispatch_minute >= DISPATCH_INTERVAL_MINUTES + + +func dispatch( + tasks: Array, + staff_by_id: Dictionary, + absolute_minute: int +) -> Dictionary: + var task_by_id := _task_index(tasks) + var pending := _sorted_open_tasks(tasks) + var assignments: Array[Dictionary] = [] + var blocked: Array[Dictionary] = [] + var unassigned: Array[Dictionary] = [] + var interruptions: Array[Dictionary] = [] + var assigned_this_pass: Dictionary = {} + + for task: StaffTask in pending: + var selected := _select_candidate( + task, + staff_by_id, + task_by_id, + assigned_this_pass, + absolute_minute + ) + if selected.is_empty(): + unassigned.append({ + &"task_id": task.task_id, + &"reason": &"NO_ELIGIBLE_STAFF", + }) + continue + var staff_id: StringName = selected[&"staff_id"] + var staff: StaffRuntime = staff_by_id[staff_id] + var previous_task: StaffTask = selected.get(&"current_task") as StaffTask + var result := _reserve_pair(task, staff, previous_task, absolute_minute) + if not bool(result.get(&"ok", false)): + var reason := StringName(result.get(&"reason", &"RESERVATION_FAILED")) + if bool(result.get(&"blocked", false)): + blocked.append({&"task_id": task.task_id, &"reason": reason}) + else: + unassigned.append({&"task_id": task.task_id, &"reason": reason}) + continue + if previous_task != null: + interruptions.append({ + &"staff_id": staff_id, + &"interrupted_task_id": previous_task.task_id, + &"replacement_task_id": task.task_id, + }) + var assignment := { + &"task_id": task.task_id, + &"staff_id": staff_id, + &"score": int(selected[&"score"]), + &"travel_minutes": int(selected[&"travel_minutes"]), + &"assigned_at": absolute_minute, + } + assignments.append(assignment) + _assignment_history.append(assignment.duplicate(true)) + assigned_this_pass[staff_id] = true + + last_dispatch_minute = absolute_minute + last_report = { + &"assigned": assignments, + &"blocked": blocked, + &"unassigned": unassigned, + &"interruptions": interruptions, + } + return last_report.duplicate(true) + + +func score_candidate( + task: StaffTask, + staff: StaffRuntime, + travel_minutes: int, + current_task: StaffTask = null +) -> int: + if task == null or staff == null or travel_minutes < 0: + return MIN_SCORE + var interrupt_cost := 0 + if current_task != null: + match current_task.interrupt_level: + 1: + interrupt_cost = INTERRUPT_COST_LEVEL_1 + 2: + interrupt_cost = INTERRUPT_COST_LEVEL_2 + _: + interrupt_cost = INTERRUPT_COST_LEVEL_3 + return ( + task.priority * PRIORITY_MULTIPLIER + + staff.skill_fit_for(task) * SKILL_FIT_MULTIPLIER + + staff.role_fit_for(task) * ROLE_FIT_MULTIPLIER + - travel_minutes * TRAVEL_MINUTE_PENALTY + - staff.fatigue * FATIGUE_PENALTY + - interrupt_cost + - (OVERTIME_PENALTY if staff.is_overtime else 0) + ) + + +func create_snapshot() -> Dictionary: + var history: Array[Dictionary] = [] + for assignment: Dictionary in _assignment_history: + var serialized := assignment.duplicate(true) + serialized[&"task_id"] = String(assignment[&"task_id"]) + serialized[&"staff_id"] = String(assignment[&"staff_id"]) + history.append(serialized) + return { + &"version": SNAPSHOT_VERSION, + &"last_dispatch_minute": last_dispatch_minute, + &"assignment_history": history, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + last_restore_error = "" + if int(snapshot.get(&"version", -1)) != SNAPSHOT_VERSION: + last_restore_error = "Unsupported task dispatcher snapshot version." + return false + var raw_history: Variant = snapshot.get(&"assignment_history", null) + if not raw_history is Array: + last_restore_error = "Task dispatcher history is malformed." + return false + var next_history: Array[Dictionary] = [] + for raw_assignment: Variant in raw_history: + if not raw_assignment is Dictionary: + last_restore_error = "Task dispatcher history entry must be a Dictionary." + return false + var task_id := StringName(raw_assignment.get(&"task_id", "")) + var staff_id := StringName(raw_assignment.get(&"staff_id", "")) + if task_id.is_empty() or staff_id.is_empty(): + last_restore_error = "Task dispatcher history has an empty identifier." + return false + var assignment: Dictionary = raw_assignment.duplicate(true) + assignment[&"task_id"] = task_id + assignment[&"staff_id"] = staff_id + next_history.append(assignment) + var next_last_minute := int(snapshot.get(&"last_dispatch_minute", -1)) + if next_last_minute < -1: + last_restore_error = "Task dispatcher minute is invalid." + return false + _assignment_history = next_history + last_dispatch_minute = next_last_minute + last_report = {} + return true + + +func assignment_history() -> Array[Dictionary]: + return _assignment_history.duplicate(true) + + +func _select_candidate( + task: StaffTask, + staff_by_id: Dictionary, + task_by_id: Dictionary, + excluded_staff_ids: Dictionary, + absolute_minute: int +) -> Dictionary: + var best: Dictionary = {} + for staff_id: StringName in _sorted_names(staff_by_id.keys()): + if excluded_staff_ids.has(staff_id): + continue + var staff: StaffRuntime = staff_by_id.get(staff_id) as StaffRuntime + if staff == null or not staff.meets_task_requirements(task): + continue + var current_task: StaffTask = null + if staff.current_task_id.is_empty(): + if not staff.is_available_for_dispatch(): + continue + else: + current_task = task_by_id.get(staff.current_task_id) as StaffTask + if current_task == null or not current_task.can_be_interrupted_by(task): + continue + var travel_minutes := _travel_minutes(staff, task, absolute_minute) + if travel_minutes < 0: + continue + var score := score_candidate(task, staff, travel_minutes, current_task) + if ( + best.is_empty() + or score > int(best[&"score"]) + or (score == int(best[&"score"]) and String(staff_id) < String(best[&"staff_id"])) + ): + best = { + &"staff_id": staff_id, + &"staff": staff, + &"current_task": current_task, + &"travel_minutes": travel_minutes, + &"score": score, + } + return best + + +func _reserve_pair( + task: StaffTask, + staff: StaffRuntime, + previous_task: StaffTask, + absolute_minute: int +) -> Dictionary: + if not task.is_dispatchable() or staff == null: + return {&"ok": false, &"reason": &"STATE_CHANGED", &"blocked": false} + if previous_task == null and not staff.is_available_for_dispatch(): + return {&"ok": false, &"reason": &"STAFF_STATE_CHANGED", &"blocked": false} + if previous_task != null: + if staff.current_task_id != previous_task.task_id: + return {&"ok": false, &"reason": &"STAFF_TASK_MISMATCH", &"blocked": false} + if previous_task.assigned_staff_id != staff.staff_id: + return {&"ok": false, &"reason": &"TASK_STAFF_MISMATCH", &"blocked": false} + if not previous_task.can_be_interrupted_by(task): + return {&"ok": false, &"reason": &"INTERRUPT_DENIED", &"blocked": false} + + var item_result := _reserve_items(task, absolute_minute) + if not bool(item_result.get(&"ok", false)): + var item_reason := StringName(item_result.get(&"reason", &"ITEM_RESERVATION_FAILED")) + task.block(item_reason, absolute_minute) + return {&"ok": false, &"reason": item_reason, &"blocked": true} + var reservation_ids: Array[StringName] = _name_array(item_result.get(&"reservation_ids", [])) + + var staff_before := { + &"current_task_id": staff.current_task_id, + &"state": staff.state, + &"state_reason": staff.state_reason, + &"state_changed_at": staff.state_changed_at, + } + var task_before := { + &"state": task.state, + &"assigned_staff_id": task.assigned_staff_id, + &"reservation_ids": task.reservation_ids.duplicate(), + &"state_reason": task.state_reason, + &"state_changed_at": task.state_changed_at, + } + var previous_before: Dictionary = {} + if previous_task != null: + previous_before = { + &"state": previous_task.state, + &"assigned_staff_id": previous_task.assigned_staff_id, + &"reservation_ids": previous_task.reservation_ids.duplicate(), + &"progress_minutes": previous_task.progress_minutes, + &"state_reason": previous_task.state_reason, + &"state_changed_at": previous_task.state_changed_at, + } + previous_task.reopen( + &"INTERRUPTED", + absolute_minute, + previous_task.preserve_progress_on_interrupt + ) + staff.release_task(absolute_minute, &"TASK_INTERRUPTED") + + var task_reserved := task.reserve(staff.staff_id, reservation_ids, absolute_minute) + var staff_reserved := task_reserved and staff.reserve_task(task.task_id, absolute_minute) + if not task_reserved or not staff_reserved: + _restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before) + _release_items(reservation_ids, task, absolute_minute) + return {&"ok": false, &"reason": &"ATOMIC_PAIR_RESERVATION_FAILED", &"blocked": false} + + if previous_task != null: + var old_ids: Array[StringName] = _name_array(previous_before[&"reservation_ids"]) + if not _release_items(old_ids, previous_task, absolute_minute): + _restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before) + _release_items(reservation_ids, task, absolute_minute) + return {&"ok": false, &"reason": &"INTERRUPT_RELEASE_FAILED", &"blocked": false} + return {&"ok": true, &"reservation_ids": reservation_ids} + + +func _reserve_items(task: StaffTask, absolute_minute: int) -> Dictionary: + if task.required_items.is_empty(): + return {&"ok": true, &"reservation_ids": []} + if not _item_reserver.is_valid(): + return {&"ok": false, &"reason": &"ITEM_RESERVATION_UNAVAILABLE"} + var response: Variant = _item_reserver.call(task, absolute_minute) + if not response is Dictionary: + return {&"ok": false, &"reason": &"ITEM_RESERVATION_INVALID_RESPONSE"} + var result: Dictionary = response + if not bool(result.get(&"ok", false)): + return { + &"ok": false, + &"reason": StringName(result.get(&"reason", result.get(&"code", &"ITEM_RESERVATION_FAILED"))), + } + var reservation_ids := _name_array(result.get(&"reservation_ids", [])) + if reservation_ids.is_empty(): + return {&"ok": false, &"reason": &"ITEM_RESERVATION_IDS_MISSING"} + return {&"ok": true, &"reservation_ids": reservation_ids} + + +func _release_items( + reservation_ids: Array[StringName], + task: StaffTask, + absolute_minute: int +) -> bool: + if reservation_ids.is_empty(): + return true + if not _item_releaser.is_valid(): + return false + var response: Variant = _item_releaser.call(reservation_ids, task, absolute_minute) + if response is Dictionary: + return bool(response.get(&"ok", false)) + return bool(response) + + +func _travel_minutes(staff: StaffRuntime, task: StaffTask, absolute_minute: int) -> int: + if _travel_estimator.is_valid(): + return int(_travel_estimator.call(staff, task, absolute_minute)) + var delta := staff.current_cell - task.target_cell + return absi(delta.x) + absi(delta.y) + absi(delta.z) + + +static func _restore_pair_state( + task: StaffTask, + staff: StaffRuntime, + previous_task: StaffTask, + task_before: Dictionary, + staff_before: Dictionary, + previous_before: Dictionary +) -> void: + task.state = int(task_before[&"state"]) as StaffTask.State + task.assigned_staff_id = task_before[&"assigned_staff_id"] + task.reservation_ids.assign(task_before[&"reservation_ids"]) + task.state_reason = task_before[&"state_reason"] + task.state_changed_at = int(task_before[&"state_changed_at"]) + staff.current_task_id = staff_before[&"current_task_id"] + staff.state = int(staff_before[&"state"]) as StaffRuntime.State + staff.state_reason = staff_before[&"state_reason"] + staff.state_changed_at = int(staff_before[&"state_changed_at"]) + if previous_task != null: + previous_task.state = int(previous_before[&"state"]) as StaffTask.State + previous_task.assigned_staff_id = previous_before[&"assigned_staff_id"] + previous_task.reservation_ids.assign(previous_before[&"reservation_ids"]) + previous_task.progress_minutes = int(previous_before[&"progress_minutes"]) + previous_task.state_reason = previous_before[&"state_reason"] + previous_task.state_changed_at = int(previous_before[&"state_changed_at"]) + + +static func _task_index(tasks: Array) -> Dictionary: + var result: Dictionary = {} + for value: Variant in tasks: + if value is StaffTask: + result[value.task_id] = value + return result + + +static func _sorted_open_tasks(tasks: Array) -> Array[StaffTask]: + var result: Array[StaffTask] = [] + for value: Variant in tasks: + if value is StaffTask and value.is_dispatchable(): + result.append(value) + result.sort_custom(func(a: StaffTask, b: StaffTask) -> bool: + if a.priority != b.priority: + return a.priority > b.priority + var a_deadline := 9_223_372_036_854_775_807 if a.deadline_at < 0 else a.deadline_at + var b_deadline := 9_223_372_036_854_775_807 if b.deadline_at < 0 else b.deadline_at + if a_deadline != b_deadline: + return a_deadline < b_deadline + return String(a.task_id) < String(b.task_id) + ) + return result + + +static func _sorted_names(values: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + for value: Variant in values: + result.append(StringName(value)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result + + +static func _name_array(source: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if not source is Array: + return result + for value: Variant in source: + var name := StringName(value) + if not name.is_empty() and not result.has(name): + result.append(name) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return result diff --git a/scripts/staff/task_dispatcher.gd.uid b/scripts/staff/task_dispatcher.gd.uid new file mode 100644 index 0000000..d51b902 --- /dev/null +++ b/scripts/staff/task_dispatcher.gd.uid @@ -0,0 +1 @@ +uid://vjdti463kwwt diff --git a/scripts/ui/pixel_tower_art.gd b/scripts/ui/pixel_tower_art.gd new file mode 100644 index 0000000..40c9e5b --- /dev/null +++ b/scripts/ui/pixel_tower_art.gd @@ -0,0 +1,144 @@ +class_name PixelTowerArt +extends RefCounted + +## Small, code-native room furnishings. Coordinates use the same pixel size as +## Kenney's 16 px sprites. This renderer never changes simulation state. +const INK := Color("34413b") +const WOOD := Color("8e6348") +const WOOD_LIGHT := Color("c39665") +const CREAM := Color("eee5c9") +const TEAL := Color("608d84") + +static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rect2, tint := Color.WHITE) -> void: + if texture == null: + return + canvas.draw_texture_rect_region(texture, target, Rect2((index % 12) * 16, (index / 12) * 16, 16, 16), tint) + + +static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0) -> void: + var p := pixel + if id in [&"guest_corridor", &"service_corridor"]: + canvas.draw_rect(rect, Color("c6b999") if not back else Color("a6b6a0")) + canvas.draw_rect(Rect2(rect.position.x,rect.end.y-4*p,rect.size.x,3*p),WOOD_LIGHT) + canvas.draw_rect(Rect2(rect.position.x,rect.end.y-p,rect.size.x,p),INK) + canvas.draw_rect(Rect2(rect.position.x,rect.position.y,rect.size.x,p),WOOD) + if posmod(decoration_seed,3)==0: + window(canvas,Vector2(rect.get_center().x-3*p,rect.position.y+5*p),p) + return + var dark := id in [&"tavern", &"adult_private_room", &"massage_room"] + var wall := Color("d4c7a5") if not back else Color("acb9a5") + if dark: + wall = Color("897487") + canvas.draw_rect(rect, INK) + var inner := rect.grow(-p) + canvas.draw_rect(inner, wall) + # Plaster, dado rail and floor boards read as interiors, even at a distance. + canvas.draw_rect(Rect2(inner.position, Vector2(inner.size.x, p)), Color("f1e9cb")) + var floor_y := rect.end.y - 4.0 * p + canvas.draw_rect(Rect2(Vector2(inner.position.x, floor_y - 4.0 * p), Vector2(inner.size.x, 4.0 * p)), Color("b39b77") if not dark else Color("615563")) + canvas.draw_rect(Rect2(Vector2(inner.position.x, floor_y), Vector2(inner.size.x, 3.0 * p)), WOOD) + canvas.draw_line(Vector2(inner.position.x, floor_y), Vector2(inner.end.x, floor_y), WOOD_LIGHT, p) + for x in range(int(inner.position.x + 2 * p), int(inner.end.x), maxi(int(8 * p), 1)): + canvas.draw_rect(Rect2(x, floor_y + p, 5 * p, p), Color("a57852")) + var mid := rect.get_center().x + var w := rect.size.x + var top := rect.position.y + if id in [&"guest_elevator", &"service_elevator"]: + canvas.draw_rect(inner, Color("455855") if not back else Color("506277")) + canvas.draw_rect(Rect2(mid - p, top + p, 2 * p, rect.size.y - 2 * p), Color("243c3c")) + canvas.draw_rect(Rect2(inner.position.x + 2*p, top + 4*p, inner.size.x - 4*p, inner.size.y - 6*p), Color("84978b")) + canvas.draw_line(Vector2(mid, top+4*p), Vector2(mid, rect.end.y-3*p), INK, p) + canvas.draw_rect(Rect2(mid-2*p, top+2*p, 4*p, p), Color("f1c774")) + return + if id in [&"guest_corridor", &"service_corridor", &"staff_door"]: + if id == &"staff_door": + door(canvas, Vector2(mid-4*p, floor_y-14*p), p, Color("587f7b")) + else: + window(canvas, Vector2(mid-3*p, top+5*p), p) + return + # A window and small wall lamp leave the lower part clear for live actors. + if w >= 28*p: + window(canvas, Vector2(rect.position.x+5*p, top+5*p), p) + window(canvas, Vector2(rect.end.x-12*p, top+5*p), p) + else: + window(canvas, Vector2(mid-3*p, top+4*p), p) + var lamp_x := mid if w > 32*p else rect.end.x-4*p + canvas.draw_rect(Rect2(lamp_x-p, top+3*p, 2*p, 3*p), WOOD) + canvas.draw_rect(Rect2(lamp_x-2*p, top+5*p, 4*p, 2*p), Color("edc97c")) + match id: + &"reception": + counter(canvas, Vector2(mid-9*p, floor_y-6*p), 18*p, p) + canvas.draw_rect(Rect2(mid-6*p, floor_y-8*p, 4*p, 2*p), CREAM) + canvas.draw_rect(Rect2(mid+4*p, floor_y-8*p, 2*p, 2*p), Color("d6ad52")) + &"standard_room", &"adult_private_room": + bed(canvas, Vector2(rect.position.x+2*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13) + if w >= 28*p: + bed(canvas, Vector2(rect.end.x-15*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13) + &"cafeteria", &"tavern": + for x in range(int(rect.position.x+5*p), int(rect.end.x-13*p), int(20*p)): + table(canvas, Vector2(x, floor_y-7*p), p) + if rect.size.y > 28*p: + counter(canvas, Vector2(rect.position.x+7*p, top+13*p), w-14*p, p) + for x in range(int(rect.position.x+10*p), int(rect.end.x-8*p), int(12*p)): + canvas.draw_rect(Rect2(x, top+11*p, 3*p, 2*p), CREAM) + &"public_bath": + canvas.draw_rect(Rect2(rect.position.x+4*p, floor_y-7*p, w-8*p, 7*p), CREAM) + canvas.draw_rect(Rect2(rect.position.x+6*p, floor_y-6*p, w-12*p, 4*p), Color("72b6b8")) + canvas.draw_line(Vector2(rect.position.x+8*p,floor_y-5*p), Vector2(rect.end.x-10*p,floor_y-5*p), Color("c1e0d4"), p) + &"kitchen": + counter(canvas, Vector2(rect.position.x+3*p, floor_y-7*p), w-6*p, p) + for x in range(int(rect.position.x+5*p), int(rect.end.x-9*p), int(12*p)): + canvas.draw_rect(Rect2(x, floor_y-9*p, 7*p, 2*p), Color("445652")) + canvas.draw_rect(Rect2(x+2*p, floor_y-11*p, 4*p, 3*p), Color("a2b2a3")) + &"warehouse": + for x in range(int(rect.position.x+2*p), int(rect.end.x-12*p), int(13*p)): + tile(canvas, dungeon, 63, Rect2(x, floor_y-12*p, 12*p, 12*p)) + &"staff_waiting_room": + table(canvas, Vector2(mid-6*p, floor_y-7*p), p) + tile(canvas, dungeon, 89, Rect2(rect.position.x+2*p, floor_y-12*p, 12*p, 12*p)) + &"security_station": + counter(canvas, Vector2(mid-8*p, floor_y-6*p), 16*p, p) + tile(canvas, dungeon, 105, Rect2(rect.position.x+3*p, floor_y-17*p, 12*p, 12*p)) + &"massage_room": + bed(canvas, Vector2(mid-8*p, floor_y-9*p), p, Color("d3aa8f")) + _: + table(canvas, Vector2(mid-6*p, floor_y-7*p), p) + + +static func window(canvas: CanvasItem, at: Vector2, p: float) -> void: + canvas.draw_rect(Rect2(at, Vector2(7,9)*p), WOOD) + canvas.draw_rect(Rect2(at+Vector2.ONE*p, Vector2(5,6)*p), Color("9ab9b3")) + canvas.draw_rect(Rect2(at+Vector2(2,1)*p, Vector2(2,2)*p), Color("d5e3d0")) + canvas.draw_rect(Rect2(at+Vector2(3,1)*p, Vector2(1,6)*p), WOOD_LIGHT) + canvas.draw_rect(Rect2(at+Vector2(1,4)*p, Vector2(5,1)*p), WOOD_LIGHT) + canvas.draw_rect(Rect2(at+Vector2(-1,8)*p, Vector2(9,1)*p), WOOD_LIGHT) + + +static func door(canvas: CanvasItem, at: Vector2, p: float, color: Color) -> void: + canvas.draw_rect(Rect2(at, Vector2(8,14)*p), INK) + canvas.draw_rect(Rect2(at+Vector2.ONE*p, Vector2(6,13)*p), color) + canvas.draw_rect(Rect2(at+Vector2(5,8)*p, Vector2.ONE*p), Color("f2ca81")) + + +static func bed(canvas: CanvasItem, at: Vector2, p: float, cover: Color, width := 17) -> void: + canvas.draw_rect(Rect2(at+Vector2(0,2)*p, Vector2(width,7)*p), WOOD) + canvas.draw_rect(Rect2(at+Vector2(1,2)*p, Vector2(width-2,4)*p), CREAM) + canvas.draw_rect(Rect2(at+Vector2(5,2)*p, Vector2(width-6,5)*p), cover) + canvas.draw_rect(Rect2(at, Vector2(1,10)*p), WOOD) + canvas.draw_rect(Rect2(at+Vector2(width-1,5)*p, Vector2(1,5)*p), WOOD) + canvas.draw_rect(Rect2(at+Vector2(2,2)*p, Vector2(3,2)*p), Color("fff0d6")) + + +static func counter(canvas: CanvasItem, at: Vector2, width: float, p: float) -> void: + canvas.draw_rect(Rect2(at, Vector2(width, 6*p)), WOOD) + canvas.draw_rect(Rect2(at, Vector2(width, p)), WOOD_LIGHT) + canvas.draw_rect(Rect2(at+Vector2(2,2)*p, Vector2(width-4*p, 3*p)), Color("a67c53")) + + +static func table(canvas: CanvasItem, at: Vector2, p: float) -> void: + canvas.draw_rect(Rect2(at+Vector2(2,1)*p, Vector2(9,2)*p), WOOD_LIGHT) + canvas.draw_rect(Rect2(at+Vector2(3,3)*p, Vector2(1,4)*p), WOOD) + canvas.draw_rect(Rect2(at+Vector2(9,3)*p, Vector2(1,4)*p), WOOD) + canvas.draw_rect(Rect2(at+Vector2(0,3)*p, Vector2(2,4)*p), TEAL) + canvas.draw_rect(Rect2(at+Vector2(12,3)*p, Vector2(2,4)*p), TEAL) + canvas.draw_rect(Rect2(at+Vector2(5,0)*p, Vector2(3,1)*p), CREAM) diff --git a/scripts/ui/pixel_tower_art.gd.uid b/scripts/ui/pixel_tower_art.gd.uid new file mode 100644 index 0000000..f25230c --- /dev/null +++ b/scripts/ui/pixel_tower_art.gd.uid @@ -0,0 +1 @@ +uid://cq3ssdj06ljk0 diff --git a/scripts/ui/pixel_tower_demo.gd b/scripts/ui/pixel_tower_demo.gd new file mode 100644 index 0000000..f663690 --- /dev/null +++ b/scripts/ui/pixel_tower_demo.gd @@ -0,0 +1,141 @@ +extends RefCounted + +## Reproducible, disposable viewing scenario. All buildings, employees, inventory, +## and jobs go through Runtime commands; visitors come from production arrivals. +## The normal new-game state and the player's saves are never changed by this file. + +var failure := "" +var facility_ids: Dictionary = {} +var _runtime: Variant +var _clock: Variant +var _tree: SceneTree +var _sequence := 0 + + +func prepare(runtime: Variant, clock: Variant, tree: SceneTree) -> bool: + _runtime = runtime + _clock = clock + _tree = tree + _clock.set_paused(true) + var operations: Dictionary = _runtime.enable_production_operations() + if not bool(operations.get(&"ok", false)): + return _fail("Could not enable production operations: %s" % operations) + for row: Array in [ + [&"reception", Vector3i(-8, 0, 0)], + [&"cafeteria", Vector3i(2, 0, 0)], + [&"kitchen", Vector3i(2, 0, 1)], + ]: + if not _build(row[0], row[1]): + return false + if not await advance(180): + return false + for facility: Dictionary in _runtime.construction_service.get_facilities(): + facility_ids[facility[&"definition_id"]] = facility[&"instance_id"] + # Four rooms sit on the completed reception and dining structure. Corridor + # cells are ordinary supported construction; unfinished elevator topology is + # deliberately excluded from this scenario. + for x: int in [-8, -6, 2, 4]: + if not _build(&"standard_room", Vector3i(x, 2, 0)): + return false + for x: int in range(-4, 2): + if not _build(&"guest_corridor", Vector3i(x, 1, 0)): + return false + if not await advance(60): + return false + for x: int in range(-4, 2): + if not _build(&"guest_corridor", Vector3i(x, 2, 0)): + return false + if not await advance(10): + return false + # This walkway follows the upper side of the rooms. Its authoritative Path + # projection allows real guests and workers to move between the three levels. + for x: int in range(-8, 6): + if not _build(&"guest_corridor", Vector3i(x, 3, 0)): + return false + if not await advance(10): + return false + for row: Array in [ + [&"staff_akane", facility_ids[&"reception"], Vector3i(-8, 0, 0)], + [&"staff_chika", facility_ids[&"cafeteria"], Vector3i(2, 0, 0)], + [&"staff_iris", facility_ids[&"cafeteria"], Vector3i(4, 0, 0)], + [&"staff_eri", &"", Vector3i(0, 0, 0)], + ]: + if not _command(&"hire_staff_candidate", { + &"candidate_id": row[0], &"facility_id": row[1], &"current_cell": row[2], + }): + return false + # A small explicitly seeded pantry is part of the disposable scenario; its + # stacks are real and get consumed by the same service pipeline as gameplay. + var cafeteria: Variant = _runtime.facility_service.get_instance(facility_ids[&"cafeteria"]) + if not _command(&"add_inventory_stack", { + &"owner_inventory_id": cafeteria.local_inventory_id, + &"item_id": &"ingredient/basic_meal", &"quantity": 32, + }): + return false + if not await advance(1): + return false + # The cleaner actually accepts, walks to, and completes these queued tasks. + for index: int in 6: + var target := Vector3i(-8 if index % 2 == 0 else 5, 3, 0) + if not _command(&"create_staff_task", { + &"task_id": StringName("pixel_view.clean.%02d" % index), + &"task_type": &"clean", &"priority": 70 - index, + &"options": { + &"target_entity_id": facility_ids[&"reception"] if index % 2 == 0 else facility_ids[&"cafeteria"], + &"target_cell": target, &"required_role_tags": [&"cleaner"], + &"task_tags": [&"cleaning"], &"estimated_minutes": 8, + }, + }): + return false + return await advance(1) + + +func advance(minutes: int) -> bool: + for index: int in minutes: + var before: int = _clock.absolute_minute + _clock.advance_minutes(1) + if _clock.absolute_minute != before + 1: + return _fail("Scenario stopped at %s: %s" % [_clock.formatted_time(), _runtime.get_operations_report()]) + var report: Dictionary = _runtime.get_operations_report() + if bool(report.get(&"runtime_failure_latched", false)): + return _fail("Operations failed: %s" % report) + if index % 30 == 29: + await _tree.process_frame + return true + + +func describe() -> Dictionary: + var facilities: Array[Dictionary] = [] + for facility: Variant in _runtime.facility_service.get_instances(): + facilities.append({ + "definition": String(facility.definition_id), "state": int(facility.state), + "origin": [facility.origin.x, facility.origin.y, facility.origin.z], + }) + var actors: Array[Dictionary] = [] + for guest: Variant in _runtime.guest_service.get_guests(): + actors.append({"id": String(guest.guest_id), "kind": "guest", "state": int(guest.state), + "cell": [guest.current_cell.x, guest.current_cell.y, guest.current_cell.z]}) + for staff: Variant in _runtime.staff_service.get_staff_members(): + actors.append({"id": String(staff.staff_id), "kind": "staff", "state": int(staff.state), + "cell": [staff.current_cell.x, staff.current_cell.y, staff.current_cell.z]}) + return {"time": _clock.formatted_time(), "minute": _clock.absolute_minute, + "facilities": facilities, "actors": actors, "pantry_seed_meals": 32} + + +func _build(definition_id: StringName, cell: Vector3i) -> bool: + return _command(&"build_facility", {&"definition_id": definition_id, &"origin": cell}) + + +func _command(type: StringName, payload: Dictionary) -> bool: + _sequence += 1 + var result: Variant = _runtime.submit_command(type, payload, &"player", &"", 50, -1, + StringName("pixel_view.command.%04d" % _sequence)) + if not result.is_success(): + return _fail("%s failed for %s: %s %s" % [type, payload, result.message, result.details]) + return true + + +func _fail(message: String) -> bool: + failure = message + push_error(message) + return false diff --git a/scripts/ui/pixel_tower_demo.gd.uid b/scripts/ui/pixel_tower_demo.gd.uid new file mode 100644 index 0000000..ba6095e --- /dev/null +++ b/scripts/ui/pixel_tower_demo.gd.uid @@ -0,0 +1 @@ +uid://l58jbi52mqdl diff --git a/scripts/ui/visual_theme_profile.gd b/scripts/ui/visual_theme_profile.gd new file mode 100644 index 0000000..9d66bb0 --- /dev/null +++ b/scripts/ui/visual_theme_profile.gd @@ -0,0 +1,90 @@ +class_name VisualThemeProfile +extends Resource + +## Replaceable visual-only configuration. Gameplay code must not branch on +## this resource, so changing art never changes simulation or save data. + +@export_group("Pixel Art") +@export var pixel_dungeon_tiles: Texture2D +@export var pixel_town_tiles: Texture2D + +@export_group("World Art") +@export var front_world_background: Texture2D +@export var back_world_background: Texture2D +@export_range(0.0, 1.0, 0.01) var world_art_opacity := 0.94 +@export_range(0.0, 1.0, 0.01) var grid_overlay_opacity := 0.24 + +@export_group("Interface Icons") +@export var facility_icons: Dictionary = {} +@export var navigation_icons: Dictionary = {} +@export var resource_icons: Dictionary = {} +@export var control_icons: Dictionary = {} +@export var guest_actor_markers: Dictionary = {} +@export var staff_actor_markers: Dictionary = {} + +@export_group("Interface Skins") +@export var ui_skin_textures: Dictionary = {} + +@export_group("Interface Palette") +@export var window_background := Color("24170f") +@export var dark_panel := Color("6b3e1d") +@export var dark_panel_alt := Color("513018") +@export var light_panel := Color("f4e3bd") +@export var border_gold := Color("d5a552") +@export var button_normal := Color("75451f") +@export var button_hover := Color("8d5628") +@export var button_pressed := Color("496528") +@export var button_disabled := Color("574334") +@export var text_light := Color("fff0c8") +@export var text_dark := Color("55371e") +@export var success := Color("4f8a45") +@export var warning := Color("d39a2d") +@export var danger := Color("c84632") + + +func world_background_for_layer(layer: int) -> Texture2D: + return front_world_background if layer == 0 else back_world_background + + +func facility_icon(definition_id: StringName) -> Texture2D: + return _icon_from(facility_icons, definition_id) + + +func navigation_icon(mode: StringName) -> Texture2D: + return _icon_from(navigation_icons, mode) + + +func resource_icon(resource_name: StringName) -> Texture2D: + return _icon_from(resource_icons, resource_name) + + +func control_icon(control_name: StringName) -> Texture2D: + return _icon_from(control_icons, control_name) + + +func guest_actor_marker(guest_type_id: StringName) -> Texture2D: + var direct := _icon_from(guest_actor_markers, guest_type_id) + if direct != null: + return direct + var type_text := String(guest_type_id) + for raw_key: Variant in guest_actor_markers: + var class_id := String(raw_key) + if class_id == "default": + continue + if type_text == class_id or type_text.begins_with(class_id + "_"): + return guest_actor_markers[raw_key] as Texture2D + return _icon_from(guest_actor_markers, &"default") + + +func staff_actor_marker(role_id: StringName) -> Texture2D: + var marker := _icon_from(staff_actor_markers, role_id) + return marker if marker != null else _icon_from(staff_actor_markers, &"default") + + +func ui_skin(skin_id: StringName) -> Texture2D: + return _icon_from(ui_skin_textures, skin_id) + + +func _icon_from(collection: Dictionary, key: StringName) -> Texture2D: + var value: Variant = collection.get(key, collection.get(String(key), null)) + return value as Texture2D diff --git a/scripts/ui/visual_theme_profile.gd.uid b/scripts/ui/visual_theme_profile.gd.uid new file mode 100644 index 0000000..9b61e99 --- /dev/null +++ b/scripts/ui/visual_theme_profile.gd.uid @@ -0,0 +1 @@ +uid://bmcsg0aclydnb diff --git a/scripts/world/facility_infrastructure_projector.gd b/scripts/world/facility_infrastructure_projector.gd new file mode 100644 index 0000000..cce07c0 --- /dev/null +++ b/scripts/world/facility_infrastructure_projector.gd @@ -0,0 +1,1756 @@ +class_name FacilityInfrastructureProjector +extends RefCounted + +## Atomic boundary which turns completed ConstructionService facility proxies +## into authoritative ROUTE subslots and PathService connectors. Primary World +## occupancy is deliberately outside this boundary, so the infrastructure +## projection can coexist with the completed facility occupying the same cells. + +const CommandResultType = preload("res://scripts/core/command_result.gd") +const FacilityDefinitionType = preload("res://scripts/data/facility_definition.gd") +const RouteAdapterType = preload("res://scripts/world/route_subslot_path_adapter.gd") +const SpatialType = preload("res://scripts/world/spatial_subslot_service.gd") +const PathType = preload("res://scripts/world/path_service.gd") + +const SNAPSHOT_SCHEMA_VERSION := 1 +const MAX_REQUEST_ID_LENGTH := 96 +const MAX_INSTANCE_ID_LENGTH := 96 +const MAX_PROJECTIONS := 4_096 +const MAX_PROCESSED_REQUESTS := 4_096 +const MAX_HISTORY_CELL_REFERENCES := 262_144 +const MAX_COST_ENTRIES := 32 +const MAX_COORDINATE_ABS := 100_000 +const MAX_SAFE_INTEGER := 9_007_199_254_740_991 +const MAX_SNAPSHOT_DEPTH := 12 +const MAX_CONTAINER_ITEMS := 262_144 + +const OPERATION_PROJECT: StringName = &"PROJECT" +const OPERATION_UNPROJECT: StringName = &"UNPROJECT" +const OPERATIONS: Array[StringName] = [OPERATION_PROJECT, OPERATION_UNPROJECT] + +const KIND_GUEST_CORRIDOR: StringName = &"guest_corridor" +const KIND_SERVICE_CORRIDOR: StringName = &"service_corridor" +const KIND_STAFF_DOOR: StringName = &"staff_door" +const KIND_GUEST_ELEVATOR: StringName = &"guest_elevator" +const KIND_SERVICE_ELEVATOR: StringName = &"service_elevator" +const SUPPORTED_KINDS: Array[StringName] = [ + KIND_GUEST_CORRIDOR, + KIND_SERVICE_CORRIDOR, + KIND_STAFF_DOOR, +] +const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = [ + KIND_GUEST_ELEVATOR, + KIND_SERVICE_ELEVATOR, +] +const KNOWN_KINDS: Array[StringName] = [ + KIND_GUEST_CORRIDOR, + KIND_SERVICE_CORRIDOR, + KIND_STAFF_DOOR, + KIND_GUEST_ELEVATOR, + KIND_SERVICE_ELEVATOR, +] + +const REASON_UNSUPPORTED_VERTICAL_TOPOLOGY: StringName = &"UNSUPPORTED_VERTICAL_TOPOLOGY" +const QUARANTINE_TRANSACTION_ROLLBACK: StringName = &"TRANSACTION_ROLLBACK_UNCERTAIN" +const QUARANTINE_RESTORE_ROLLBACK: StringName = &"RESTORE_ROLLBACK_UNCERTAIN" +const QUARANTINE_REASONS: Array[StringName] = [ + QUARANTINE_TRANSACTION_ROLLBACK, + QUARANTINE_RESTORE_ROLLBACK, +] + +const ROUTE_ENTITY_PREFIX := "infra.route:" +const CONNECTOR_PREFIX := "infra.connector:" +const ADAPTER_PROJECT_REQUEST_PREFIX := "infra.project:" +const ADAPTER_UNPROJECT_REQUEST_PREFIX := "infra.unproject:" +const STAFF_ACCESS_ID: StringName = &"staff" +const STAFF_DOOR_CONNECTOR_COST := 120 + +const _INSTANCE_KEYS: Array[StringName] = [ + &"instance_id", &"definition_id", &"origin", &"rotation", &"layer", + &"occupied_cells", &"construction_cost", &"source_job_id", &"state", &"completed_at", +] +const _DEFINITION_INPUT_KEYS: Array[StringName] = [ + &"id", &"layer", &"footprint", &"pass_through", &"tags", +] +const _PROJECT_INPUT_KEYS: Array[StringName] = [&"definition", &"instance"] +const _UNPROJECT_INPUT_KEYS: Array[StringName] = [&"instance_id"] +const _PROJECTION_KEYS: Array[StringName] = [ + &"instance_id", &"definition_id", &"kind", &"route_entity_id", &"connector_id", + &"layer", &"cells", &"staff_only", &"pass_through", &"definition_tags", + &"footprint", &"source_job_id", &"origin", &"rotation", &"completed_at", + &"record_fingerprint", +] +const _DETAIL_KEYS: Array[StringName] = [ + &"request_id", &"operation", &"instance_id", &"changed", + &"projection_revision", &"projection", +] + +var projection_revision := 0 +var last_error := "" + +var _adapter: RouteSubslotPathAdapter +var _source: SpatialSubslotService +var _path: PathService +var _configured := false +var _projections: Dictionary = {} +var _processed_requests: Dictionary = {} +var _quarantined := false +var _quarantine_reason: StringName = &"" +var _transaction_active := false + + +## The explicit three-way binding is intentional: exact rollback must restore +## the adapter and both authoritative services, not merely compensate forward. +func configure( + adapter_value: Variant, + source_value: Variant, + path_value: Variant +) -> bool: + if _transaction_active: + return false + if _configured: + return _fail("Facility infrastructure projector is already configured.") + if not adapter_value is RouteSubslotPathAdapter \ + or not source_value is SpatialSubslotService \ + or not path_value is PathService: + return _fail("Facility infrastructure requires RouteSubslotPathAdapter, SpatialSubslotService, and PathService instances.") + var adapter_service: RouteSubslotPathAdapter = adapter_value + var source_service: SpatialSubslotService = source_value + var path_service: PathService = path_value + if not adapter_service.is_bound_to(source_service, path_service): + return _fail("Route adapter is not bound to the supplied authoritative services.") + if adapter_service.is_quarantined() or adapter_service.is_projection_stale(): + return _fail("Route adapter must be fresh and available before projector configuration.") + var source_audit: Dictionary = source_service.validate_invariants() + var adapter_audit: Dictionary = adapter_service.validate_invariants() + if not bool(source_audit.get(&"ok", false)) or not bool(adapter_audit.get(&"ok", false)): + return _fail("Authoritative infrastructure invariants are invalid.") + var path_capture := _capture_path_state(path_service) + if not bool(path_capture[&"ok"]): + return _fail(String(path_capture[&"message"])) + if _has_managed_namespace_collision(source_service, path_service, {}): + return _fail("Managed infrastructure ID namespace already contains untracked state.") + _adapter = adapter_service + _source = source_service + _path = path_service + _configured = true + projection_revision = 0 + _projections.clear() + _processed_requests.clear() + _quarantined = false + _quarantine_reason = &"" + last_error = "" + return true + + +func project_facility( + request_id_value: Variant, + definition_value: Variant, + instance_value: Variant +) -> CommandResult: + if _transaction_active: + return CommandResultType.failed( + CommandResultType.Code.TRANSACTION_CLOSED, + 0, + "Facility infrastructure transaction is already active." + ) + if not _configured: + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Facility infrastructure projector is not configured.") + if not _request_id_is_valid(request_id_value): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Facility infrastructure request ID is invalid.") + var input_result := _canonicalize_live_project_input(definition_value, instance_value) + if not bool(input_result[&"ok"]): + return _failure( + CommandResultType.Code.INVALID_PAYLOAD, + String(input_result[&"message"]), + (input_result.get(&"details", {}) as Dictionary).duplicate(true) + ) + var canonical_input: Dictionary = input_result[&"input"] + var definition_input: Dictionary = canonical_input[&"definition"] + var kind: StringName = definition_input[&"id"] + if kind in UNSUPPORTED_VERTICAL_KINDS: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "Vertical facility projection is unsupported until ordinary adjacent walk edges can be suppressed safely.", + { + &"reason": REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, + &"definition_id": kind, + &"unchanged": true, + } + ) + if kind not in SUPPORTED_KINDS: + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Facility definition is not an infrastructure proxy.") + var request_id := StringName(request_id_value) + var fingerprint := _request_fingerprint(OPERATION_PROJECT, canonical_input) + var replay: Variant = _replay_or_conflict(request_id, fingerprint) + if replay != null: + return replay + if _quarantined: + return _quarantine_failure() + if not _history_capacity_allows(canonical_input): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Facility infrastructure history capacity is exhausted.") + if _projections.size() >= MAX_PROJECTIONS: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Facility infrastructure projection capacity is exhausted.") + var instance_input: Dictionary = canonical_input[&"instance"] + var instance_id: StringName = instance_input[&"instance_id"] + if _projections.has(instance_id): + return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Facility instance is already projected.") + var record := _projection_from_project_input(canonical_input) + if _source.has_entity(record[&"route_entity_id"]) \ + or (not StringName(record[&"connector_id"]).is_empty() and _path.has_connector(record[&"connector_id"])): + return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Stable infrastructure ID collides with existing authoritative state.") + if not _dependencies_match_current_state(): + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Facility infrastructure dependencies are stale or invalid.") + + var owned_before := _capture_owned_state() + _transaction_active = true + var guard_state := _capture_owned_state() + var dependency_capture := _capture_dependencies() + if not bool(dependency_capture[&"ok"]) \ + or not _owned_state_matches(guard_state) \ + or not _dependency_state_matches_capture(dependency_capture): + return _rollback_failure( + owned_before, + dependency_capture, + "Facility infrastructure capture failed or was tampered with." + ) + + var route_result := _adapter.place_route( + StringName(ADAPTER_PROJECT_REQUEST_PREFIX + String(request_id)), + StringName(record[&"route_entity_id"]), + (record[&"cells"] as Array).duplicate() + ) + if not route_result.is_success() or not _owned_state_matches(guard_state): + return _rollback_failure( + owned_before, + dependency_capture, + "ROUTE projection failed or reentrant callback tampering was detected." + ) + if kind == KIND_STAFF_DOOR: + var back_cell: Vector3i = record[&"cells"][0] + var front_cell := Vector3i(back_cell.x, back_cell.y, PathType.FRONT) + var connector_ok := _path.configure_connector( + StringName(record[&"connector_id"]), + front_cell, + back_cell, + STAFF_DOOR_CONNECTOR_COST, + true, + STAFF_ACCESS_ID, + true + ) + if not connector_ok or not _owned_state_matches(guard_state): + return _rollback_failure( + owned_before, + dependency_capture, + "Staff-door connector projection failed or callback tampering was detected." + ) + var candidate_projections := _projections.duplicate(true) + candidate_projections[instance_id] = record.duplicate(true) + if not _dependencies_match_projection_map(candidate_projections) \ + or not _owned_state_matches(guard_state): + return _rollback_failure( + owned_before, + dependency_capture, + "Facility infrastructure postcondition failed." + ) + + projection_revision += 1 + _projections[instance_id] = record.duplicate(true) + var details := _result_details( + request_id, + OPERATION_PROJECT, + record, + projection_revision + ) + _processed_requests[request_id] = { + &"operation": OPERATION_PROJECT, + &"instance_id": instance_id, + &"input": canonical_input.duplicate(true), + &"fingerprint": fingerprint, + &"details": details.duplicate(true), + } + _transaction_active = false + last_error = "" + return _success(details) + + +## Signal-friendly alias: ConstructionService.facility_completed can pass its +## completed instance Dictionary after the caller resolves the definition. +func project_completed_facility( + request_id_value: Variant, + definition_value: Variant, + instance_value: Variant +) -> CommandResult: + return project_facility(request_id_value, definition_value, instance_value) + + +func unproject_facility(request_id_value: Variant, instance_id_value: Variant) -> CommandResult: + if _transaction_active: + return CommandResultType.failed( + CommandResultType.Code.TRANSACTION_CLOSED, + 0, + "Facility infrastructure transaction is already active." + ) + if not _configured: + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Facility infrastructure projector is not configured.") + if not _request_id_is_valid(request_id_value) or not _instance_id_is_valid(instance_id_value): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Facility infrastructure request or instance ID is invalid.") + var request_id := StringName(request_id_value) + var instance_id := StringName(instance_id_value) + var canonical_input := {&"instance_id": instance_id} + var fingerprint := _request_fingerprint(OPERATION_UNPROJECT, canonical_input) + var replay: Variant = _replay_or_conflict(request_id, fingerprint) + if replay != null: + return replay + if _quarantined: + return _quarantine_failure() + if not _history_capacity_allows(canonical_input): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Facility infrastructure history capacity is exhausted.") + if not _projections.has(instance_id): + return _failure(CommandResultType.Code.NOT_FOUND, "Facility infrastructure projection was not found.") + if not _dependencies_match_current_state(): + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Facility infrastructure dependencies are stale or invalid.") + var record: Dictionary = _projections[instance_id] + + var owned_before := _capture_owned_state() + _transaction_active = true + var guard_state := _capture_owned_state() + var dependency_capture := _capture_dependencies() + if not bool(dependency_capture[&"ok"]) \ + or not _owned_state_matches(guard_state) \ + or not _dependency_state_matches_capture(dependency_capture): + return _rollback_failure( + owned_before, + dependency_capture, + "Facility infrastructure capture failed or was tampered with." + ) + var connector_id: StringName = record[&"connector_id"] + if not connector_id.is_empty(): + if not _path.remove_connector(connector_id) or not _owned_state_matches(guard_state): + return _rollback_failure( + owned_before, + dependency_capture, + "Staff-door connector removal failed or callback tampering was detected." + ) + var route_result := _adapter.remove_route( + StringName(ADAPTER_UNPROJECT_REQUEST_PREFIX + String(request_id)), + StringName(record[&"route_entity_id"]) + ) + if not route_result.is_success() or not _owned_state_matches(guard_state): + return _rollback_failure( + owned_before, + dependency_capture, + "ROUTE unprojection failed or callback tampering was detected." + ) + var candidate_projections := _projections.duplicate(true) + candidate_projections.erase(instance_id) + if _source.has_entity(record[&"route_entity_id"]) \ + or (not connector_id.is_empty() and _path.has_connector(connector_id)) \ + or not _dependencies_match_projection_map(candidate_projections) \ + or not _owned_state_matches(guard_state): + return _rollback_failure(owned_before, dependency_capture, "Facility infrastructure unprojection postcondition failed.") + + projection_revision += 1 + _projections.erase(instance_id) + var details := _result_details( + request_id, + OPERATION_UNPROJECT, + record, + projection_revision + ) + _processed_requests[request_id] = { + &"operation": OPERATION_UNPROJECT, + &"instance_id": instance_id, + &"input": canonical_input.duplicate(true), + &"fingerprint": fingerprint, + &"details": details.duplicate(true), + } + _transaction_active = false + last_error = "" + return _success(details) + + +func is_configured() -> bool: + return _configured + + +func is_quarantined() -> bool: + return _quarantined + + +func get_quarantine_reason() -> StringName: + return _quarantine_reason + + +func has_projection(instance_id: StringName) -> bool: + return _projections.has(instance_id) + + +func get_projection(instance_id: StringName) -> Dictionary: + if not _projections.has(instance_id): + return {} + return (_projections[instance_id] as Dictionary).duplicate(true) + + +func get_projection_ids() -> Array[StringName]: + return _sorted_names(_projections) + + +func get_processed_request_ids() -> Array[StringName]: + return _sorted_names(_processed_requests) + + +func is_projection_stale() -> bool: + return not _dependencies_match_current_state() + + +func get_status() -> Dictionary: + return { + &"configured": _configured, + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + &"projection_revision": projection_revision, + &"projection_count": _projections.size(), + &"stale": not _dependencies_match_current_state(), + }.duplicate(true) + + +func create_snapshot() -> Dictionary: + if not _configured: + return {} + var projection_rows: Array[Dictionary] = [] + for instance_id: StringName in _sorted_names(_projections): + projection_rows.append(_serialize_projection(_projections[instance_id])) + var request_rows: Array[Dictionary] = [] + for request_id: StringName in _sorted_names(_processed_requests): + var request: Dictionary = _processed_requests[request_id] + request_rows.append({ + &"request_id": String(request_id), + &"operation": String(request[&"operation"]), + &"instance_id": String(request[&"instance_id"]), + &"input": _serialize_request_input(request[&"operation"], request[&"input"]), + &"fingerprint": String(request[&"fingerprint"]), + &"details": _serialize_details(request[&"details"]), + }) + return { + &"schema_version": SNAPSHOT_SCHEMA_VERSION, + &"projection_revision": projection_revision, + &"projections": projection_rows, + &"processed_requests": request_rows, + &"quarantined": _quarantined, + &"quarantine_reason": String(_quarantine_reason), + } + + +## Restores projector-owned metadata after the caller restores Spatial, Path, +## and RouteAdapter snapshots. The three dependencies are captured and +## crosslinked in the same atomic boundary so a hostile callback cannot turn a +## rejected restore into a partial dependency mutation. +func restore_snapshot(snapshot: Dictionary) -> bool: + if _transaction_active: + return false + if not _configured: + return _fail("Facility infrastructure projector must be configured before restore.") + var parsed := _parse_snapshot(snapshot) + if not bool(parsed[&"ok"]): + return _fail(String(parsed[&"message"])) + var owned_before := _capture_owned_state() + _transaction_active = true + var guard_state := _capture_owned_state() + var dependency_capture := _capture_dependencies() + if not bool(dependency_capture[&"ok"]) \ + or not _owned_state_matches(guard_state) \ + or not _dependency_state_matches_capture(dependency_capture): + return _restore_failure(owned_before, dependency_capture, "Snapshot dependency capture failed or was tampered with.") + if not bool(parsed[&"quarantined"]) \ + and not _dependencies_match_projection_map(parsed[&"projections"]): + return _restore_failure(owned_before, dependency_capture, "Snapshot projection metadata does not match live infrastructure.") + if not _owned_state_matches(guard_state) \ + or not _dependency_state_matches_capture(dependency_capture): + return _restore_failure(owned_before, dependency_capture, "Snapshot validation callback tampered with projector state.") + projection_revision = int(parsed[&"projection_revision"]) + _projections = (parsed[&"projections"] as Dictionary).duplicate(true) + _processed_requests = (parsed[&"processed_requests"] as Dictionary).duplicate(true) + _quarantined = bool(parsed[&"quarantined"]) + _quarantine_reason = parsed[&"quarantine_reason"] + _transaction_active = false + last_error = "" + return true + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + if not _configured or _adapter == null or _source == null or _path == null: + errors.append("Facility infrastructure projector is not configured.") + return {&"ok": false, &"errors": errors} + var parsed := _parse_snapshot(create_snapshot()) + if not bool(parsed[&"ok"]): + errors.append(String(parsed[&"message"])) + if not _quarantined and not _dependencies_match_current_state(): + errors.append("Facility infrastructure live crosslinks are stale.") + return {&"ok": errors.is_empty(), &"errors": errors} + + +func _canonicalize_live_project_input(definition_value: Variant, instance_value: Variant) -> Dictionary: + if not definition_value is FacilityDefinition: + return {&"ok": false, &"message": "Infrastructure projection requires a FacilityDefinition resource."} + if not instance_value is Dictionary or not _keys_match(instance_value, _INSTANCE_KEYS): + return {&"ok": false, &"message": "Completed facility instance fields are not canonical."} + var definition: FacilityDefinition = definition_value + if not definition.is_valid(): + return {&"ok": false, &"message": "Facility definition is invalid."} + if not _name_is_valid(definition.id, MAX_INSTANCE_ID_LENGTH) or definition.id not in KNOWN_KINDS: + return {&"ok": false, &"message": "Facility definition ID is not a recognized infrastructure proxy."} + var schema := _kind_schema(definition.id) + var tags_result := _normalize_names(definition.tags, false, 16, "Facility definition tags") + if not bool(tags_result[&"ok"]): + return tags_result + var canonical_tags: Array[StringName] = tags_result[&"names"] + if ( + definition.layer != int(schema[&"layer"]) + or definition.footprint != schema[&"footprint"] + or definition.pass_through != bool(schema[&"pass_through"]) + or canonical_tags != schema[&"tags"] + ): + return {&"ok": false, &"message": "Facility definition layer, footprint, pass-through flag, or tags diverge from its infrastructure schema."} + var canonical_definition := { + &"id": definition.id, + &"layer": definition.layer, + &"footprint": definition.footprint, + &"pass_through": definition.pass_through, + &"tags": canonical_tags, + } + var instance_result := _canonicalize_live_instance( + instance_value, + canonical_definition, + definition.allowed_rotations + ) + if not bool(instance_result[&"ok"]): + return instance_result + return { + &"ok": true, + &"input": { + &"definition": canonical_definition, + &"instance": instance_result[&"instance"], + }, + } + + +func _canonicalize_live_instance( + value: Dictionary, + definition: Dictionary, + allowed_rotations: Array[int] +) -> Dictionary: + for key: StringName in [&"instance_id", &"definition_id", &"source_job_id", &"state"]: + if typeof(value.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Completed facility identifier/state types are invalid."} + var instance_id := StringName(value[&"instance_id"]) + var definition_id := StringName(value[&"definition_id"]) + var source_job_id := StringName(value[&"source_job_id"]) + var state := StringName(value[&"state"]) + if not _instance_id_is_valid(instance_id) \ + or not _name_is_valid(source_job_id, MAX_INSTANCE_ID_LENGTH) \ + or definition_id != definition[&"id"] \ + or state != &"ACTIVE": + return {&"ok": false, &"message": "Completed facility IDs, definition crosslink, or ACTIVE state are invalid."} + if not value[&"origin"] is Vector3i \ + or typeof(value[&"rotation"]) != TYPE_INT \ + or typeof(value[&"layer"]) != TYPE_INT \ + or typeof(value[&"completed_at"]) != TYPE_INT: + return {&"ok": false, &"message": "Completed facility coordinate or integer field types are invalid."} + var origin: Vector3i = value[&"origin"] + var rotation := int(value[&"rotation"]) + var layer := int(value[&"layer"]) + var completed_at := int(value[&"completed_at"]) + if not _cell_is_valid(origin) \ + or rotation not in [0, 90, 180, 270] \ + or rotation not in allowed_rotations \ + or layer != int(definition[&"layer"]) \ + or origin.z != layer \ + or completed_at < 0 \ + or completed_at > MAX_SAFE_INTEGER: + return {&"ok": false, &"message": "Completed facility origin, rotation, layer, or completion time is invalid."} + var cells_result := _normalize_cells(value[&"occupied_cells"], false, SpatialType.MAX_CELLS_PER_ENTITY, "Completed facility cells") + if not bool(cells_result[&"ok"]): + return cells_result + var cells: Array[Vector3i] = cells_result[&"cells"] + if value[&"occupied_cells"] != cells: + return {&"ok": false, &"message": "Completed facility cells must be in canonical order."} + var expected_cells := _footprint_cells(origin, definition[&"footprint"], rotation) + if cells != expected_cells: + return {&"ok": false, &"message": "Completed facility cells do not match the rotated definition footprint."} + var cost_result := _normalize_cost(value[&"construction_cost"]) + if not bool(cost_result[&"ok"]): + return cost_result + return { + &"ok": true, + &"instance": { + &"instance_id": instance_id, + &"definition_id": definition_id, + &"origin": origin, + &"rotation": rotation, + &"layer": layer, + &"occupied_cells": cells, + &"construction_cost": cost_result[&"cost"], + &"source_job_id": source_job_id, + &"state": state, + &"completed_at": completed_at, + }, + } + + +static func _kind_schema(kind: StringName) -> Dictionary: + match kind: + KIND_GUEST_CORRIDOR: + return { + &"layer": PathType.FRONT, + &"footprint": Vector2i.ONE, + &"pass_through": true, + &"staff_only": false, + &"tags": _sorted_name_array([ + &"corridor", &"guest_access", &"movement", &"route_subslot", &"schema_facility_proxy", + ]), + } + KIND_SERVICE_CORRIDOR: + return { + &"layer": PathType.BACK, + &"footprint": Vector2i.ONE, + &"pass_through": true, + &"staff_only": true, + &"tags": _sorted_name_array([ + &"corridor", &"movement", &"route_subslot", &"schema_facility_proxy", &"staff_only", + ]), + } + KIND_STAFF_DOOR: + return { + &"layer": PathType.BACK, + &"footprint": Vector2i.ONE, + &"pass_through": true, + &"staff_only": true, + &"tags": _sorted_name_array([ + &"cross_layer_connector", &"movement", &"schema_facility_proxy", &"staff_only", + ]), + } + KIND_GUEST_ELEVATOR: + return { + &"layer": PathType.FRONT, + &"footprint": Vector2i(2, 2), + &"pass_through": true, + &"staff_only": false, + &"tags": _sorted_name_array([ + &"guest_access", &"movement", &"schema_facility_proxy", &"vertical_connector", + ]), + } + KIND_SERVICE_ELEVATOR: + return { + &"layer": PathType.BACK, + &"footprint": Vector2i(3, 2), + &"pass_through": true, + &"staff_only": true, + &"tags": _sorted_name_array([ + &"movement", &"schema_facility_proxy", &"staff_only", &"vertical_connector", + ]), + } + return {} + + +static func _projection_from_project_input(input: Dictionary) -> Dictionary: + var definition: Dictionary = input[&"definition"] + var instance: Dictionary = input[&"instance"] + var kind: StringName = definition[&"id"] + var instance_id: StringName = instance[&"instance_id"] + var record := { + &"instance_id": instance_id, + &"definition_id": kind, + &"kind": kind, + &"route_entity_id": StringName(ROUTE_ENTITY_PREFIX + String(instance_id)), + &"connector_id": StringName(CONNECTOR_PREFIX + String(instance_id)) if kind == KIND_STAFF_DOOR else &"", + &"layer": int(definition[&"layer"]), + &"cells": (instance[&"occupied_cells"] as Array).duplicate(), + &"staff_only": bool(_kind_schema(kind)[&"staff_only"]), + &"pass_through": bool(definition[&"pass_through"]), + &"definition_tags": (definition[&"tags"] as Array).duplicate(), + &"footprint": definition[&"footprint"], + &"source_job_id": instance[&"source_job_id"], + &"origin": instance[&"origin"], + &"rotation": int(instance[&"rotation"]), + &"completed_at": int(instance[&"completed_at"]), + &"record_fingerprint": "", + } + record[&"record_fingerprint"] = _projection_fingerprint(record) + return record + + +static func _projection_fingerprint(record: Dictionary) -> String: + var canonical := "projection|" + for key: StringName in [&"instance_id", &"definition_id", &"kind", &"route_entity_id", &"connector_id", &"source_job_id"]: + var text := String(record[key]) + canonical += "%d:%s|" % [text.length(), text] + canonical += "layer=%d|staff=%d|pass=%d|footprint=%d,%d|rotation=%d|completed=%d|" % [ + int(record[&"layer"]), int(bool(record[&"staff_only"])), int(bool(record[&"pass_through"])), + (record[&"footprint"] as Vector2i).x, (record[&"footprint"] as Vector2i).y, + int(record[&"rotation"]), int(record[&"completed_at"]), + ] + var origin: Vector3i = record[&"origin"] + canonical += "origin=%d,%d,%d|tags=" % [origin.x, origin.y, origin.z] + for tag: StringName in record[&"definition_tags"]: + canonical += "%d:%s;" % [String(tag).length(), String(tag)] + canonical += "|cells=" + for cell: Vector3i in record[&"cells"]: + canonical += "%d,%d,%d;" % [cell.x, cell.y, cell.z] + return canonical.sha256_text() + + +static func _request_fingerprint(operation: StringName, input: Dictionary) -> String: + var canonical := "%s|" % String(operation) + if operation == OPERATION_UNPROJECT: + var instance_text := String(input[&"instance_id"]) + canonical += "%d:%s" % [instance_text.length(), instance_text] + return canonical.sha256_text() + var definition: Dictionary = input[&"definition"] + var instance: Dictionary = input[&"instance"] + canonical += "definition=%s|layer=%d|footprint=%d,%d|pass=%d|tags=" % [ + String(definition[&"id"]), int(definition[&"layer"]), + (definition[&"footprint"] as Vector2i).x, (definition[&"footprint"] as Vector2i).y, + int(bool(definition[&"pass_through"])), + ] + for tag: StringName in definition[&"tags"]: + canonical += "%d:%s;" % [String(tag).length(), String(tag)] + canonical += "|instance=" + for key: StringName in [&"instance_id", &"definition_id", &"source_job_id", &"state"]: + var text := String(instance[key]) + canonical += "%d:%s|" % [text.length(), text] + var origin: Vector3i = instance[&"origin"] + canonical += "origin=%d,%d,%d|rotation=%d|layer=%d|completed=%d|cells=" % [ + origin.x, origin.y, origin.z, int(instance[&"rotation"]), int(instance[&"layer"]), + int(instance[&"completed_at"]), + ] + for cell: Vector3i in instance[&"occupied_cells"]: + canonical += "%d,%d,%d;" % [cell.x, cell.y, cell.z] + canonical += "|cost=" + for key: StringName in _sorted_names(instance[&"construction_cost"]): + canonical += "%d:%s=%d;" % [String(key).length(), String(key), int(instance[&"construction_cost"][key])] + return canonical.sha256_text() + + +func _replay_or_conflict(request_id: StringName, fingerprint: String) -> Variant: + if not _processed_requests.has(request_id): + return null + var previous: Dictionary = _processed_requests[request_id] + if String(previous[&"fingerprint"]) != fingerprint: + return _failure( + CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, + "Facility infrastructure request ID conflicts with earlier input." + ) + var result := _success((previous[&"details"] as Dictionary).duplicate(true)) + result.is_replay = true + return result + + +static func _result_details( + request_id: StringName, + operation: StringName, + record: Dictionary, + revision: int +) -> Dictionary: + return { + &"request_id": request_id, + &"operation": operation, + &"instance_id": record[&"instance_id"], + &"changed": true, + &"projection_revision": revision, + &"projection": record.duplicate(true), + } + + +func _capture_owned_state() -> Dictionary: + return { + &"adapter": _adapter, + &"source": _source, + &"path": _path, + &"configured": _configured, + &"projection_revision": projection_revision, + &"projections": _projections.duplicate(true), + &"processed_requests": _processed_requests.duplicate(true), + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + &"transaction_active": _transaction_active, + &"last_error": last_error, + } + + +func _owned_state_matches(capture: Dictionary) -> bool: + return ( + _adapter == capture[&"adapter"] + and _source == capture[&"source"] + and _path == capture[&"path"] + and _configured == bool(capture[&"configured"]) + and projection_revision == int(capture[&"projection_revision"]) + and _projections == capture[&"projections"] + and _processed_requests == capture[&"processed_requests"] + and _quarantined == bool(capture[&"quarantined"]) + and _quarantine_reason == capture[&"quarantine_reason"] + and _transaction_active == bool(capture[&"transaction_active"]) + and last_error == String(capture[&"last_error"]) + ) + + +func _restore_owned_state(capture: Dictionary) -> void: + _adapter = capture[&"adapter"] + _source = capture[&"source"] + _path = capture[&"path"] + _configured = bool(capture[&"configured"]) + projection_revision = int(capture[&"projection_revision"]) + _projections = (capture[&"projections"] as Dictionary).duplicate(true) + _processed_requests = (capture[&"processed_requests"] as Dictionary).duplicate(true) + _quarantined = bool(capture[&"quarantined"]) + _quarantine_reason = capture[&"quarantine_reason"] + _transaction_active = bool(capture[&"transaction_active"]) + last_error = String(capture[&"last_error"]) + + +func _capture_dependencies() -> Dictionary: + if _adapter == null or _source == null or _path == null: + return {&"ok": false, &"message": "Facility infrastructure dependencies are unavailable."} + var source_snapshot := _source.create_snapshot() + var adapter_snapshot := _adapter.create_snapshot() + var path_capture := _capture_path_state(_path) + if source_snapshot.is_empty() or adapter_snapshot.is_empty() or not bool(path_capture[&"ok"]): + return { + &"ok": false, + &"message": String(path_capture.get(&"message", "Facility infrastructure dependency snapshot is unavailable.")), + } + return { + &"ok": true, + &"source_snapshot": source_snapshot.duplicate(true), + &"adapter_snapshot": adapter_snapshot.duplicate(true), + &"path_capture": path_capture, + } + + +func _rollback_failure( + owned_before: Dictionary, + dependency_capture: Dictionary, + message: String +) -> CommandResult: + var rollback_ok := bool(dependency_capture.get(&"ok", false)) \ + and _restore_dependencies_exact(dependency_capture) + _restore_owned_state(owned_before) + if not rollback_ok: + _quarantine(QUARANTINE_TRANSACTION_ROLLBACK) + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "%s Exact rollback is uncertain; projector was quarantined." % message, + { + &"rollback_ok": false, + &"quarantined": true, + &"quarantine_reason": _quarantine_reason, + } + ) + return _failure( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + "%s Adapter, Spatial, and Path state were restored exactly." % message, + {&"rollback_ok": true, &"quarantined": false} + ) + + +func _restore_failure( + owned_before: Dictionary, + dependency_capture: Dictionary, + message: String +) -> bool: + var rollback_ok := bool(dependency_capture.get(&"ok", false)) \ + and _restore_dependencies_exact(dependency_capture) + _restore_owned_state(owned_before) + if not rollback_ok: + _quarantine(QUARANTINE_RESTORE_ROLLBACK) + return _fail("%s Exact rollback is uncertain; projector was quarantined." % message) + return _fail(message) + + +func _restore_dependencies_exact(capture: Dictionary) -> bool: + var source_snapshot: Dictionary = capture[&"source_snapshot"] + var adapter_snapshot: Dictionary = capture[&"adapter_snapshot"] + var path_capture: Dictionary = capture[&"path_capture"] + for _attempt: int in range(2): + _source.restore_snapshot(source_snapshot.duplicate(true)) + _restore_path_once(_path, path_capture) + _adapter.restore_snapshot(adapter_snapshot.duplicate(true)) + if ( + _source.create_snapshot() == source_snapshot + and _path_state_matches_capture(_path, path_capture) + and _adapter.create_snapshot() == adapter_snapshot + ): + return true + return false + + +func _dependency_state_matches_capture(capture: Dictionary) -> bool: + if not bool(capture.get(&"ok", false)): + return false + return ( + _source.create_snapshot() == capture[&"source_snapshot"] + and _adapter.create_snapshot() == capture[&"adapter_snapshot"] + and _path_state_matches_capture(_path, capture[&"path_capture"]) + ) + + +static func _capture_path_state(path_service: PathService) -> Dictionary: + var snapshot := path_service.create_snapshot() + var cells_result := _path_cells_from_snapshot(snapshot) + if not bool(cells_result[&"ok"]): + return cells_result + for cell: Vector3i in cells_result[&"cells"]: + if not path_service.has_cell(cell): + return {&"ok": false, &"message": "Path snapshot topology is not materialized in the live graph."} + return { + &"ok": true, + &"snapshot": snapshot.duplicate(true), + &"cells": (cells_result[&"cells"] as Array).duplicate(), + } + + +static func _path_cells_from_snapshot(snapshot: Dictionary) -> Dictionary: + var rows: Variant = snapshot.get(&"cell_traffic", null) + if not rows is Array or rows.size() > RouteAdapterType.MAX_PROJECTED_CELLS: + return {&"ok": false, &"message": "Path snapshot does not expose a bounded cell topology."} + var cells: Array[Vector3i] = [] + var seen: Dictionary = {} + for raw_row: Variant in rows: + if not raw_row is Dictionary: + return {&"ok": false, &"message": "Path snapshot topology row is malformed."} + var raw_cell: Variant = raw_row.get(&"cell", raw_row.get("cell", null)) + if not _cell_is_valid(raw_cell) or seen.has(raw_cell): + return {&"ok": false, &"message": "Path snapshot contains an invalid or duplicate cell."} + seen[raw_cell] = true + cells.append(raw_cell) + cells.sort_custom(_cell_less) + return {&"ok": true, &"cells": cells} + + +static func _restore_path_once(path_service: PathService, capture: Dictionary) -> void: + var cells: Array = capture[&"cells"] + var snapshot: Dictionary = capture[&"snapshot"] + path_service.rebuild_graph(cells.duplicate()) + path_service.restore_snapshot(snapshot.duplicate(true)) + path_service.rebuild_graph(cells.duplicate()) + + +static func _path_state_matches_capture(path_service: PathService, capture: Dictionary) -> bool: + if path_service.create_snapshot() != capture[&"snapshot"]: + return false + var current := _capture_path_state(path_service) + return bool(current[&"ok"]) and current[&"cells"] == capture[&"cells"] + + +func _dependencies_match_current_state() -> bool: + return _configured \ + and not _quarantined \ + and _dependencies_match_projection_map(_projections) + + +func _dependencies_match_projection_map(projections: Dictionary) -> bool: + if _adapter == null or _source == null or _path == null: + return false + if not _adapter.is_bound_to(_source, _path) \ + or _adapter.is_quarantined() \ + or _adapter.is_projection_stale(): + return false + if not bool(_source.validate_invariants().get(&"ok", false)) \ + or not bool(_adapter.validate_invariants().get(&"ok", false)): + return false + for instance_id: StringName in _sorted_names(projections): + var record: Dictionary = projections[instance_id] + if record[&"instance_id"] != instance_id or not _projection_live_matches(record): + return false + return not _has_managed_namespace_collision(_source, _path, projections) + + +func _projection_live_matches(record: Dictionary) -> bool: + var route_entity_id: StringName = record[&"route_entity_id"] + if not _source.has_entity(route_entity_id) \ + or _source.get_entity_category(route_entity_id) != _source.CATEGORY_ROUTE \ + or _source.get_entity_cells(route_entity_id) != record[&"cells"]: + return false + var connector_id: StringName = record[&"connector_id"] + if record[&"kind"] == KIND_STAFF_DOOR: + if connector_id.is_empty() or not _path.has_connector(connector_id): + return false + return _path.get_connector(connector_id) == _expected_staff_connector(record) + return connector_id.is_empty() + + +static func _expected_staff_connector(record: Dictionary) -> Dictionary: + var back_cell: Vector3i = record[&"cells"][0] + return { + &"id": record[&"connector_id"], + &"from": Vector3i(back_cell.x, back_cell.y, PathType.FRONT), + &"to": back_cell, + &"cost": STAFF_DOOR_CONNECTOR_COST, + &"bidirectional": true, + &"access_id": STAFF_ACCESS_ID, + &"enabled": true, + &"movement_type": PathType.MOVEMENT_CONNECTOR, + &"dynamic_cost": 0, + &"operational_state": PathType.CONNECTOR_OPERATIONAL, + &"cabin_capacity": 8, + &"queue_capacity": 256, + &"cabin_state": PathType.CABIN_IDLE, + &"current_floor": back_cell.y, + &"travel_minutes_per_floor": 1, + &"request_queue": [], + } + + +static func _has_managed_namespace_collision( + source_service: SpatialSubslotService, + path_service: PathService, + projections: Dictionary +) -> bool: + var expected_routes: Dictionary = {} + var expected_connectors: Dictionary = {} + for record_value: Variant in projections.values(): + if not record_value is Dictionary: + return true + var record: Dictionary = record_value + expected_routes[record[&"route_entity_id"]] = true + if not StringName(record[&"connector_id"]).is_empty(): + expected_connectors[record[&"connector_id"]] = true + for entity_id: StringName in source_service.get_entity_ids(source_service.CATEGORY_ROUTE): + if String(entity_id).begins_with(ROUTE_ENTITY_PREFIX) and not expected_routes.has(entity_id): + return true + var path_snapshot := path_service.create_snapshot() + var connector_rows: Variant = path_snapshot.get(&"connectors", null) + if not connector_rows is Array: + return true + for raw_connector: Variant in connector_rows: + if not raw_connector is Dictionary: + return true + var connector_id_value: Variant = raw_connector.get(&"id", raw_connector.get("id", null)) + if typeof(connector_id_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return true + var connector_id := StringName(connector_id_value) + if String(connector_id).begins_with(CONNECTOR_PREFIX) and not expected_connectors.has(connector_id): + return true + return false + + +func _quarantine(reason: StringName) -> void: + _quarantined = true + _quarantine_reason = reason + _transaction_active = false + + +func _quarantine_failure() -> CommandResult: + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "Facility infrastructure projector is quarantined until an exact snapshot is restored.", + {&"quarantine_reason": _quarantine_reason} + ) + + +static func _serialize_projection(record: Dictionary) -> Dictionary: + var tags: Array[String] = [] + for tag: StringName in record[&"definition_tags"]: + tags.append(String(tag)) + return { + &"instance_id": String(record[&"instance_id"]), + &"definition_id": String(record[&"definition_id"]), + &"kind": String(record[&"kind"]), + &"route_entity_id": String(record[&"route_entity_id"]), + &"connector_id": String(record[&"connector_id"]), + &"layer": int(record[&"layer"]), + &"cells": (record[&"cells"] as Array).duplicate(), + &"staff_only": bool(record[&"staff_only"]), + &"pass_through": bool(record[&"pass_through"]), + &"definition_tags": tags, + &"footprint": record[&"footprint"], + &"source_job_id": String(record[&"source_job_id"]), + &"origin": record[&"origin"], + &"rotation": int(record[&"rotation"]), + &"completed_at": int(record[&"completed_at"]), + &"record_fingerprint": String(record[&"record_fingerprint"]), + } + + +static func _serialize_request_input(operation: StringName, input: Dictionary) -> Dictionary: + if operation == OPERATION_UNPROJECT: + return {&"instance_id": String(input[&"instance_id"])} + var definition: Dictionary = input[&"definition"] + var instance: Dictionary = input[&"instance"] + var tags: Array[String] = [] + for tag: StringName in definition[&"tags"]: + tags.append(String(tag)) + var cost: Dictionary = {} + for cost_key: StringName in _sorted_names(instance[&"construction_cost"]): + cost[String(cost_key)] = int(instance[&"construction_cost"][cost_key]) + return { + &"definition": { + &"id": String(definition[&"id"]), + &"layer": int(definition[&"layer"]), + &"footprint": definition[&"footprint"], + &"pass_through": bool(definition[&"pass_through"]), + &"tags": tags, + }, + &"instance": { + &"instance_id": String(instance[&"instance_id"]), + &"definition_id": String(instance[&"definition_id"]), + &"origin": instance[&"origin"], + &"rotation": int(instance[&"rotation"]), + &"layer": int(instance[&"layer"]), + &"occupied_cells": (instance[&"occupied_cells"] as Array).duplicate(), + &"construction_cost": cost, + &"source_job_id": String(instance[&"source_job_id"]), + &"state": String(instance[&"state"]), + &"completed_at": int(instance[&"completed_at"]), + }, + } + + +static func _serialize_details(details: Dictionary) -> Dictionary: + return { + &"request_id": String(details[&"request_id"]), + &"operation": String(details[&"operation"]), + &"instance_id": String(details[&"instance_id"]), + &"changed": bool(details[&"changed"]), + &"projection_revision": int(details[&"projection_revision"]), + &"projection": _serialize_projection(details[&"projection"]), + } + + +func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + if not _variant_depth_is_valid(snapshot, 0): + return {&"ok": false, &"message": "Facility infrastructure snapshot depth or capacity is exceeded."} + if not _keys_match(snapshot, [ + &"schema_version", &"projection_revision", &"projections", &"processed_requests", + &"quarantined", &"quarantine_reason", + ]): + return {&"ok": false, &"message": "Facility infrastructure snapshot root fields are not canonical."} + if typeof(snapshot.get(&"schema_version", null)) != TYPE_INT \ + or int(snapshot[&"schema_version"]) != SNAPSHOT_SCHEMA_VERSION: + return {&"ok": false, &"message": "Unsupported FacilityInfrastructureProjector snapshot version."} + if typeof(snapshot.get(&"projection_revision", null)) != TYPE_INT: + return {&"ok": false, &"message": "Facility infrastructure projection revision must be an integer."} + var next_revision := int(snapshot[&"projection_revision"]) + if next_revision < 0 or next_revision > MAX_SAFE_INTEGER: + return {&"ok": false, &"message": "Facility infrastructure projection revision is outside supported bounds."} + if typeof(snapshot.get(&"quarantined", null)) != TYPE_BOOL \ + or typeof(snapshot.get(&"quarantine_reason", null)) != TYPE_STRING: + return {&"ok": false, &"message": "Facility infrastructure quarantine fields have invalid types."} + var next_quarantined := bool(snapshot[&"quarantined"]) + var next_quarantine_reason := StringName(snapshot[&"quarantine_reason"]) + if not _quarantine_state_is_valid(next_quarantined, next_quarantine_reason): + return {&"ok": false, &"message": "Facility infrastructure quarantine fields are inconsistent."} + var raw_projections: Variant = snapshot.get(&"projections", null) + var raw_requests: Variant = snapshot.get(&"processed_requests", null) + if not raw_projections is Array \ + or raw_projections.size() > MAX_PROJECTIONS \ + or not raw_requests is Array \ + or raw_requests.size() > MAX_PROCESSED_REQUESTS: + return {&"ok": false, &"message": "Facility infrastructure snapshot collections are invalid or exceed capacity."} + + var next_projections: Dictionary = {} + var prior_instance := "" + for raw_projection: Variant in raw_projections: + var parsed_projection := _parse_projection(raw_projection) + if not bool(parsed_projection[&"ok"]): + return parsed_projection + var record: Dictionary = parsed_projection[&"projection"] + var instance_text := String(record[&"instance_id"]) + if not prior_instance.is_empty() and instance_text <= prior_instance: + return {&"ok": false, &"message": "Facility infrastructure projections are not in lexical order."} + prior_instance = instance_text + next_projections[record[&"instance_id"]] = record + + var next_requests: Dictionary = {} + var requests_by_revision: Dictionary = {} + var prior_request := "" + var history_cells := 0 + for raw_request: Variant in raw_requests: + var parsed_request := _parse_processed_request(raw_request, next_revision) + if not bool(parsed_request[&"ok"]): + return parsed_request + var request_id: StringName = parsed_request[&"request_id"] + var request_text := String(request_id) + if not prior_request.is_empty() and request_text <= prior_request: + return {&"ok": false, &"message": "Facility infrastructure request history is not in lexical order."} + prior_request = request_text + var request: Dictionary = parsed_request[&"request"] + var detail_revision := int(request[&"details"][&"projection_revision"]) + if requests_by_revision.has(detail_revision): + return {&"ok": false, &"message": "Facility infrastructure history contains duplicate revisions."} + requests_by_revision[detail_revision] = request + history_cells += (request[&"details"][&"projection"][&"cells"] as Array).size() + if history_cells > MAX_HISTORY_CELL_REFERENCES: + return {&"ok": false, &"message": "Facility infrastructure history cell budget is exceeded."} + next_requests[request_id] = request + if next_revision != next_requests.size(): + return {&"ok": false, &"message": "Facility infrastructure revision does not match successful history length."} + for revision: int in range(1, next_revision + 1): + if not requests_by_revision.has(revision): + return {&"ok": false, &"message": "Facility infrastructure history revisions are not contiguous."} + + var simulated: Dictionary = {} + for revision: int in range(1, next_revision + 1): + var request: Dictionary = requests_by_revision[revision] + var instance_id: StringName = request[&"instance_id"] + var record: Dictionary = request[&"details"][&"projection"] + match request[&"operation"]: + OPERATION_PROJECT: + if simulated.has(instance_id): + return {&"ok": false, &"message": "Facility infrastructure history projects an already-live instance."} + simulated[instance_id] = record.duplicate(true) + OPERATION_UNPROJECT: + if not simulated.has(instance_id) or simulated[instance_id] != record: + return {&"ok": false, &"message": "Facility infrastructure unprojection history crosslink is invalid."} + simulated.erase(instance_id) + if simulated != next_projections: + return {&"ok": false, &"message": "Facility infrastructure current projections diverge from request history."} + return { + &"ok": true, + &"projection_revision": next_revision, + &"projections": next_projections, + &"processed_requests": next_requests, + &"quarantined": next_quarantined, + &"quarantine_reason": next_quarantine_reason, + } + + +static func _parse_projection(raw: Variant) -> Dictionary: + if not raw is Dictionary or not _keys_match(raw, _PROJECTION_KEYS): + return {&"ok": false, &"message": "Facility infrastructure projection fields are invalid."} + for key: StringName in [ + &"instance_id", &"definition_id", &"kind", &"route_entity_id", &"connector_id", + &"source_job_id", &"record_fingerprint", + ]: + if typeof(raw.get(key, null)) != TYPE_STRING: + return {&"ok": false, &"message": "Facility infrastructure projection identifier types are invalid."} + if typeof(raw.get(&"layer", null)) != TYPE_INT \ + or typeof(raw.get(&"rotation", null)) != TYPE_INT \ + or typeof(raw.get(&"completed_at", null)) != TYPE_INT \ + or typeof(raw.get(&"staff_only", null)) != TYPE_BOOL \ + or typeof(raw.get(&"pass_through", null)) != TYPE_BOOL \ + or not raw.get(&"footprint", null) is Vector2i \ + or not raw.get(&"origin", null) is Vector3i: + return {&"ok": false, &"message": "Facility infrastructure projection scalar types are invalid."} + var instance_id := StringName(raw[&"instance_id"]) + var kind := StringName(raw[&"kind"]) + var definition_id := StringName(raw[&"definition_id"]) + var route_entity_id := StringName(raw[&"route_entity_id"]) + var connector_id := StringName(raw[&"connector_id"]) + var source_job_id := StringName(raw[&"source_job_id"]) + if not _instance_id_is_valid(instance_id) \ + or kind not in SUPPORTED_KINDS \ + or definition_id != kind \ + or route_entity_id != StringName(ROUTE_ENTITY_PREFIX + String(instance_id)) \ + or connector_id != (StringName(CONNECTOR_PREFIX + String(instance_id)) if kind == KIND_STAFF_DOOR else &"") \ + or not _name_is_valid(source_job_id, MAX_INSTANCE_ID_LENGTH): + return {&"ok": false, &"message": "Facility infrastructure projection stable-ID crosslink is invalid."} + var schema := _kind_schema(kind) + var tags_result := _normalize_snapshot_names(raw[&"definition_tags"], false, 16, "Projection definition tags") + if not bool(tags_result[&"ok"]): + return tags_result + var cells_result := _normalize_cells(raw[&"cells"], false, SpatialType.MAX_CELLS_PER_ENTITY, "Projection cells") + if not bool(cells_result[&"ok"]) or raw[&"cells"] != cells_result[&"cells"]: + return {&"ok": false, &"message": "Facility infrastructure projection cells are invalid or unordered."} + var origin: Vector3i = raw[&"origin"] + var rotation := int(raw[&"rotation"]) + var completed_at := int(raw[&"completed_at"]) + var record := { + &"instance_id": instance_id, + &"definition_id": definition_id, + &"kind": kind, + &"route_entity_id": route_entity_id, + &"connector_id": connector_id, + &"layer": int(raw[&"layer"]), + &"cells": cells_result[&"cells"], + &"staff_only": bool(raw[&"staff_only"]), + &"pass_through": bool(raw[&"pass_through"]), + &"definition_tags": tags_result[&"names"], + &"footprint": raw[&"footprint"], + &"source_job_id": source_job_id, + &"origin": origin, + &"rotation": rotation, + &"completed_at": completed_at, + &"record_fingerprint": String(raw[&"record_fingerprint"]), + } + if ( + record[&"layer"] != schema[&"layer"] + or record[&"footprint"] != schema[&"footprint"] + or record[&"staff_only"] != schema[&"staff_only"] + or record[&"pass_through"] != schema[&"pass_through"] + or record[&"definition_tags"] != schema[&"tags"] + or not _cell_is_valid(origin) + or origin.z != int(record[&"layer"]) + or rotation not in [0, 90, 180, 270] + or completed_at < 0 + or completed_at > MAX_SAFE_INTEGER + or record[&"cells"] != _footprint_cells(origin, record[&"footprint"], rotation) + or record[&"record_fingerprint"] != _projection_fingerprint(record) + ): + return {&"ok": false, &"message": "Facility infrastructure projection semantic crosslink is invalid."} + return {&"ok": true, &"projection": record} + + +static func _parse_processed_request(raw: Variant, current_revision: int) -> Dictionary: + if not raw is Dictionary or not _keys_match(raw, [ + &"request_id", &"operation", &"instance_id", &"input", &"fingerprint", &"details", + ]): + return {&"ok": false, &"message": "Facility infrastructure processed-request fields are invalid."} + for key: StringName in [&"request_id", &"operation", &"instance_id", &"fingerprint"]: + if typeof(raw.get(key, null)) != TYPE_STRING: + return {&"ok": false, &"message": "Facility infrastructure processed-request identifier types are invalid."} + var request_id := StringName(raw[&"request_id"]) + var operation := StringName(raw[&"operation"]) + var instance_id := StringName(raw[&"instance_id"]) + if not _request_id_is_valid(request_id) \ + or operation not in OPERATIONS \ + or not _instance_id_is_valid(instance_id): + return {&"ok": false, &"message": "Facility infrastructure processed-request ID or operation is invalid."} + var input_result := ( + _parse_snapshot_project_input(raw[&"input"]) + if operation == OPERATION_PROJECT + else _parse_snapshot_unproject_input(raw[&"input"]) + ) + if not bool(input_result[&"ok"]): + return input_result + var input: Dictionary = input_result[&"input"] + if input[&"instance"][&"instance_id"] != instance_id if operation == OPERATION_PROJECT else input[&"instance_id"] != instance_id: + return {&"ok": false, &"message": "Facility infrastructure request input instance crosslink is invalid."} + var expected_fingerprint := _request_fingerprint(operation, input) + if String(raw[&"fingerprint"]) != expected_fingerprint: + return {&"ok": false, &"message": "Facility infrastructure request fingerprint is not derived from canonical input."} + var details_result := _parse_details( + raw[&"details"], + request_id, + operation, + instance_id, + current_revision + ) + if not bool(details_result[&"ok"]): + return details_result + var details: Dictionary = details_result[&"details"] + if operation == OPERATION_PROJECT: + var expected_record := _projection_from_project_input(input) + if details[&"projection"] != expected_record: + return {&"ok": false, &"message": "Facility infrastructure project result diverges from canonical input."} + return { + &"ok": true, + &"request_id": request_id, + &"request": { + &"operation": operation, + &"instance_id": instance_id, + &"input": input, + &"fingerprint": expected_fingerprint, + &"details": details, + }, + } + + +static func _parse_details( + raw: Variant, + request_id: StringName, + operation: StringName, + instance_id: StringName, + current_revision: int +) -> Dictionary: + if not raw is Dictionary or not _keys_match(raw, _DETAIL_KEYS): + return {&"ok": false, &"message": "Facility infrastructure result fields are invalid."} + if typeof(raw.get(&"request_id", null)) != TYPE_STRING \ + or typeof(raw.get(&"operation", null)) != TYPE_STRING \ + or typeof(raw.get(&"instance_id", null)) != TYPE_STRING \ + or typeof(raw.get(&"changed", null)) != TYPE_BOOL \ + or typeof(raw.get(&"projection_revision", null)) != TYPE_INT: + return {&"ok": false, &"message": "Facility infrastructure result types are invalid."} + var detail_revision := int(raw[&"projection_revision"]) + if StringName(raw[&"request_id"]) != request_id \ + or StringName(raw[&"operation"]) != operation \ + or StringName(raw[&"instance_id"]) != instance_id \ + or not bool(raw[&"changed"]) \ + or detail_revision < 1 \ + or detail_revision > current_revision: + return {&"ok": false, &"message": "Facility infrastructure result crosslink is invalid."} + var projection_result := _parse_projection(raw[&"projection"]) + if not bool(projection_result[&"ok"]): + return projection_result + var record: Dictionary = projection_result[&"projection"] + if record[&"instance_id"] != instance_id: + return {&"ok": false, &"message": "Facility infrastructure result projection belongs to another instance."} + return { + &"ok": true, + &"details": { + &"request_id": request_id, + &"operation": operation, + &"instance_id": instance_id, + &"changed": true, + &"projection_revision": detail_revision, + &"projection": record, + }, + } + + +static func _parse_snapshot_project_input(raw: Variant) -> Dictionary: + if not raw is Dictionary or not _keys_match(raw, _PROJECT_INPUT_KEYS): + return {&"ok": false, &"message": "Facility infrastructure project input fields are invalid."} + var raw_definition: Variant = raw[&"definition"] + var raw_instance: Variant = raw[&"instance"] + if not raw_definition is Dictionary \ + or not _keys_match(raw_definition, _DEFINITION_INPUT_KEYS) \ + or not raw_instance is Dictionary \ + or not _keys_match(raw_instance, _INSTANCE_KEYS): + return {&"ok": false, &"message": "Facility infrastructure project definition/instance input is malformed."} + if typeof(raw_definition.get(&"id", null)) != TYPE_STRING \ + or typeof(raw_definition.get(&"layer", null)) != TYPE_INT \ + or not raw_definition.get(&"footprint", null) is Vector2i \ + or typeof(raw_definition.get(&"pass_through", null)) != TYPE_BOOL: + return {&"ok": false, &"message": "Facility infrastructure project definition input types are invalid."} + var definition_id := StringName(raw_definition[&"id"]) + if definition_id not in SUPPORTED_KINDS: + return {&"ok": false, &"message": "Snapshot request history contains an unsupported infrastructure kind."} + var schema := _kind_schema(definition_id) + var tags_result := _normalize_snapshot_names(raw_definition[&"tags"], false, 16, "Snapshot definition tags") + if not bool(tags_result[&"ok"]): + return tags_result + var definition := { + &"id": definition_id, + &"layer": int(raw_definition[&"layer"]), + &"footprint": raw_definition[&"footprint"], + &"pass_through": bool(raw_definition[&"pass_through"]), + &"tags": tags_result[&"names"], + } + if definition[&"layer"] != schema[&"layer"] \ + or definition[&"footprint"] != schema[&"footprint"] \ + or definition[&"pass_through"] != schema[&"pass_through"] \ + or definition[&"tags"] != schema[&"tags"]: + return {&"ok": false, &"message": "Snapshot definition input diverges from its infrastructure schema."} + for key: StringName in [&"instance_id", &"definition_id", &"source_job_id", &"state"]: + if typeof(raw_instance.get(key, null)) != TYPE_STRING: + return {&"ok": false, &"message": "Snapshot completed-facility identifier/state types are invalid."} + if not raw_instance.get(&"origin", null) is Vector3i \ + or typeof(raw_instance.get(&"rotation", null)) != TYPE_INT \ + or typeof(raw_instance.get(&"layer", null)) != TYPE_INT \ + or typeof(raw_instance.get(&"completed_at", null)) != TYPE_INT: + return {&"ok": false, &"message": "Snapshot completed-facility scalar types are invalid."} + var instance_id := StringName(raw_instance[&"instance_id"]) + var instance_definition_id := StringName(raw_instance[&"definition_id"]) + var source_job_id := StringName(raw_instance[&"source_job_id"]) + var state := StringName(raw_instance[&"state"]) + var origin: Vector3i = raw_instance[&"origin"] + var rotation := int(raw_instance[&"rotation"]) + var layer := int(raw_instance[&"layer"]) + var completed_at := int(raw_instance[&"completed_at"]) + if not _instance_id_is_valid(instance_id) \ + or instance_definition_id != definition_id \ + or not _name_is_valid(source_job_id, MAX_INSTANCE_ID_LENGTH) \ + or state != &"ACTIVE" \ + or not _cell_is_valid(origin) \ + or origin.z != layer \ + or layer != int(definition[&"layer"]) \ + or rotation not in [0, 90, 180, 270] \ + or completed_at < 0 \ + or completed_at > MAX_SAFE_INTEGER: + return {&"ok": false, &"message": "Snapshot completed-facility semantic fields are invalid."} + var cells_result := _normalize_cells(raw_instance[&"occupied_cells"], false, SpatialType.MAX_CELLS_PER_ENTITY, "Snapshot completed-facility cells") + if not bool(cells_result[&"ok"]) \ + or raw_instance[&"occupied_cells"] != cells_result[&"cells"] \ + or cells_result[&"cells"] != _footprint_cells(origin, definition[&"footprint"], rotation): + return {&"ok": false, &"message": "Snapshot completed-facility cells do not crosslink to the footprint."} + var cost_result := _normalize_snapshot_cost(raw_instance[&"construction_cost"]) + if not bool(cost_result[&"ok"]): + return cost_result + return { + &"ok": true, + &"input": { + &"definition": definition, + &"instance": { + &"instance_id": instance_id, + &"definition_id": definition_id, + &"origin": origin, + &"rotation": rotation, + &"layer": layer, + &"occupied_cells": cells_result[&"cells"], + &"construction_cost": cost_result[&"cost"], + &"source_job_id": source_job_id, + &"state": state, + &"completed_at": completed_at, + }, + }, + } + + +static func _parse_snapshot_unproject_input(raw: Variant) -> Dictionary: + if not raw is Dictionary \ + or not _keys_match(raw, _UNPROJECT_INPUT_KEYS) \ + or typeof(raw.get(&"instance_id", null)) != TYPE_STRING: + return {&"ok": false, &"message": "Facility infrastructure unproject input is malformed."} + var instance_id := StringName(raw[&"instance_id"]) + if not _instance_id_is_valid(instance_id): + return {&"ok": false, &"message": "Facility infrastructure unproject instance ID is invalid."} + return {&"ok": true, &"input": {&"instance_id": instance_id}} + + +func _history_capacity_allows(input: Dictionary) -> bool: + if _processed_requests.size() >= MAX_PROCESSED_REQUESTS: + return false + var added_cells := 0 + if input.has(&"instance"): + added_cells = (input[&"instance"][&"occupied_cells"] as Array).size() + return _history_cell_reference_count(_processed_requests) <= MAX_HISTORY_CELL_REFERENCES - added_cells + + +static func _history_cell_reference_count(requests: Dictionary) -> int: + var total := 0 + for request_value: Variant in requests.values(): + if not request_value is Dictionary: + continue + var details: Variant = request_value.get(&"details", {}) + if details is Dictionary: + var projection: Variant = details.get(&"projection", {}) + if projection is Dictionary: + var cells: Variant = projection.get(&"cells", []) + if cells is Array: + total += cells.size() + return total + + +static func _normalize_cost(value: Variant) -> Dictionary: + if not value is Dictionary or value.size() > MAX_COST_ENTRIES: + return {&"ok": false, &"message": "Completed facility construction cost is not a bounded Dictionary."} + var result: Dictionary = {} + for raw_key: Variant in value: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _name_is_valid(raw_key, MAX_INSTANCE_ID_LENGTH) \ + or typeof(value[raw_key]) != TYPE_INT: + return {&"ok": false, &"message": "Completed facility construction-cost key/value types are invalid."} + var key := StringName(raw_key) + var amount := int(value[raw_key]) + if result.has(key) or amount < 0 or amount > MAX_SAFE_INTEGER: + return {&"ok": false, &"message": "Completed facility construction cost contains a duplicate or invalid amount."} + result[key] = amount + return {&"ok": true, &"cost": _sorted_dictionary_by_name(result)} + + +static func _normalize_snapshot_cost(value: Variant) -> Dictionary: + if not value is Dictionary or value.size() > MAX_COST_ENTRIES: + return {&"ok": false, &"message": "Snapshot construction cost is not a bounded Dictionary."} + var result: Dictionary = {} + var prior := "" + for raw_key: Variant in value: + if typeof(raw_key) != TYPE_STRING or typeof(value[raw_key]) != TYPE_INT: + return {&"ok": false, &"message": "Snapshot construction-cost types are invalid."} + var key_text := String(raw_key) + var key := StringName(key_text) + var amount := int(value[raw_key]) + if not prior.is_empty() and key_text <= prior: + return {&"ok": false, &"message": "Snapshot construction-cost keys are not lexical."} + if not _name_is_valid(key, MAX_INSTANCE_ID_LENGTH) \ + or amount < 0 \ + or amount > MAX_SAFE_INTEGER: + return {&"ok": false, &"message": "Snapshot construction-cost key or amount is invalid."} + prior = key_text + result[key] = amount + return {&"ok": true, &"cost": result} + + +static func _normalize_names( + value: Variant, + allow_empty: bool, + maximum: int, + label: String +) -> Dictionary: + if not value is Array or value.size() > maximum or (not allow_empty and value.is_empty()): + return {&"ok": false, &"message": "%s have an invalid collection shape or capacity." % label} + var seen: Dictionary = {} + var names: Array[StringName] = [] + for raw_name: Variant in value: + if typeof(raw_name) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _name_is_valid(raw_name, MAX_INSTANCE_ID_LENGTH): + return {&"ok": false, &"message": "%s contain an invalid name." % label} + var name := StringName(raw_name) + if seen.has(name): + return {&"ok": false, &"message": "%s contain a duplicate name." % label} + seen[name] = true + names.append(name) + names.sort_custom(_name_less) + return {&"ok": true, &"names": names} + + +static func _normalize_snapshot_names( + value: Variant, + allow_empty: bool, + maximum: int, + label: String +) -> Dictionary: + if not value is Array or value.size() > maximum or (not allow_empty and value.is_empty()): + return {&"ok": false, &"message": "%s have an invalid collection shape or capacity." % label} + var names: Array[StringName] = [] + var prior := "" + for raw_name: Variant in value: + if typeof(raw_name) != TYPE_STRING or not _name_is_valid(raw_name, MAX_INSTANCE_ID_LENGTH): + return {&"ok": false, &"message": "%s contain an invalid string name." % label} + var text := String(raw_name) + if not prior.is_empty() and text <= prior: + return {&"ok": false, &"message": "%s are not in lexical order." % label} + prior = text + names.append(StringName(text)) + return {&"ok": true, &"names": names} + + +static func _normalize_cells( + value: Variant, + allow_empty: bool, + maximum: int, + label: String +) -> Dictionary: + if not value is Array or value.size() > maximum or (not allow_empty and value.is_empty()): + return {&"ok": false, &"message": "%s have an invalid collection shape or capacity." % label} + var seen: Dictionary = {} + var cells: Array[Vector3i] = [] + for raw_cell: Variant in value: + if not _cell_is_valid(raw_cell) or seen.has(raw_cell): + return {&"ok": false, &"message": "%s contain an invalid or duplicate cell." % label} + seen[raw_cell] = true + cells.append(raw_cell) + cells.sort_custom(_cell_less) + return {&"ok": true, &"cells": cells} + + +static func _footprint_cells(origin: Vector3i, footprint: Vector2i, rotation: int) -> Array[Vector3i]: + var dimensions := footprint + if rotation in [90, 270]: + dimensions = Vector2i(footprint.y, footprint.x) + var cells: Array[Vector3i] = [] + for y_offset: int in range(dimensions.y): + for x_offset: int in range(dimensions.x): + cells.append(origin + Vector3i(x_offset, y_offset, 0)) + cells.sort_custom(_cell_less) + return cells + + +static func _sorted_dictionary_by_name(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + for key: StringName in _sorted_names(source): + result[key] = source[key] + return result + + +static func _sorted_name_array(source: Array) -> Array[StringName]: + var result: Array[StringName] = [] + for value: Variant in source: + result.append(StringName(value)) + result.sort_custom(_name_less) + return result + + +static func _sorted_names(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_name: Variant in source: + result.append(StringName(raw_name)) + result.sort_custom(_name_less) + return result + + +static func _name_less(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + + +static func _cell_less(left: Vector3i, right: Vector3i) -> bool: + if left.y != right.y: + return left.y < right.y + if left.x != right.x: + return left.x < right.x + return left.z < right.z + + +static func _cell_is_valid(value: Variant) -> bool: + if not value is Vector3i: + return false + var cell: Vector3i = value + return ( + cell.z in [PathType.FRONT, PathType.BACK] + and cell.x >= -MAX_COORDINATE_ABS + and cell.x <= MAX_COORDINATE_ABS + and cell.y >= -MAX_COORDINATE_ABS + and cell.y <= MAX_COORDINATE_ABS + ) + + +static func _request_id_is_valid(value: Variant) -> bool: + return _name_is_valid(value, MAX_REQUEST_ID_LENGTH) + + +static func _instance_id_is_valid(value: Variant) -> bool: + return _name_is_valid(value, MAX_INSTANCE_ID_LENGTH) + + +static func _name_is_valid(value: Variant, maximum_length: int) -> bool: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var text := String(value) + if text.is_empty() or text.length() > maximum_length: + return false + for index: int in range(text.length()): + var code := text.unicode_at(index) + var allowed := ( + code >= 48 and code <= 57 + or code >= 65 and code <= 90 + or code >= 97 and code <= 122 + or code in [45, 46, 58, 64, 95] + ) + if not allowed: + return false + return true + + +static func _keys_match(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + var seen: Dictionary = {} + for raw_key: Variant in source: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var key := StringName(raw_key) + if seen.has(key) or not expected.has(key): + return false + seen[key] = true + return seen.size() == expected.size() + + +static func _variant_depth_is_valid(value: Variant, depth: int) -> bool: + if depth > MAX_SNAPSHOT_DEPTH: + return false + if value is Array: + if value.size() > MAX_CONTAINER_ITEMS: + return false + for child: Variant in value: + if not _variant_depth_is_valid(child, depth + 1): + return false + elif value is Dictionary: + if value.size() > MAX_CONTAINER_ITEMS: + return false + for key: Variant in value: + if not _variant_depth_is_valid(key, depth + 1) \ + or not _variant_depth_is_valid(value[key], depth + 1): + return false + elif typeof(value) in [TYPE_OBJECT, TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]: + return false + return true + + +static func _quarantine_state_is_valid(value: bool, reason: StringName) -> bool: + return (value and reason in QUARANTINE_REASONS) or (not value and reason.is_empty()) + + +func _success(details: Dictionary = {}) -> CommandResult: + last_error = "" + return CommandResultType.succeeded(0, details) + + +func _failure(code: CommandResult.Code, message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed(code, 0, message, details) + + +func _fail(message: String) -> bool: + last_error = message + return false diff --git a/scripts/world/facility_infrastructure_projector.gd.uid b/scripts/world/facility_infrastructure_projector.gd.uid new file mode 100644 index 0000000..30c1da8 --- /dev/null +++ b/scripts/world/facility_infrastructure_projector.gd.uid @@ -0,0 +1 @@ +uid://uj1a8hstgr4q diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd new file mode 100644 index 0000000..78fdd8d --- /dev/null +++ b/scripts/world/path_service.gd @@ -0,0 +1,2327 @@ +class_name PathService +extends RefCounted + +signal path_query_measured(duration_us: int) + +## Deterministic path graph derived from WorldService-owned passable cells. +## +## Coordinates are Vector3i(x, y, layer), where layer is FRONT (0) or BACK (1). +## This service owns connector configuration, but not the authoritative world cells. + +const FRONT := 0 +const BACK := 1 +const ORTHOGONAL_MOVE_COST := 100 +const DEFAULT_CONNECTOR_COST := 150 +const DEFAULT_CELL_CAPACITY := 4 +const CONGESTION_COST_PER_EXCESS := 75 +const MINIMUM_SPEED_MULTIPLIER := 0.25 +const STUCK_THRESHOLD_MINUTES := 30 +const ELEVATOR_STARVATION_MINUTES := 60 +const SNAPSHOT_VERSION := 3 +const LEGACY_SNAPSHOT_VERSION := 1 +const TRAFFIC_SNAPSHOT_VERSION := 2 + +const ELEVATOR_REQUEST_QUEUED := &"QUEUED" +const ELEVATOR_REQUEST_BOARDED := &"BOARDED" + +const MOVEMENT_WALK := &"WALK" +const MOVEMENT_CONNECTOR := &"CONNECTOR" +const MOVEMENT_ELEVATOR := &"ELEVATOR" + +const CONNECTOR_OPERATIONAL := &"OPERATIONAL" +const CONNECTOR_STOPPED := &"STOPPED" +const CONNECTOR_OUTAGE := &"OUTAGE" +const CONNECTOR_MAINTENANCE := &"MAINTENANCE" + +const CABIN_IDLE := &"IDLE" +const CABIN_MOVING_UP := &"MOVING_UP" +const CABIN_MOVING_DOWN := &"MOVING_DOWN" +const CABIN_DOOR_OPEN := &"DOOR_OPEN" +const CABIN_OUTAGE := &"OUTAGE" + +const ELEVATOR_PRIORITY_RESCUE := 0 +const ELEVATOR_PRIORITY_SECURITY := 10 +const ELEVATOR_PRIORITY_PROTAGONIST := 20 +const ELEVATOR_PRIORITY_VIP := 30 +const ELEVATOR_PRIORITY_NORMAL := 40 + +const ROUTE_OK := &"OK" +const ROUTE_START_NOT_PASSABLE := &"START_NOT_PASSABLE" +const ROUTE_GOAL_NOT_PASSABLE := &"GOAL_NOT_PASSABLE" +const ROUTE_START_BLOCKED := &"START_TEMPORARILY_BLOCKED" +const ROUTE_GOAL_BLOCKED := &"GOAL_TEMPORARILY_BLOCKED" +const ROUTE_ACCESS_LAYER_DENIED := &"ACCESS_LAYER_DENIED" +const ROUTE_CONNECTOR_UNAVAILABLE := &"CONNECTOR_UNAVAILABLE" +const ROUTE_TEMPORARILY_BLOCKED := &"TEMPORARILY_BLOCKED" +const ROUTE_GOAL_UNREACHABLE := &"GOAL_UNREACHABLE" + +const _CARDINAL_DIRECTIONS: Array[Vector3i] = [ + Vector3i(-1, 0, 0), + Vector3i(0, -1, 0), + Vector3i(0, 1, 0), + Vector3i(1, 0, 0), +] +const _INFINITE_COST := 9223372036854775807 + +var graph_revision := 0 +var traffic_revision := 0 +var cache_hits := 0 +var cache_misses := 0 +var last_error := "" +var last_no_route_reason: StringName = ROUTE_OK +var last_path_cost := 0 + +var _passable_cells: Dictionary = {} +var _connectors: Dictionary = {} +var _adjacency: Dictionary = {} +var _path_cache: Dictionary = {} +var _cell_traffic: Dictionary = {} +var _actor_cells: Dictionary = {} +var _actor_progress: Dictionary = {} +var _known_elevator_request_ids: Dictionary = {} +var _active_elevator_units: Dictionary = {} +var _pending_graph_revision := -1 +var _traffic_loaded_without_graph := false + + +func rebuild_graph(passable_cells: Variant) -> bool: + var normalization := _normalize_cell_set(passable_cells) + if not normalization[&"ok"]: + last_error = normalization[&"error"] + return false + + var next_cells: Dictionary = normalization[&"cells"] + if _traffic_loaded_without_graph: + for cell_value: Variant in _cell_traffic: + if not next_cells.has(cell_value): + last_error = "Snapshot traffic references a cell absent from the authoritative world: %s" % cell_value + return false + for actor_id: Variant in _actor_cells: + if not next_cells.has(_actor_cells[actor_id]): + last_error = "Snapshot actor projection references a non-walkable cell: %s" % actor_id + return false + if next_cells == _passable_cells: + _ensure_cell_traffic_for_graph(next_cells, not _traffic_loaded_without_graph) + _traffic_loaded_without_graph = false + if _pending_graph_revision >= 0: + graph_revision = _pending_graph_revision + _pending_graph_revision = -1 + invalidate_all_cache() + last_error = "" + return true + + _passable_cells = next_cells + _ensure_cell_traffic_for_graph(next_cells, not _traffic_loaded_without_graph) + _traffic_loaded_without_graph = false + _rebuild_derived_graph() + if _pending_graph_revision >= 0: + graph_revision = _pending_graph_revision + _pending_graph_revision = -1 + invalidate_all_cache() + else: + _mark_graph_changed() + last_error = "" + return true + + +func configure_connector( + connector_id: StringName, + from_cell: Vector3i, + to_cell: Vector3i, + cost := DEFAULT_CONNECTOR_COST, + bidirectional := true, + access_id: StringName = &"", + enabled := true, + options: Dictionary = {} +) -> bool: + var raw_configuration := options.duplicate(true) + raw_configuration.merge({ + &"id": connector_id, + &"from": from_cell, + &"to": to_cell, + &"cost": cost, + &"bidirectional": bidirectional, + &"access_id": access_id, + &"enabled": enabled, + }, true) + var normalized := _normalize_connector(raw_configuration) + if not normalized[&"ok"]: + last_error = normalized[&"error"] + return false + + var configuration: Dictionary = normalized[&"connector"] + if _connectors.has(connector_id) and _connectors[connector_id] == configuration: + last_error = "" + return true + for unit_id: Variant in _active_elevator_units: + var active: Dictionary = _active_elevator_units[unit_id] + if active[&"connector_id"] == connector_id: + last_error = "Cannot reconfigure a connector with active elevator requests." + return false + var proposed_connectors := _connectors.duplicate(true) + proposed_connectors[connector_id] = configuration + var index_result := _derive_elevator_indexes_from_connectors(proposed_connectors) + if not index_result[&"ok"]: + last_error = index_result[&"error"] + return false + for request_id: Variant in index_result[&"known_request_ids"]: + if _known_elevator_request_ids.has(request_id) \ + and not _active_request_id_exists(StringName(request_id)): + last_error = "Connector configuration reuses a retired elevator request id." + return false + _connectors[connector_id] = configuration + var next_active: Dictionary = index_result[&"active_units"] + for unit_id: Variant in _active_elevator_units: + var active: Dictionary = _active_elevator_units[unit_id] + if active[&"state"] == ELEVATOR_REQUEST_BOARDED: + next_active[unit_id] = active.duplicate(true) + _active_elevator_units = next_active + for request_id: Variant in index_result[&"known_request_ids"]: + _known_elevator_request_ids[request_id] = true + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func add_connector( + connector_id: StringName, + from_cell: Vector3i, + to_cell: Vector3i, + cost := DEFAULT_CONNECTOR_COST, + bidirectional := true, + access_id: StringName = &"" +) -> bool: + return configure_connector( + connector_id, + from_cell, + to_cell, + cost, + bidirectional, + access_id, + true + ) + + +func configure_elevator( + connector_id: StringName, + from_cell: Vector3i, + to_cell: Vector3i, + options: Dictionary = {}, + access_id: StringName = &"" +) -> bool: + var elevator_options := options.duplicate(true) + elevator_options[&"movement_type"] = MOVEMENT_ELEVATOR + var cost := int(_dictionary_value(elevator_options, &"cost", DEFAULT_CONNECTOR_COST)) + var bidirectional := bool(_dictionary_value(elevator_options, &"bidirectional", true)) + var enabled := bool(_dictionary_value(elevator_options, &"enabled", true)) + return configure_connector( + connector_id, + from_cell, + to_cell, + cost, + bidirectional, + access_id, + enabled, + elevator_options + ) + + +func set_connectors(configurations: Array) -> bool: + var normalized_collection := _normalize_connector_collection(configurations) + if not normalized_collection[&"ok"]: + last_error = normalized_collection[&"error"] + return false + var next_connectors: Dictionary = normalized_collection[&"connectors"] + + if next_connectors == _connectors: + last_error = "" + return true + for request_id: Variant in normalized_collection[&"known_request_ids"]: + if _known_elevator_request_ids.has(request_id) \ + and not _active_request_id_exists(StringName(request_id)): + last_error = "Connector set reuses a retired elevator request id." + return false + for unit_id: Variant in _active_elevator_units: + if (_active_elevator_units[unit_id] as Dictionary)[&"state"] == ELEVATOR_REQUEST_BOARDED: + last_error = "Cannot replace connector set while an elevator trip is boarded." + return false + _connectors = next_connectors + _active_elevator_units = normalized_collection[&"active_units"] + for request_id: Variant in normalized_collection[&"known_request_ids"]: + _known_elevator_request_ids[request_id] = true + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func remove_connector(connector_id: StringName) -> bool: + if not _connectors.has(connector_id): + return false + for unit_id: Variant in _active_elevator_units: + if (_active_elevator_units[unit_id] as Dictionary)[&"connector_id"] == connector_id: + last_error = "Cannot remove a connector with active elevator requests: %s" % connector_id + return false + _connectors.erase(connector_id) + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func set_connector_enabled(connector_id: StringName, enabled: bool) -> bool: + if not _connectors.has(connector_id): + last_error = "Unknown connector id: %s" % connector_id + return false + var connector: Dictionary = _connectors[connector_id] + if bool(connector[&"enabled"]) == enabled: + last_error = "" + return true + connector[&"enabled"] = enabled + _connectors[connector_id] = connector + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func set_connector_operational_state(connector_id: StringName, state: StringName) -> bool: + if not _connectors.has(connector_id): + last_error = "Unknown connector id: %s" % connector_id + return false + if state not in [ + CONNECTOR_OPERATIONAL, + CONNECTOR_STOPPED, + CONNECTOR_OUTAGE, + CONNECTOR_MAINTENANCE, + ]: + last_error = "Unsupported connector operational state: %s" % state + return false + var connector: Dictionary = _connectors[connector_id] + if connector[&"operational_state"] == state: + last_error = "" + return true + connector[&"operational_state"] = state + if connector[&"movement_type"] == MOVEMENT_ELEVATOR: + connector[&"cabin_state"] = CABIN_IDLE if state == CONNECTOR_OPERATIONAL else CABIN_OUTAGE + _connectors[connector_id] = connector + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func set_elevator_operational(connector_id: StringName, operational: bool) -> bool: + return set_connector_operational_state( + connector_id, + CONNECTOR_OPERATIONAL if operational else CONNECTOR_OUTAGE + ) + + +func set_elevator_cabin_state(connector_id: StringName, cabin_state: StringName) -> bool: + if not _connectors.has(connector_id): + last_error = "Unknown connector id: %s" % connector_id + return false + if cabin_state not in [ + CABIN_IDLE, + CABIN_MOVING_UP, + CABIN_MOVING_DOWN, + CABIN_DOOR_OPEN, + CABIN_OUTAGE, + ]: + last_error = "Unsupported elevator cabin state: %s" % cabin_state + return false + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + last_error = "Connector is not an elevator: %s" % connector_id + return false + if connector[&"cabin_state"] == cabin_state: + last_error = "" + return true + connector[&"cabin_state"] = cabin_state + connector[&"operational_state"] = ( + CONNECTOR_OUTAGE if cabin_state == CABIN_OUTAGE else CONNECTOR_OPERATIONAL + ) + _connectors[connector_id] = connector + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func clear_connectors() -> void: + if _connectors.is_empty(): + return + if not _active_elevator_units.is_empty(): + last_error = "Cannot clear connectors while elevator requests are active." + return + _connectors.clear() + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + + +func configure_cell_traffic( + cell: Vector3i, + base_capacity: int = DEFAULT_CELL_CAPACITY, + dynamic_cost: int = 0, + temporary_block: bool = false +) -> bool: + if not _passable_cells.has(cell): + last_error = "Traffic configuration requires a passable cell: %s" % cell + return false + if base_capacity <= 0 or dynamic_cost < 0: + last_error = "Cell capacity must be positive and dynamic cost non-negative." + return false + var current: Dictionary = _cell_traffic.get(cell, _default_cell_traffic()) + var updated := { + &"base_capacity": base_capacity, + &"current_density": int(current.get(&"current_density", 0)), + &"dynamic_cost": dynamic_cost, + &"temporary_block": temporary_block, + } + if current == updated: + last_error = "" + return true + var may_make_routes_cheaper := ( + _traffic_effective_cost(current) > _traffic_effective_cost(updated) + or (bool(current[&"temporary_block"]) and not temporary_block) + ) + _cell_traffic[cell] = updated + _mark_traffic_changed([cell], may_make_routes_cheaper) + last_error = "" + return true + + +func set_cell_base_capacity(cell: Vector3i, base_capacity: int) -> bool: + var current := get_cell_traffic(cell) + if current.is_empty(): + last_error = "Unknown path cell: %s" % cell + return false + return configure_cell_traffic( + cell, + base_capacity, + int(current[&"dynamic_cost"]), + bool(current[&"temporary_block"]) + ) + + +func set_cell_dynamic_cost(cell: Vector3i, dynamic_cost: int) -> bool: + var current := get_cell_traffic(cell) + if current.is_empty(): + last_error = "Unknown path cell: %s" % cell + return false + return configure_cell_traffic( + cell, + int(current[&"base_capacity"]), + dynamic_cost, + bool(current[&"temporary_block"]) + ) + + +func set_cell_temporary_block(cell: Vector3i, temporary_block: bool) -> bool: + var current := get_cell_traffic(cell) + if current.is_empty(): + last_error = "Unknown path cell: %s" % cell + return false + return configure_cell_traffic( + cell, + int(current[&"base_capacity"]), + int(current[&"dynamic_cost"]), + temporary_block + ) + + +func get_cell_traffic(cell: Vector3i) -> Dictionary: + if not _passable_cells.has(cell): + return {} + return (_cell_traffic.get(cell, _default_cell_traffic()) as Dictionary).duplicate(true) + + +func get_current_density(cell: Vector3i) -> int: + return int(_cell_traffic.get(cell, {}).get(&"current_density", 0)) + + +func get_congestion_cost(cell: Vector3i) -> int: + var traffic: Dictionary = _cell_traffic.get(cell, {}) + if traffic.is_empty(): + return 0 + return maxi( + 0, + int(traffic[&"current_density"]) - int(traffic[&"base_capacity"]) + ) * CONGESTION_COST_PER_EXCESS + + +func get_cell_dynamic_cost(cell: Vector3i) -> int: + var traffic: Dictionary = _cell_traffic.get(cell, {}) + if traffic.is_empty(): + return 0 + return int(traffic[&"dynamic_cost"]) + get_congestion_cost(cell) + + +func get_speed_multiplier(cell: Vector3i) -> float: + var traffic: Dictionary = _cell_traffic.get(cell, {}) + if traffic.is_empty(): + return 1.0 + var capacity := int(traffic[&"base_capacity"]) + var density := int(traffic[&"current_density"]) + return clampf( + float(capacity) / float(maxi(capacity, density)), + MINIMUM_SPEED_MULTIPLIER, + 1.0 + ) + + +func project_actor_position( + actor_id: StringName, + cell: Vector3i, + absolute_minute: int = -1 +) -> bool: + if actor_id.is_empty() or not _passable_cells.has(cell): + last_error = "Actor projection requires an id and a passable destination." + return false + if bool(_cell_traffic[cell][&"temporary_block"]): + last_error = "Actor cannot enter a temporarily blocked cell." + return false + if absolute_minute >= 0 and _actor_progress.has(actor_id) \ + and absolute_minute < int((_actor_progress[actor_id] as Dictionary)[&"last_observed_at"]): + last_error = "Actor projection time cannot move backwards." + return false + if _actor_cells.has(actor_id) and _actor_cells[actor_id] == cell: + if absolute_minute >= 0: + var progress_result := record_actor_progress(actor_id, cell, absolute_minute) + if not bool(progress_result[&"ok"]): + return false + last_error = "" + return true + + var changed_cells: Array[Vector3i] = [] + if _actor_cells.has(actor_id): + var previous: Vector3i = _actor_cells[actor_id] + if not _decrement_density(previous): + last_error = "Actor density projection is inconsistent for: %s" % actor_id + return false + changed_cells.append(previous) + _actor_cells[actor_id] = cell + _increment_density(cell) + changed_cells.append(cell) + # Leaving the previous cell can make an unselected alternative cheaper, so a + # move requires broad cache invalidation even though congestion itself does + # not advance graph_revision. + _mark_traffic_changed(changed_cells, changed_cells.size() > 1) + if absolute_minute >= 0: + record_actor_progress(actor_id, cell, absolute_minute) + last_error = "" + return true + + +func actor_enter_cell(actor_id: StringName, cell: Vector3i, absolute_minute: int = -1) -> bool: + return project_actor_position(actor_id, cell, absolute_minute) + + +func actor_leave_cell(actor_id: StringName, clear_progress: bool = true) -> bool: + if actor_id.is_empty() or not _actor_cells.has(actor_id): + last_error = "Actor has no projected cell: %s" % actor_id + return false + var previous: Vector3i = _actor_cells[actor_id] + if not _decrement_density(previous): + last_error = "Actor density projection is inconsistent for: %s" % actor_id + return false + _actor_cells.erase(actor_id) + if clear_progress: + _actor_progress.erase(actor_id) + _mark_traffic_changed([previous], true) + last_error = "" + return true + + +func despawn_actor(actor_id: StringName) -> bool: + var removed := false + if _actor_cells.has(actor_id): + removed = actor_leave_cell(actor_id, true) + elif _actor_progress.erase(actor_id): + removed = true + if not removed: + last_error = "Unknown actor id: %s" % actor_id + return false + last_error = "" + return true + + +func has_actor_projection(actor_id: StringName) -> bool: + return _actor_cells.has(actor_id) + + +func get_actor_projected_cell(actor_id: StringName) -> Variant: + return _actor_cells.get(actor_id, null) + + +func project_occupancy(entries: Array, clear_unprojected_progress: bool = true) -> bool: + var next_actor_cells: Dictionary = {} + var next_densities: Dictionary = {} + for raw_entry: Variant in entries: + if not raw_entry is Dictionary: + last_error = "Occupancy projection entries must be Dictionaries." + return false + var actor_id := StringName(str(_dictionary_value(raw_entry, &"actor_id", ""))) + var cell: Variant = _dictionary_value(raw_entry, &"cell", null) + if actor_id.is_empty() or not cell is Vector3i or not _passable_cells.has(cell): + last_error = "Occupancy projection contains an invalid actor or cell." + return false + if bool(_cell_traffic[cell][&"temporary_block"]) or next_actor_cells.has(actor_id): + last_error = "Occupancy projection contains a duplicate actor or blocked cell." + return false + next_actor_cells[actor_id] = cell + next_densities[cell] = int(next_densities.get(cell, 0)) + 1 + + var changed: Dictionary = {} + var may_make_routes_cheaper := false + for cell_value: Variant in _passable_cells: + var cell: Vector3i = cell_value + var old_density := get_current_density(cell) + var next_density := int(next_densities.get(cell, 0)) + if old_density != next_density: + changed[cell] = true + if next_density < old_density: + may_make_routes_cheaper = true + var traffic: Dictionary = _cell_traffic[cell] + traffic[&"current_density"] = next_density + _cell_traffic[cell] = traffic + _actor_cells = next_actor_cells + if clear_unprojected_progress: + var stale_progress_ids: Array = [] + for actor_id: Variant in _actor_progress: + if not next_actor_cells.has(actor_id): + stale_progress_ids.append(actor_id) + for actor_id: Variant in stale_progress_ids: + _actor_progress.erase(actor_id) + if not changed.is_empty(): + _mark_traffic_changed(changed.keys(), may_make_routes_cheaper) + last_error = "" + return true + + +func enqueue_elevator_request(connector_id: StringName, request: Dictionary) -> Dictionary: + if not _connectors.has(connector_id): + return _queue_failure(&"UNKNOWN_CONNECTOR", "Unknown connector id: %s" % connector_id) + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + return _queue_failure(&"NOT_ELEVATOR", "Connector is not an elevator: %s" % connector_id) + var normalized := _normalize_elevator_request(request) + if not normalized[&"ok"]: + return _queue_failure(&"INVALID_REQUEST", normalized[&"error"]) + var candidate: Dictionary = normalized[&"request"] + var queue: Array = connector[&"request_queue"] + var request_id: StringName = candidate[&"request_id"] + var unit_id: StringName = candidate[&"unit_id"] + if _known_elevator_request_ids.has(request_id): + var active: Dictionary = _active_elevator_units.get(unit_id, {}) + if not active.is_empty() \ + and active[&"request_id"] == request_id \ + and active[&"connector_id"] == connector_id \ + and active[&"state"] == ELEVATOR_REQUEST_QUEUED: + for existing_value: Variant in queue: + var existing: Dictionary = existing_value + if existing[&"request_id"] == request_id and existing == candidate: + last_error = "" + return { + &"ok": true, + &"replay": true, + &"request": existing.duplicate(true), + &"queue_size": queue.size(), + } + return _queue_failure( + &"DUPLICATE_REQUEST", + "Elevator request id has already been used on this or another connector." + ) + if _active_elevator_units.has(unit_id): + var existing_active: Dictionary = _active_elevator_units[unit_id] + return _queue_failure( + &"UNIT_ALREADY_ACTIVE", + "Elevator unit already has an active %s request." % existing_active[&"state"] + ) + if queue.size() >= int(connector[&"queue_capacity"]): + return _queue_failure(&"QUEUE_FULL", "Elevator request queue is full.") + queue.append(candidate) + queue.sort_custom(Callable(self, "_elevator_request_base_less")) + connector[&"request_queue"] = queue + _connectors[connector_id] = connector + _known_elevator_request_ids[request_id] = true + _active_elevator_units[unit_id] = { + &"unit_id": unit_id, + &"request_id": request_id, + &"connector_id": connector_id, + &"state": ELEVATOR_REQUEST_QUEUED, + } + _mark_connector_traffic_changed(connector, false) + last_error = "" + return { + &"ok": true, + &"replay": false, + &"request": candidate.duplicate(true), + &"queue_size": queue.size(), + } + + +func queue_elevator_request(connector_id: StringName, request: Dictionary) -> Dictionary: + return enqueue_elevator_request(connector_id, request) + + +func cancel_elevator_request(connector_id: StringName, request_id: StringName) -> bool: + if not _connectors.has(connector_id): + last_error = "Unknown connector id: %s" % connector_id + return false + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + last_error = "Connector is not an elevator: %s" % connector_id + return false + var queue: Array = connector[&"request_queue"] + for index: int in range(queue.size()): + var request: Dictionary = queue[index] + if request[&"request_id"] == request_id: + queue.remove_at(index) + connector[&"request_queue"] = queue + _connectors[connector_id] = connector + _active_elevator_units.erase(request[&"unit_id"]) + # Request ids are tombstoned permanently; cancellation permits a new + # id for the same unit but never reuse of the cancelled id. + _mark_connector_traffic_changed(connector, true) + last_error = "" + return true + last_error = "Unknown elevator request id: %s" % request_id + return false + + +func get_elevator_queue(connector_id: StringName, absolute_minute: int = 0) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if not _connectors.has(connector_id): + return result + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + return result + var queue: Array = (connector[&"request_queue"] as Array).duplicate(true) + queue.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: + return _elevator_request_less_at(left, right, absolute_minute) + ) + for value: Variant in queue: + result.append((value as Dictionary).duplicate(true)) + return result + + +func elevator_queue_size(connector_id: StringName) -> int: + if not _connectors.has(connector_id): + return 0 + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + return 0 + return (connector[&"request_queue"] as Array).size() + + +func dequeue_elevator_boarding( + connector_id: StringName, + current_floor: int, + absolute_minute: int, + available_capacity: int = -1 +) -> Array[Dictionary]: + var boarded: Array[Dictionary] = [] + if not _connectors.has(connector_id): + last_error = "Unknown connector id: %s" % connector_id + return boarded + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + last_error = "Connector is not an elevator: %s" % connector_id + return boarded + if not _connector_is_operational(connector): + last_error = "Elevator is not operational: %s" % connector_id + return boarded + var boarding_capacity := int(connector[&"cabin_capacity"]) + if available_capacity >= 0: + boarding_capacity = mini(boarding_capacity, available_capacity) + if boarding_capacity <= 0: + last_error = "" + return boarded + + var queue := get_elevator_queue(connector_id, absolute_minute) + var remaining: Array = [] + for request: Dictionary in queue: + if boarded.size() < boarding_capacity and int(request[&"floor_from"]) == current_floor: + boarded.append(request.duplicate(true)) + else: + remaining.append(request.duplicate(true)) + connector[&"request_queue"] = remaining + connector[&"current_floor"] = current_floor + connector[&"cabin_state"] = CABIN_DOOR_OPEN if not boarded.is_empty() else CABIN_IDLE + _connectors[connector_id] = connector + if not boarded.is_empty(): + for request: Dictionary in boarded: + var unit_id: StringName = request[&"unit_id"] + var active: Dictionary = _active_elevator_units.get(unit_id, {}) + if not active.is_empty() and active[&"request_id"] == request[&"request_id"]: + active[&"state"] = ELEVATOR_REQUEST_BOARDED + _active_elevator_units[unit_id] = active + _mark_connector_traffic_changed(connector, true) + last_error = "" + return boarded + + +func complete_elevator_request(connector_id: StringName, request_id: StringName) -> bool: + if connector_id.is_empty() or request_id.is_empty(): + last_error = "Elevator completion requires connector and request ids." + return false + for unit_id: StringName in _sorted_name_keys(_active_elevator_units): + var active: Dictionary = _active_elevator_units[unit_id] + if active[&"request_id"] != request_id: + continue + if active[&"connector_id"] != connector_id: + last_error = "Elevator request belongs to another connector." + return false + if active[&"state"] != ELEVATOR_REQUEST_BOARDED: + last_error = "Only a boarded elevator request can complete." + return false + _active_elevator_units.erase(unit_id) + last_error = "" + return true + last_error = "Unknown active elevator request id: %s" % request_id + return false + + +func complete_elevator_trip(connector_id: StringName, request_id: StringName) -> bool: + return complete_elevator_request(connector_id, request_id) + + +func get_active_elevator_request(unit_id: StringName) -> Dictionary: + if not _active_elevator_units.has(unit_id): + return {} + return (_active_elevator_units[unit_id] as Dictionary).duplicate(true) + + +func _active_request_id_exists(request_id: StringName) -> bool: + for unit_id: Variant in _active_elevator_units: + if (_active_elevator_units[unit_id] as Dictionary)[&"request_id"] == request_id: + return true + return false + + +func estimate_elevator_wait_cost(connector_id: StringName) -> int: + if not _connectors.has(connector_id): + return 0 + var connector: Dictionary = _connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + return 0 + var queue_size := (connector[&"request_queue"] as Array).size() + var capacity := maxi(int(connector[&"cabin_capacity"]), 1) + # Boarding costs one minute per unit. Each full cabin ahead adds another + # door cycle, represented here as two game minutes. + var cabin_cycles := ceili(float(queue_size) / float(capacity)) + return (queue_size + cabin_cycles * 2) * ORTHOGONAL_MOVE_COST + + +func find_path( + start: Vector3i, + goal: Vector3i, + access_layers: Variant = null, + connector_access: Variant = null +) -> Array[Vector3i]: + var started_usec := Time.get_ticks_usec() + var path := _find_path_internal(start, goal, access_layers, connector_access) + path_query_measured.emit(maxi(Time.get_ticks_usec() - started_usec, 0)) + return path + + +func _find_path_internal( + start: Vector3i, + goal: Vector3i, + access_layers: Variant = null, + connector_access: Variant = null +) -> Array[Vector3i]: + var raw_layers: Variant = access_layers + var raw_connector_access: Variant = connector_access + if access_layers is Dictionary: + var access_profile: Dictionary = access_layers + raw_layers = _profile_value( + access_profile, + [&"allowed_layers", &"access_layers", &"access_layer", &"layer"], + null + ) + if connector_access == null: + raw_connector_access = _profile_value( + access_profile, + [&"connector_access", &"connector_access_ids", &"access_ids"], + null + ) + + var layer_result := _normalize_access_layers(raw_layers) + if not layer_result[&"ok"]: + last_error = layer_result[&"error"] + last_no_route_reason = ROUTE_ACCESS_LAYER_DENIED + last_path_cost = 0 + return [] + var connector_result := _normalize_access_ids(raw_connector_access) + if not connector_result[&"ok"]: + last_error = connector_result[&"error"] + last_no_route_reason = ROUTE_CONNECTOR_UNAVAILABLE + last_path_cost = 0 + return [] + + var allowed_layers: Array[int] = layer_result[&"values"] + var allowed_access_ids: Array[StringName] = connector_result[&"values"] + if not _passable_cells.has(start): + return _route_failure(ROUTE_START_NOT_PASSABLE) + if not _passable_cells.has(goal): + return _route_failure(ROUTE_GOAL_NOT_PASSABLE) + if start.z not in allowed_layers or goal.z not in allowed_layers: + return _route_failure(ROUTE_ACCESS_LAYER_DENIED) + if _cell_is_temporarily_blocked(start): + return _route_failure(ROUTE_START_BLOCKED) + if _cell_is_temporarily_blocked(goal): + return _route_failure(ROUTE_GOAL_BLOCKED) + + var cache_key := _cache_key(start, goal, allowed_layers, allowed_access_ids) + if _path_cache.has(cache_key): + var cached: Array = _path_cache[cache_key] + if _path_is_currently_valid(cached, allowed_layers, allowed_access_ids): + cache_hits += 1 + last_no_route_reason = ROUTE_OK + last_path_cost = _calculate_path_cost_for_access(cached, allowed_access_ids) + last_error = "" + return _copy_path(cached) + _path_cache.erase(cache_key) + + cache_misses += 1 + var path := _run_a_star(start, goal, allowed_layers, allowed_access_ids) + if path.is_empty(): + return _route_failure(_diagnose_no_route_reason( + start, + goal, + allowed_layers, + allowed_access_ids + )) + _path_cache[cache_key] = _copy_path(path) + last_no_route_reason = ROUTE_OK + last_path_cost = _calculate_path_cost_for_access(path, allowed_access_ids) + last_error = "" + return path + + +func find_path_report( + start: Vector3i, + goal: Vector3i, + access_layers: Variant = null, + connector_access: Variant = null +) -> Dictionary: + var path := find_path(start, goal, access_layers, connector_access) + return { + &"reachable": not path.is_empty(), + &"reason": last_no_route_reason, + &"cost": last_path_cost, + &"path": path, + &"graph_revision": graph_revision, + &"traffic_revision": traffic_revision, + } + + +func calculate_path_cost(path: Array, connector_access: Variant = null) -> int: + var access_result := _normalize_access_ids(connector_access) + if not access_result[&"ok"]: + last_error = access_result[&"error"] + return -1 + var total := _calculate_path_cost_for_access(path, access_result[&"values"]) + last_error = "" if total >= 0 else "Path contains an unavailable edge." + return total + + +func _calculate_path_cost_for_access( + path: Array, + allowed_access_ids: Array[StringName] +) -> int: + if path.is_empty(): + return 0 + var total := 0 + for index: int in range(1, path.size()): + var from_cell: Variant = path[index - 1] + var to_cell: Variant = path[index] + if not from_cell is Vector3i or not to_cell is Vector3i: + return -1 + var edge := _find_edge(from_cell, to_cell, allowed_access_ids) + if edge.is_empty(): + return -1 + total += _edge_total_cost(edge) + return total + + +func invalidate_cache_for_cells(cells: Variant) -> int: + var normalization := _normalize_cell_set(cells, false) + if not normalization[&"ok"]: + last_error = normalization[&"error"] + return 0 + var affected: Dictionary = normalization[&"cells"] + var keys_to_remove: Array = [] + for cache_key: Variant in _path_cache: + var cached_path: Array = _path_cache[cache_key] + if cached_path.is_empty() or _path_touches(cached_path, affected): + keys_to_remove.append(cache_key) + for cache_key: Variant in keys_to_remove: + _path_cache.erase(cache_key) + last_error = "" + return keys_to_remove.size() + + +func invalidate_local_cache(cells: Variant) -> int: + return invalidate_cache_for_cells(cells) + + +func invalidate_all_cache() -> int: + var removed := _path_cache.size() + _path_cache.clear() + return removed + + +func cached_path_count() -> int: + return _path_cache.size() + + +func has_cell(cell: Vector3i) -> bool: + return _passable_cells.has(cell) + + +func has_connector(connector_id: StringName) -> bool: + return _connectors.has(connector_id) + + +func get_connector(connector_id: StringName) -> Dictionary: + if not _connectors.has(connector_id): + return {} + return (_connectors[connector_id] as Dictionary).duplicate(true) + + +func get_neighbors(cell: Vector3i) -> Array: + if not _adjacency.has(cell): + return [] + return (_adjacency[cell] as Array).duplicate(true) + + +func record_actor_progress(actor_id: StringName, cell: Vector3i, absolute_minute: int) -> Dictionary: + if actor_id.is_empty() or absolute_minute < 0: + last_error = "Progress tracking requires an actor id and non-negative minute." + return {&"ok": false, &"reason": &"INVALID_PROGRESS"} + if _actor_progress.has(actor_id): + var existing: Dictionary = _actor_progress[actor_id] + if absolute_minute < int(existing[&"last_observed_at"]): + last_error = "Actor progress time cannot move backwards." + return {&"ok": false, &"reason": &"TIME_REVERSED"} + var advanced: bool = existing[&"last_cell"] != cell + existing[&"last_cell"] = cell + existing[&"last_observed_at"] = absolute_minute + if advanced: + existing[&"last_progress_at"] = absolute_minute + existing[&"stuck_reported_at"] = -1 + _actor_progress[actor_id] = existing + last_error = "" + return { + &"ok": true, + &"advanced": advanced, + &"minutes_without_progress": absolute_minute - int(existing[&"last_progress_at"]), + } + _actor_progress[actor_id] = { + &"actor_id": actor_id, + &"last_cell": cell, + &"last_progress_at": absolute_minute, + &"last_observed_at": absolute_minute, + &"stuck_reported_at": -1, + } + last_error = "" + return {&"ok": true, &"advanced": true, &"minutes_without_progress": 0} + + +func get_actor_stuck_report( + actor_id: StringName, + absolute_minute: int, + allowed_layers: Variant = null +) -> Dictionary: + if not _actor_progress.has(actor_id): + last_error = "Unknown actor progress id: %s" % actor_id + return { + &"ok": false, + &"actor_id": actor_id, + &"reason": &"PROGRESS_NOT_TRACKED", + &"should_recover": false, + } + var progress: Dictionary = _actor_progress[actor_id] + if absolute_minute < int(progress[&"last_observed_at"]): + last_error = "Stuck evaluation time cannot move backwards." + return { + &"ok": false, + &"actor_id": actor_id, + &"reason": &"TIME_REVERSED", + &"should_recover": false, + } + var minutes_without_progress := absolute_minute - int(progress[&"last_progress_at"]) + var should_recover := minutes_without_progress >= STUCK_THRESHOLD_MINUTES + var recovery := { + &"found": false, + &"reason": &"NOT_STUCK", + } + if should_recover: + recovery = find_nearest_walkable_recovery(progress[&"last_cell"], allowed_layers) + if int(progress[&"stuck_reported_at"]) < 0: + progress[&"stuck_reported_at"] = absolute_minute + progress[&"last_observed_at"] = absolute_minute + _actor_progress[actor_id] = progress + last_error = "" + return { + &"ok": true, + &"actor_id": actor_id, + &"reason": &"STUCK" if should_recover else &"PROGRESS_RECENT", + &"should_recover": should_recover, + &"threshold_minutes": STUCK_THRESHOLD_MINUTES, + &"minutes_without_progress": minutes_without_progress, + &"last_cell": progress[&"last_cell"], + &"recovery": recovery, + &"requires_explicit_move": should_recover, + } + + +func check_actor_stuck( + actor_id: StringName, + absolute_minute: int, + allowed_layers: Variant = null +) -> Dictionary: + return get_actor_stuck_report(actor_id, absolute_minute, allowed_layers) + + +func clear_actor_progress(actor_id: StringName) -> bool: + if not _actor_progress.has(actor_id): + return false + _actor_progress.erase(actor_id) + return true + + +func find_nearest_walkable_recovery( + origin: Vector3i, + allowed_layers: Variant = null +) -> Dictionary: + var normalized := _normalize_access_layers(allowed_layers) + if not normalized[&"ok"]: + return {&"found": false, &"reason": &"INVALID_ACCESS_LAYERS"} + var layers: Array[int] = normalized[&"values"] + var candidates: Array[Vector3i] = [] + for cell_value: Variant in _passable_cells: + var cell: Vector3i = cell_value + if cell.z in layers and not _cell_is_temporarily_blocked(cell): + candidates.append(cell) + if candidates.is_empty(): + return {&"found": false, &"reason": &"NO_SAFE_WALKABLE_CELL"} + candidates.sort_custom(func(left: Vector3i, right: Vector3i) -> bool: + var left_distance := _recovery_distance(origin, left) + var right_distance := _recovery_distance(origin, right) + if left_distance != right_distance: + return left_distance < right_distance + return _coordinate_less(left, right) + ) + var target: Vector3i = candidates[0] + return { + &"found": true, + &"reason": &"SAFE_WALKABLE_CELL", + &"cell": target, + &"distance": _recovery_distance(origin, target), + &"implicit_move_applied": false, + } + + +func create_snapshot() -> Dictionary: + var connector_ids: Array = _connectors.keys() + connector_ids.sort_custom(Callable(self, "_string_name_less")) + var saved_connectors: Array = [] + for connector_id: Variant in connector_ids: + var connector: Dictionary = (_connectors[connector_id] as Dictionary).duplicate(true) + var queue: Array = connector[&"request_queue"] + queue.sort_custom(Callable(self, "_elevator_request_base_less")) + connector[&"request_queue"] = queue + saved_connectors.append(connector) + + var cells: Array = _passable_cells.keys() + cells.sort_custom(Callable(self, "_coordinate_less")) + var saved_cell_traffic: Array = [] + for cell_value: Variant in cells: + var cell: Vector3i = cell_value + var traffic: Dictionary = _cell_traffic.get(cell, _default_cell_traffic()) + saved_cell_traffic.append({ + &"cell": cell, + &"base_capacity": int(traffic[&"base_capacity"]), + &"current_density": int(traffic[&"current_density"]), + &"dynamic_cost": int(traffic[&"dynamic_cost"]), + &"temporary_block": bool(traffic[&"temporary_block"]), + }) + + var actor_ids: Array = _actor_cells.keys() + actor_ids.sort_custom(Callable(self, "_string_name_less")) + var saved_actor_cells: Array = [] + for actor_id: Variant in actor_ids: + saved_actor_cells.append({ + &"actor_id": StringName(actor_id), + &"cell": _actor_cells[actor_id], + }) + + var progress_ids: Array = _actor_progress.keys() + progress_ids.sort_custom(Callable(self, "_string_name_less")) + var saved_progress: Array = [] + for actor_id: Variant in progress_ids: + saved_progress.append((_actor_progress[actor_id] as Dictionary).duplicate(true)) + + var known_request_ids: Array = _known_elevator_request_ids.keys() + known_request_ids.sort_custom(Callable(self, "_string_name_less")) + var saved_known_requests: Array[StringName] = [] + for request_id: Variant in known_request_ids: + saved_known_requests.append(StringName(request_id)) + var active_unit_ids: Array = _active_elevator_units.keys() + active_unit_ids.sort_custom(Callable(self, "_string_name_less")) + var saved_active_units: Array = [] + for unit_id: Variant in active_unit_ids: + saved_active_units.append((_active_elevator_units[unit_id] as Dictionary).duplicate(true)) + return { + &"schema_version": SNAPSHOT_VERSION, + &"graph_revision": graph_revision, + &"traffic_revision": traffic_revision, + &"connectors": saved_connectors, + &"cell_traffic": saved_cell_traffic, + &"actor_cells": saved_actor_cells, + &"actor_progress": saved_progress, + &"known_elevator_request_ids": saved_known_requests, + &"active_elevator_units": saved_active_units, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + var version := int(_dictionary_value(snapshot, &"schema_version", -1)) + if version not in [LEGACY_SNAPSHOT_VERSION, TRAFFIC_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + last_error = "Unsupported PathService snapshot version: %d" % version + return false + var raw_connectors: Variant = _dictionary_value(snapshot, &"connectors", null) + if not raw_connectors is Array: + last_error = "PathService snapshot connectors must be an Array." + return false + var connector_result := _normalize_connector_collection(raw_connectors) + if not connector_result[&"ok"]: + last_error = connector_result[&"error"] + return false + var next_connectors: Dictionary = connector_result[&"connectors"] + var next_known_requests: Dictionary = connector_result[&"known_request_ids"] + var next_active_units: Dictionary = connector_result[&"active_units"] + + if version == LEGACY_SNAPSHOT_VERSION: + var changed := next_connectors != _connectors + _connectors = next_connectors + _cell_traffic.clear() + _actor_cells.clear() + _actor_progress.clear() + _known_elevator_request_ids = next_known_requests + _active_elevator_units = next_active_units + traffic_revision = 0 + _pending_graph_revision = -1 + _traffic_loaded_without_graph = false + _ensure_cell_traffic_for_graph(_passable_cells, true) + _rebuild_derived_graph() + if changed: + _mark_graph_changed() + else: + invalidate_all_cache() + last_error = "" + return true + + var saved_graph_revision := int(_dictionary_value(snapshot, &"graph_revision", -1)) + var saved_traffic_revision := int(_dictionary_value(snapshot, &"traffic_revision", -1)) + var raw_cell_traffic: Variant = _dictionary_value(snapshot, &"cell_traffic", null) + var raw_actor_cells: Variant = _dictionary_value(snapshot, &"actor_cells", null) + var raw_progress: Variant = _dictionary_value(snapshot, &"actor_progress", null) + if saved_graph_revision < 0 or saved_traffic_revision < 0 \ + or not raw_cell_traffic is Array \ + or not raw_actor_cells is Array \ + or not raw_progress is Array: + last_error = "PathService traffic snapshot fields are missing or invalid." + return false + + var traffic_result := _normalize_snapshot_cell_traffic(raw_cell_traffic) + if not traffic_result[&"ok"]: + last_error = traffic_result[&"error"] + return false + var next_traffic: Dictionary = traffic_result[&"traffic"] + var actor_result := _normalize_snapshot_actor_cells(raw_actor_cells, next_traffic) + if not actor_result[&"ok"]: + last_error = actor_result[&"error"] + return false + var next_actor_cells: Dictionary = actor_result[&"actor_cells"] + var progress_result := _normalize_snapshot_actor_progress(raw_progress) + if not progress_result[&"ok"]: + last_error = progress_result[&"error"] + return false + var next_progress: Dictionary = progress_result[&"progress"] + if version == SNAPSHOT_VERSION: + var raw_known_requests: Variant = _dictionary_value( + snapshot, + &"known_elevator_request_ids", + null + ) + var raw_active_units: Variant = _dictionary_value(snapshot, &"active_elevator_units", null) + if not raw_known_requests is Array or not raw_active_units is Array: + last_error = "PathService v3 elevator request indexes are missing or invalid." + return false + var index_result := _normalize_snapshot_elevator_indexes( + raw_known_requests, + raw_active_units, + next_connectors, + connector_result[&"active_units"] + ) + if not index_result[&"ok"]: + last_error = index_result[&"error"] + return false + next_known_requests = index_result[&"known_request_ids"] + next_active_units = index_result[&"active_units"] + if not _snapshot_densities_match(next_traffic, next_actor_cells): + last_error = "PathService snapshot density does not match actor projections." + return false + if not _passable_cells.is_empty(): + if next_traffic.size() != _passable_cells.size(): + last_error = "PathService snapshot traffic topology does not match the current graph." + return false + for cell_value: Variant in next_traffic: + if not _passable_cells.has(cell_value): + last_error = "PathService snapshot traffic references an unknown cell." + return false + + _connectors = next_connectors + _cell_traffic = next_traffic + _actor_cells = next_actor_cells + _actor_progress = next_progress + _known_elevator_request_ids = next_known_requests + _active_elevator_units = next_active_units + traffic_revision = saved_traffic_revision + _pending_graph_revision = saved_graph_revision if _passable_cells.is_empty() else -1 + _traffic_loaded_without_graph = _passable_cells.is_empty() and not next_traffic.is_empty() + if not _passable_cells.is_empty(): + graph_revision = saved_graph_revision + _rebuild_derived_graph() + invalidate_all_cache() + last_no_route_reason = ROUTE_OK + last_path_cost = 0 + last_error = "" + return true + + +func _rebuild_derived_graph() -> void: + _adjacency.clear() + var cells: Array = _passable_cells.keys() + cells.sort_custom(Callable(self, "_coordinate_less")) + for cell_value: Variant in cells: + var cell: Vector3i = cell_value + _adjacency[cell] = [] + + for cell_value: Variant in cells: + var cell: Vector3i = cell_value + for direction in _CARDINAL_DIRECTIONS: + var neighbor := cell + direction + if _passable_cells.has(neighbor): + _append_edge( + cell, + neighbor, + ORTHOGONAL_MOVE_COST, + &"", + &"", + MOVEMENT_WALK + ) + + var connector_ids: Array = _connectors.keys() + connector_ids.sort_custom(Callable(self, "_string_name_less")) + for connector_id_value: Variant in connector_ids: + var connector_id: StringName = connector_id_value + var connector: Dictionary = _connectors[connector_id] + if not _connector_is_operational(connector): + continue + var from_cell: Vector3i = connector[&"from"] + var to_cell: Vector3i = connector[&"to"] + if not _passable_cells.has(from_cell) or not _passable_cells.has(to_cell): + continue + var cost: int = connector[&"cost"] + var access_id: StringName = connector[&"access_id"] + var movement_type: StringName = connector[&"movement_type"] + _append_edge(from_cell, to_cell, cost, connector_id, access_id, movement_type) + if bool(connector[&"bidirectional"]): + _append_edge(to_cell, from_cell, cost, connector_id, access_id, movement_type) + + for cell_value: Variant in cells: + var edges: Array = _adjacency[cell_value] + edges.sort_custom(Callable(self, "_edge_less")) + _adjacency[cell_value] = edges + + +func _append_edge( + from_cell: Vector3i, + to_cell: Vector3i, + cost: int, + connector_id: StringName, + access_id: StringName, + movement_type: StringName +) -> void: + var edges: Array = _adjacency[from_cell] + edges.append({ + &"to": to_cell, + &"cost": cost, + &"base_cost": cost, + &"connector_id": connector_id, + &"access_id": access_id, + &"movement_type": movement_type, + }) + _adjacency[from_cell] = edges + + +func _run_a_star( + start: Vector3i, + goal: Vector3i, + allowed_layers: Array[int], + allowed_access_ids: Array[StringName] +) -> Array[Vector3i]: + if start == goal: + return [start] + + var open_set: Dictionary = {start: true} + var closed_set: Dictionary = {} + var came_from: Dictionary = {} + var g_score: Dictionary = {start: 0} + + while not open_set.is_empty(): + var current := _select_best_open_node(open_set, g_score, goal) + if current == goal: + return _reconstruct_path(came_from, current) + + open_set.erase(current) + closed_set[current] = true + var edges: Array = _adjacency.get(current, []) + for edge_value: Variant in edges: + var edge: Dictionary = edge_value + var neighbor: Vector3i = edge[&"to"] + if neighbor.z not in allowed_layers \ + or _cell_is_temporarily_blocked(neighbor) \ + or not _edge_is_accessible(edge, allowed_access_ids): + continue + var tentative_cost: int = int(g_score[current]) + _edge_total_cost(edge) + var known_cost: int = int(g_score.get(neighbor, _INFINITE_COST)) + var better_parent := false + if tentative_cost == known_cost and came_from.has(neighbor): + better_parent = _coordinate_less(current, came_from[neighbor]) + if tentative_cost > known_cost or (tentative_cost == known_cost and not better_parent): + continue + + came_from[neighbor] = current + g_score[neighbor] = tentative_cost + if closed_set.has(neighbor): + closed_set.erase(neighbor) + open_set[neighbor] = true + + return [] + + +func _select_best_open_node(open_set: Dictionary, g_score: Dictionary, goal: Vector3i) -> Vector3i: + var best := Vector3i.ZERO + var has_best := false + var best_score := _INFINITE_COST + for candidate_value: Variant in open_set: + var candidate: Vector3i = candidate_value + var score: int = int(g_score[candidate]) + _heuristic(candidate, goal) + if not has_best or score < best_score: + best = candidate + best_score = score + has_best = true + elif score == best_score and _coordinate_less(candidate, best): + best = candidate + return best + + +func _heuristic(from_cell: Vector3i, to_cell: Vector3i) -> int: + # A connector may be cheaper than the geometric distance. In that case zero is + # the conservative admissible heuristic; without connectors Manhattan is exact. + if not _connectors.is_empty() or from_cell.z != to_cell.z: + return 0 + return (absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y)) * ORTHOGONAL_MOVE_COST + + +func _reconstruct_path(came_from: Dictionary, goal: Vector3i) -> Array[Vector3i]: + var reversed_path: Array[Vector3i] = [goal] + var current := goal + var maximum_length := _adjacency.size() + 1 + while came_from.has(current) and reversed_path.size() <= maximum_length: + current = came_from[current] + reversed_path.append(current) + reversed_path.reverse() + return reversed_path + + +func _edge_is_accessible(edge: Dictionary, allowed_access_ids: Array[StringName]) -> bool: + var required_access: StringName = edge[&"access_id"] + if not required_access.is_empty() and required_access not in allowed_access_ids: + return false + var connector_id: StringName = edge[&"connector_id"] + if connector_id.is_empty(): + return true + return _connectors.has(connector_id) and _connector_is_operational(_connectors[connector_id]) + + +func _edge_total_cost(edge: Dictionary) -> int: + var to_cell: Vector3i = edge[&"to"] + var total := int(edge.get(&"base_cost", edge.get(&"cost", ORTHOGONAL_MOVE_COST))) + total += get_cell_dynamic_cost(to_cell) + var connector_id: StringName = edge.get(&"connector_id", &"") + if connector_id.is_empty() or not _connectors.has(connector_id): + return total + var connector: Dictionary = _connectors[connector_id] + total += int(connector[&"dynamic_cost"]) + if connector[&"movement_type"] == MOVEMENT_ELEVATOR: + total += estimate_elevator_wait_cost(connector_id) + var from_cell: Vector3i = connector[&"from"] + var destination: Vector3i = connector[&"to"] + var floor_distance := absi(destination.y - from_cell.y) + total += floor_distance * int(connector[&"travel_minutes_per_floor"]) * ORTHOGONAL_MOVE_COST + return total + + +func _find_edge( + from_cell: Vector3i, + to_cell: Vector3i, + allowed_access_ids: Variant = null +) -> Dictionary: + var best: Dictionary = {} + var best_cost := _INFINITE_COST + for edge_value: Variant in _adjacency.get(from_cell, []): + var edge: Dictionary = edge_value + if edge[&"to"] != to_cell: + continue + if allowed_access_ids is Array and not _edge_is_accessible(edge, allowed_access_ids): + continue + var cost := _edge_total_cost(edge) + if best.is_empty() or cost < best_cost \ + or (cost == best_cost and _edge_less(edge, best)): + best = edge + best_cost = cost + return best + + +func _path_is_currently_valid( + path: Array, + allowed_layers: Array[int], + allowed_access_ids: Array[StringName] +) -> bool: + if path.is_empty(): + return false + for cell_value: Variant in path: + if not cell_value is Vector3i: + return false + var cell: Vector3i = cell_value + if not _passable_cells.has(cell) or cell.z not in allowed_layers \ + or _cell_is_temporarily_blocked(cell): + return false + for index: int in range(1, path.size()): + var edge := _find_edge(path[index - 1], path[index], allowed_access_ids) + if edge.is_empty(): + return false + return true + + +func _route_failure(reason: StringName) -> Array[Vector3i]: + last_no_route_reason = reason + last_path_cost = 0 + last_error = str(reason) + return [] + + +func _cell_is_temporarily_blocked(cell: Vector3i) -> bool: + var traffic: Dictionary = _cell_traffic.get(cell, {}) + return not traffic.is_empty() and bool(traffic[&"temporary_block"]) + + +func _diagnose_no_route_reason( + start: Vector3i, + goal: Vector3i, + allowed_layers: Array[int], + allowed_access_ids: Array[StringName] +) -> StringName: + # Diagnose only obstacles which actually separate this start/goal pair. A + # stopped lift or blocked cell in another disconnected region must not leak + # into this route's reason. + if _route_exists_with_overrides( + start, + goal, + allowed_layers, + allowed_access_ids, + true, + false + ): + return ROUTE_TEMPORARILY_BLOCKED + if _route_exists_with_overrides( + start, + goal, + allowed_layers, + allowed_access_ids, + false, + true + ): + return ROUTE_CONNECTOR_UNAVAILABLE + return ROUTE_GOAL_UNREACHABLE + + +func _route_exists_with_overrides( + start: Vector3i, + goal: Vector3i, + allowed_layers: Array[int], + allowed_access_ids: Array[StringName], + allow_blocked_cells: bool, + allow_unavailable_connectors: bool +) -> bool: + var frontier: Array[Vector3i] = [start] + var visited: Dictionary = {start: true} + var cursor := 0 + var connector_ids := _sorted_name_keys(_connectors) + while cursor < frontier.size(): + var current: Vector3i = frontier[cursor] + cursor += 1 + if current == goal: + return true + var neighbor_set: Dictionary = {} + for direction: Vector3i in _CARDINAL_DIRECTIONS: + var neighbor := current + direction + if _passable_cells.has(neighbor): + neighbor_set[neighbor] = true + for connector_id: StringName in connector_ids: + var connector: Dictionary = _connectors[connector_id] + var required_access: StringName = connector[&"access_id"] + if not required_access.is_empty() and required_access not in allowed_access_ids: + continue + if not allow_unavailable_connectors and not _connector_is_operational(connector): + continue + var from_cell: Vector3i = connector[&"from"] + var to_cell: Vector3i = connector[&"to"] + if current == from_cell: + neighbor_set[to_cell] = true + elif bool(connector[&"bidirectional"]) and current == to_cell: + neighbor_set[from_cell] = true + var neighbors: Array = neighbor_set.keys() + neighbors.sort_custom(Callable(self, "_coordinate_less")) + for neighbor_value: Variant in neighbors: + var neighbor: Vector3i = neighbor_value + if visited.has(neighbor) or neighbor.z not in allowed_layers: + continue + if not allow_blocked_cells and _cell_is_temporarily_blocked(neighbor): + continue + visited[neighbor] = true + frontier.append(neighbor) + return false + + +func _connector_is_operational(connector: Dictionary) -> bool: + return bool(connector[&"enabled"]) \ + and connector[&"operational_state"] == CONNECTOR_OPERATIONAL \ + and ( + connector[&"movement_type"] != MOVEMENT_ELEVATOR + or connector[&"cabin_state"] != CABIN_OUTAGE + ) + + +func _ensure_cell_traffic_for_graph(cells: Dictionary, reset_missing: bool) -> void: + var next_traffic: Dictionary = {} + for cell_value: Variant in cells: + var cell: Vector3i = cell_value + if _cell_traffic.has(cell): + next_traffic[cell] = (_cell_traffic[cell] as Dictionary).duplicate(true) + elif reset_missing: + next_traffic[cell] = _default_cell_traffic() + else: + next_traffic[cell] = _default_cell_traffic() + _cell_traffic = next_traffic + var invalid_actor_ids: Array = [] + for actor_id: Variant in _actor_cells: + if not cells.has(_actor_cells[actor_id]): + invalid_actor_ids.append(actor_id) + for actor_id: Variant in invalid_actor_ids: + _actor_cells.erase(actor_id) + _recompute_all_densities() + + +func _default_cell_traffic() -> Dictionary: + return { + &"base_capacity": DEFAULT_CELL_CAPACITY, + &"current_density": 0, + &"dynamic_cost": 0, + &"temporary_block": false, + } + + +func _increment_density(cell: Vector3i) -> void: + var traffic: Dictionary = _cell_traffic[cell] + traffic[&"current_density"] = int(traffic[&"current_density"]) + 1 + _cell_traffic[cell] = traffic + + +func _decrement_density(cell: Vector3i) -> bool: + if not _cell_traffic.has(cell): + return false + var traffic: Dictionary = _cell_traffic[cell] + var density := int(traffic[&"current_density"]) + if density <= 0: + return false + traffic[&"current_density"] = density - 1 + _cell_traffic[cell] = traffic + return true + + +func _recompute_all_densities() -> void: + for cell_value: Variant in _cell_traffic: + var traffic: Dictionary = _cell_traffic[cell_value] + traffic[&"current_density"] = 0 + _cell_traffic[cell_value] = traffic + for actor_id: Variant in _actor_cells: + var cell: Vector3i = _actor_cells[actor_id] + if _cell_traffic.has(cell): + _increment_density(cell) + + +func _mark_traffic_changed(cells: Variant, may_make_routes_cheaper: bool = false) -> void: + traffic_revision += 1 + if may_make_routes_cheaper: + # A cached path cannot know which non-selected alternatives were explored. + # Cost decreases and block removal therefore invalidate broadly for + # correctness; cost increases remain locally invalidated. + invalidate_all_cache() + else: + invalidate_cache_for_cells(cells) + + +func _mark_connector_traffic_changed( + connector: Dictionary, + may_make_routes_cheaper: bool = false +) -> void: + _mark_traffic_changed( + [connector[&"from"], connector[&"to"]], + may_make_routes_cheaper + ) + + +func _traffic_effective_cost(traffic: Dictionary) -> int: + return int(traffic[&"dynamic_cost"]) + maxi( + 0, + int(traffic[&"current_density"]) - int(traffic[&"base_capacity"]) + ) * CONGESTION_COST_PER_EXCESS + + +func _queue_failure(reason: StringName, message: String) -> Dictionary: + last_error = message + return {&"ok": false, &"reason": reason, &"error": message} + + +func _normalize_elevator_request(raw: Dictionary) -> Dictionary: + var request_id := StringName(str(_dictionary_value(raw, &"request_id", ""))) + var unit_id := StringName(str(_dictionary_value(raw, &"unit_id", ""))) + var floor_from_value: Variant = _dictionary_value(raw, &"floor_from", null) + var floor_to_value: Variant = _dictionary_value(raw, &"floor_to", null) + var enqueued_at_value: Variant = _dictionary_value(raw, &"enqueued_at", null) + if request_id.is_empty() or unit_id.is_empty(): + return {&"ok": false, &"error": "Elevator request and unit ids must not be empty."} + if not floor_from_value is int or not floor_to_value is int or not enqueued_at_value is int: + return {&"ok": false, &"error": "Elevator floors and enqueue minute must be integers."} + var floor_from := int(floor_from_value) + var floor_to := int(floor_to_value) + var enqueued_at := int(enqueued_at_value) + if floor_from == floor_to or enqueued_at < 0: + return {&"ok": false, &"error": "Elevator request must change floors at a non-negative minute."} + var expected_direction := &"UP" if floor_to > floor_from else &"DOWN" + var direction := StringName(str(_dictionary_value(raw, &"direction", expected_direction))) + if direction != expected_direction: + return {&"ok": false, &"error": "Elevator request direction disagrees with its floors."} + var priority_result := _normalize_elevator_priority(_dictionary_value( + raw, + &"priority", + ELEVATOR_PRIORITY_NORMAL + )) + if not priority_result[&"ok"]: + return priority_result + return { + &"ok": true, + &"request": { + &"request_id": request_id, + &"unit_id": unit_id, + &"floor_from": floor_from, + &"floor_to": floor_to, + &"direction": direction, + &"priority": int(priority_result[&"priority"]), + &"enqueued_at": enqueued_at, + }, + } + + +func _normalize_elevator_priority(value: Variant) -> Dictionary: + if value is int: + var priority := int(value) + if priority < ELEVATOR_PRIORITY_RESCUE or priority > ELEVATOR_PRIORITY_NORMAL: + return {&"ok": false, &"error": "Elevator priority is outside the supported range."} + return {&"ok": true, &"priority": priority} + if value is String or value is StringName: + var key := StringName(str(value).to_upper()) + var priorities := { + &"RESCUE": ELEVATOR_PRIORITY_RESCUE, + &"SECURITY": ELEVATOR_PRIORITY_SECURITY, + &"PROTAGONIST": ELEVATOR_PRIORITY_PROTAGONIST, + &"VIP": ELEVATOR_PRIORITY_VIP, + &"NORMAL": ELEVATOR_PRIORITY_NORMAL, + } + if priorities.has(key): + return {&"ok": true, &"priority": priorities[key]} + return {&"ok": false, &"error": "Elevator priority must be a known name or integer."} + + +func _elevator_request_base_less(left: Variant, right: Variant) -> bool: + var left_request: Dictionary = left + var right_request: Dictionary = right + return _compare_elevator_requests(left_request, right_request, -1) + + +func _elevator_request_less_at( + left: Dictionary, + right: Dictionary, + absolute_minute: int +) -> bool: + return _compare_elevator_requests(left, right, absolute_minute) + + +func _compare_elevator_requests( + left: Dictionary, + right: Dictionary, + absolute_minute: int +) -> bool: + var left_priority := _effective_elevator_priority(left, absolute_minute) + var right_priority := _effective_elevator_priority(right, absolute_minute) + if left_priority != right_priority: + return left_priority < right_priority + var left_enqueued := int(left[&"enqueued_at"]) + var right_enqueued := int(right[&"enqueued_at"]) + if left_enqueued != right_enqueued: + return left_enqueued < right_enqueued + var left_unit := str(left[&"unit_id"]) + var right_unit := str(right[&"unit_id"]) + if left_unit != right_unit: + return left_unit < right_unit + return str(left[&"request_id"]) < str(right[&"request_id"]) + + +func _effective_elevator_priority(request: Dictionary, absolute_minute: int) -> int: + var priority := int(request[&"priority"]) + if absolute_minute >= 0 \ + and priority == ELEVATOR_PRIORITY_NORMAL \ + and absolute_minute - int(request[&"enqueued_at"]) >= ELEVATOR_STARVATION_MINUTES: + return ELEVATOR_PRIORITY_VIP - 1 + return priority + + +func _normalize_cell_set(raw_cells: Variant, validate_layers := true) -> Dictionary: + if not (raw_cells is Array) and not (raw_cells is Dictionary): + return { + &"ok": false, + &"error": "Passable cells must be an Array or Dictionary set.", + } + + var values: Array = [] + if raw_cells is Array: + values = raw_cells + else: + for key: Variant in raw_cells: + if bool(raw_cells[key]): + values.append(key) + + var cells: Dictionary = {} + for value: Variant in values: + if not value is Vector3i: + return { + &"ok": false, + &"error": "Every passable coordinate must be a Vector3i.", + } + var cell: Vector3i = value + if validate_layers and not _is_valid_layer(cell.z): + return { + &"ok": false, + &"error": "Coordinate layer must be FRONT (0) or BACK (1): %s" % cell, + } + cells[cell] = true + return { + &"ok": true, + &"cells": cells, + } + + +func _normalize_connector(raw: Dictionary) -> Dictionary: + var connector_id := StringName(str(_dictionary_value(raw, &"id", ""))) + var from_value: Variant = _dictionary_value(raw, &"from", null) + var to_value: Variant = _dictionary_value(raw, &"to", null) + var cost := int(_dictionary_value(raw, &"cost", DEFAULT_CONNECTOR_COST)) + var bidirectional := bool(_dictionary_value(raw, &"bidirectional", true)) + var access_id := StringName(str(_dictionary_value(raw, &"access_id", ""))) + var enabled := bool(_dictionary_value(raw, &"enabled", true)) + var movement_type := StringName(str(_dictionary_value(raw, &"movement_type", MOVEMENT_CONNECTOR))) + var dynamic_cost := int(_dictionary_value(raw, &"dynamic_cost", 0)) + var operational_state := StringName(str(_dictionary_value( + raw, + &"operational_state", + CONNECTOR_OPERATIONAL + ))) + var cabin_capacity := int(_dictionary_value(raw, &"cabin_capacity", 8)) + var queue_capacity := int(_dictionary_value(raw, &"queue_capacity", 256)) + var cabin_state := StringName(str(_dictionary_value(raw, &"cabin_state", CABIN_IDLE))) + var current_floor := int(_dictionary_value( + raw, + &"current_floor", + from_value.y if from_value is Vector3i else 0 + )) + var travel_minutes_per_floor := int(_dictionary_value(raw, &"travel_minutes_per_floor", 1)) + var raw_queue: Variant = _dictionary_value(raw, &"request_queue", []) + + if connector_id.is_empty(): + return {&"ok": false, &"error": "Connector id must not be empty."} + if not from_value is Vector3i or not to_value is Vector3i: + return {&"ok": false, &"error": "Connector endpoints must be Vector3i coordinates."} + var from_cell: Vector3i = from_value + var to_cell: Vector3i = to_value + if not _is_valid_layer(from_cell.z) or not _is_valid_layer(to_cell.z): + return {&"ok": false, &"error": "Connector endpoint layer must be FRONT (0) or BACK (1)."} + if from_cell == to_cell: + return {&"ok": false, &"error": "Connector endpoints must differ."} + if cost <= 0: + return {&"ok": false, &"error": "Connector cost must be a positive integer."} + if dynamic_cost < 0: + return {&"ok": false, &"error": "Connector dynamic cost must be non-negative."} + if movement_type not in [MOVEMENT_CONNECTOR, MOVEMENT_ELEVATOR]: + return {&"ok": false, &"error": "Unsupported connector movement type: %s" % movement_type} + if operational_state not in [ + CONNECTOR_OPERATIONAL, + CONNECTOR_STOPPED, + CONNECTOR_OUTAGE, + CONNECTOR_MAINTENANCE, + ]: + return {&"ok": false, &"error": "Unsupported connector operational state."} + if cabin_capacity <= 0 or queue_capacity <= 0 or travel_minutes_per_floor <= 0: + return {&"ok": false, &"error": "Elevator capacities and travel time must be positive."} + if cabin_state not in [ + CABIN_IDLE, + CABIN_MOVING_UP, + CABIN_MOVING_DOWN, + CABIN_DOOR_OPEN, + CABIN_OUTAGE, + ]: + return {&"ok": false, &"error": "Unsupported elevator cabin state."} + if not raw_queue is Array: + return {&"ok": false, &"error": "Connector request queue must be an Array."} + var request_queue: Array = [] + var request_ids: Dictionary = {} + for raw_request: Variant in raw_queue: + if not raw_request is Dictionary: + return {&"ok": false, &"error": "Elevator queue entries must be Dictionaries."} + var normalized_request := _normalize_elevator_request(raw_request) + if not normalized_request[&"ok"]: + return normalized_request + var request: Dictionary = normalized_request[&"request"] + if request_ids.has(request[&"request_id"]): + return {&"ok": false, &"error": "Duplicate elevator request id."} + request_ids[request[&"request_id"]] = true + request_queue.append(request) + if request_queue.size() > queue_capacity: + return {&"ok": false, &"error": "Elevator request queue exceeds configured capacity."} + if movement_type != MOVEMENT_ELEVATOR and not request_queue.is_empty(): + return {&"ok": false, &"error": "Only elevator connectors can own requests."} + request_queue.sort_custom(Callable(self, "_elevator_request_base_less")) + if movement_type == MOVEMENT_ELEVATOR: + if operational_state != CONNECTOR_OPERATIONAL: + cabin_state = CABIN_OUTAGE + elif cabin_state == CABIN_OUTAGE: + return {&"ok": false, &"error": "An operational elevator cannot have OUTAGE cabin state."} + else: + # Cabin state has no semantic meaning for doors/stairs. Keeping one fixed + # canonical value prevents an OUTAGE connector from changing digest merely + # by passing through snapshot normalization. + cabin_state = CABIN_IDLE + + return { + &"ok": true, + &"connector": { + &"id": connector_id, + &"from": from_cell, + &"to": to_cell, + &"cost": cost, + &"bidirectional": bidirectional, + &"access_id": access_id, + &"enabled": enabled, + &"movement_type": movement_type, + &"dynamic_cost": dynamic_cost, + &"operational_state": operational_state, + &"cabin_capacity": cabin_capacity, + &"queue_capacity": queue_capacity, + &"cabin_state": cabin_state, + &"current_floor": current_floor, + &"travel_minutes_per_floor": travel_minutes_per_floor, + &"request_queue": request_queue, + }, + } + + +func _normalize_connector_collection(raw_connectors: Array) -> Dictionary: + var connectors: Dictionary = {} + for raw_configuration: Variant in raw_connectors: + if not raw_configuration is Dictionary: + return {&"ok": false, &"error": "Connector configuration must be a Dictionary."} + var normalized := _normalize_connector(raw_configuration) + if not normalized[&"ok"]: + return normalized + var connector: Dictionary = normalized[&"connector"] + var connector_id: StringName = connector[&"id"] + if connectors.has(connector_id): + return {&"ok": false, &"error": "Duplicate connector id: %s" % connector_id} + connectors[connector_id] = connector + var index_result := _derive_elevator_indexes_from_connectors(connectors) + if not index_result[&"ok"]: + return index_result + return { + &"ok": true, + &"connectors": connectors, + &"known_request_ids": index_result[&"known_request_ids"], + &"active_units": index_result[&"active_units"], + } + + +func _derive_elevator_indexes_from_connectors(connectors: Dictionary) -> Dictionary: + var known_request_ids: Dictionary = {} + var active_units: Dictionary = {} + for connector_id: StringName in _sorted_name_keys(connectors): + var connector: Dictionary = connectors[connector_id] + for request_value: Variant in connector[&"request_queue"]: + var request: Dictionary = request_value + var request_id: StringName = request[&"request_id"] + var unit_id: StringName = request[&"unit_id"] + if known_request_ids.has(request_id): + return { + &"ok": false, + &"error": "Elevator request id is duplicated across connectors: %s" % request_id, + } + if active_units.has(unit_id): + return { + &"ok": false, + &"error": "Elevator unit has requests on multiple connectors: %s" % unit_id, + } + known_request_ids[request_id] = true + active_units[unit_id] = { + &"unit_id": unit_id, + &"request_id": request_id, + &"connector_id": connector_id, + &"state": ELEVATOR_REQUEST_QUEUED, + } + return { + &"ok": true, + &"known_request_ids": known_request_ids, + &"active_units": active_units, + } + + +func _normalize_snapshot_elevator_indexes( + raw_known_requests: Array, + raw_active_units: Array, + connectors: Dictionary, + queued_units: Dictionary +) -> Dictionary: + var known_request_ids: Dictionary = {} + for raw_request_id: Variant in raw_known_requests: + if not raw_request_id is String and not raw_request_id is StringName: + return {&"ok": false, &"error": "Known elevator request ids must be names."} + var request_id := StringName(str(raw_request_id)) + if request_id.is_empty() or known_request_ids.has(request_id): + return {&"ok": false, &"error": "Known elevator request ids must be unique and non-empty."} + known_request_ids[request_id] = true + + var active_units: Dictionary = {} + var active_request_ids: Dictionary = {} + for raw_active: Variant in raw_active_units: + if not raw_active is Dictionary: + return {&"ok": false, &"error": "Active elevator unit entries must be Dictionaries."} + var unit_id := StringName(str(_dictionary_value(raw_active, &"unit_id", ""))) + var request_id := StringName(str(_dictionary_value(raw_active, &"request_id", ""))) + var connector_id := StringName(str(_dictionary_value(raw_active, &"connector_id", ""))) + var state := StringName(str(_dictionary_value(raw_active, &"state", ""))) + if unit_id.is_empty() or request_id.is_empty() or connector_id.is_empty() \ + or state not in [ELEVATOR_REQUEST_QUEUED, ELEVATOR_REQUEST_BOARDED]: + return {&"ok": false, &"error": "Active elevator unit entry is malformed."} + if active_units.has(unit_id) or active_request_ids.has(request_id): + return {&"ok": false, &"error": "Active elevator request indexes contain duplicates."} + if not known_request_ids.has(request_id) or not connectors.has(connector_id): + return {&"ok": false, &"error": "Active elevator request references unknown state."} + var connector: Dictionary = connectors[connector_id] + if connector[&"movement_type"] != MOVEMENT_ELEVATOR: + return {&"ok": false, &"error": "Active elevator request references a non-elevator connector."} + active_units[unit_id] = { + &"unit_id": unit_id, + &"request_id": request_id, + &"connector_id": connector_id, + &"state": state, + } + active_request_ids[request_id] = true + + for unit_id: Variant in queued_units: + if not active_units.has(unit_id) or active_units[unit_id] != queued_units[unit_id]: + return {&"ok": false, &"error": "Queued elevator request is missing from active indexes."} + for unit_id: Variant in active_units: + var active: Dictionary = active_units[unit_id] + if active[&"state"] == ELEVATOR_REQUEST_QUEUED \ + and (not queued_units.has(unit_id) or queued_units[unit_id] != active): + return {&"ok": false, &"error": "Active QUEUED index has no matching connector request."} + return { + &"ok": true, + &"known_request_ids": known_request_ids, + &"active_units": active_units, + } + + +func _normalize_snapshot_cell_traffic(raw_entries: Array) -> Dictionary: + var traffic: Dictionary = {} + for raw_entry: Variant in raw_entries: + if not raw_entry is Dictionary: + return {&"ok": false, &"error": "Path traffic entries must be Dictionaries."} + var cell: Variant = _dictionary_value(raw_entry, &"cell", null) + var capacity_value: Variant = _dictionary_value(raw_entry, &"base_capacity", null) + var density_value: Variant = _dictionary_value(raw_entry, &"current_density", null) + var cost_value: Variant = _dictionary_value(raw_entry, &"dynamic_cost", null) + var block_value: Variant = _dictionary_value(raw_entry, &"temporary_block", null) + if not cell is Vector3i or not _is_valid_layer(cell.z) \ + or not capacity_value is int or not density_value is int \ + or not cost_value is int or not block_value is bool: + return {&"ok": false, &"error": "Path traffic entry has invalid field types."} + if traffic.has(cell): + return {&"ok": false, &"error": "Path traffic snapshot contains a duplicate cell."} + if int(capacity_value) <= 0 or int(density_value) < 0 or int(cost_value) < 0: + return {&"ok": false, &"error": "Path traffic values are outside valid bounds."} + traffic[cell] = { + &"base_capacity": int(capacity_value), + &"current_density": int(density_value), + &"dynamic_cost": int(cost_value), + &"temporary_block": bool(block_value), + } + return {&"ok": true, &"traffic": traffic} + + +func _normalize_snapshot_actor_cells(raw_entries: Array, traffic: Dictionary) -> Dictionary: + var actor_cells: Dictionary = {} + for raw_entry: Variant in raw_entries: + if not raw_entry is Dictionary: + return {&"ok": false, &"error": "Actor cell projections must be Dictionaries."} + var actor_id := StringName(str(_dictionary_value(raw_entry, &"actor_id", ""))) + var cell: Variant = _dictionary_value(raw_entry, &"cell", null) + if actor_id.is_empty() or not cell is Vector3i or not traffic.has(cell): + return {&"ok": false, &"error": "Actor projection references an invalid id or cell."} + if actor_cells.has(actor_id): + return {&"ok": false, &"error": "Actor projection contains a duplicate actor id."} + actor_cells[actor_id] = cell + return {&"ok": true, &"actor_cells": actor_cells} + + +func _normalize_snapshot_actor_progress(raw_entries: Array) -> Dictionary: + var progress: Dictionary = {} + for raw_entry: Variant in raw_entries: + if not raw_entry is Dictionary: + return {&"ok": false, &"error": "Actor progress entries must be Dictionaries."} + var actor_id := StringName(str(_dictionary_value(raw_entry, &"actor_id", ""))) + var last_cell: Variant = _dictionary_value(raw_entry, &"last_cell", null) + var progress_value: Variant = _dictionary_value(raw_entry, &"last_progress_at", null) + var observed_value: Variant = _dictionary_value(raw_entry, &"last_observed_at", null) + var stuck_value: Variant = _dictionary_value(raw_entry, &"stuck_reported_at", null) + if actor_id.is_empty() or not last_cell is Vector3i \ + or not progress_value is int or not observed_value is int or not stuck_value is int: + return {&"ok": false, &"error": "Actor progress entry has invalid field types."} + var last_progress_at := int(progress_value) + var last_observed_at := int(observed_value) + var stuck_reported_at := int(stuck_value) + if last_progress_at < 0 or last_observed_at < last_progress_at \ + or (stuck_reported_at >= 0 and stuck_reported_at < last_progress_at): + return {&"ok": false, &"error": "Actor progress timestamps are inconsistent."} + if progress.has(actor_id): + return {&"ok": false, &"error": "Actor progress contains a duplicate actor id."} + progress[actor_id] = { + &"actor_id": actor_id, + &"last_cell": last_cell, + &"last_progress_at": last_progress_at, + &"last_observed_at": last_observed_at, + &"stuck_reported_at": stuck_reported_at, + } + return {&"ok": true, &"progress": progress} + + +func _snapshot_densities_match(traffic: Dictionary, actor_cells: Dictionary) -> bool: + var expected: Dictionary = {} + for actor_id: Variant in actor_cells: + var cell: Vector3i = actor_cells[actor_id] + expected[cell] = int(expected.get(cell, 0)) + 1 + for cell_value: Variant in traffic: + var saved_density := int((traffic[cell_value] as Dictionary)[&"current_density"]) + if saved_density != int(expected.get(cell_value, 0)): + return false + return true + + +func _recovery_distance(origin: Vector3i, candidate: Vector3i) -> int: + # Layer changes require an explicit connector in normal travel. Recovery is a + # proposal only, so a large layer penalty keeps the actor on its current layer + # whenever any valid local cell exists. + return absi(origin.x - candidate.x) + absi(origin.y - candidate.y) \ + + absi(origin.z - candidate.z) * 1_000_000 + + +func _normalize_access_layers(raw_layers: Variant) -> Dictionary: + var values: Array = [] + if raw_layers == null: + values = [FRONT, BACK] + elif raw_layers is int: + values = [raw_layers] + elif raw_layers is Array or raw_layers is PackedInt32Array: + values.assign(raw_layers) + else: + return {&"ok": false, &"error": "Access layers must be an integer or integer Array."} + + var unique: Dictionary = {} + for value: Variant in values: + if not value is int or not _is_valid_layer(int(value)): + return {&"ok": false, &"error": "Access layer must be FRONT (0) or BACK (1)."} + unique[int(value)] = true + var normalized: Array[int] = [] + for value: Variant in unique: + normalized.append(int(value)) + normalized.sort() + return {&"ok": true, &"values": normalized} + + +func _normalize_access_ids(raw_access_ids: Variant) -> Dictionary: + var values: Array = [] + if raw_access_ids == null: + values = [] + elif raw_access_ids is String or raw_access_ids is StringName: + values = [raw_access_ids] + elif raw_access_ids is Array or raw_access_ids is PackedStringArray: + values.assign(raw_access_ids) + elif raw_access_ids is Dictionary: + for key: Variant in raw_access_ids: + if bool(raw_access_ids[key]): + values.append(key) + else: + return {&"ok": false, &"error": "Connector access ids must be a name, Array, or Dictionary set."} + + var unique: Dictionary = {} + for value: Variant in values: + if not (value is String) and not (value is StringName): + return {&"ok": false, &"error": "Every connector access id must be a String or StringName."} + var access_id := StringName(str(value)) + if not access_id.is_empty(): + unique[access_id] = true + var normalized: Array[StringName] = [] + for value: Variant in unique: + normalized.append(value) + normalized.sort_custom(Callable(self, "_string_name_less")) + return {&"ok": true, &"values": normalized} + + +func _cache_key( + start: Vector3i, + goal: Vector3i, + allowed_layers: Array[int], + allowed_access_ids: Array[StringName] +) -> String: + var layer_parts := PackedStringArray() + for layer in allowed_layers: + layer_parts.append(str(layer)) + var access_parts := PackedStringArray() + for access_id in allowed_access_ids: + var value := str(access_id) + access_parts.append("%d:%s" % [value.length(), value]) + return "%d,%d,%d>%d,%d,%d|L:%s|A:%s|R:%d" % [ + start.x, + start.y, + start.z, + goal.x, + goal.y, + goal.z, + ",".join(layer_parts), + ",".join(access_parts), + graph_revision, + ] + + +func _path_touches(path: Array, affected: Dictionary) -> bool: + for cell: Variant in path: + if affected.has(cell): + return true + return false + + +func _copy_path(path: Array) -> Array[Vector3i]: + var copied: Array[Vector3i] = [] + for cell: Variant in path: + copied.append(cell) + return copied + + +func _mark_graph_changed() -> void: + graph_revision += 1 + invalidate_all_cache() + + +func _is_valid_layer(layer: int) -> bool: + return layer == FRONT or layer == BACK + + +func _coordinate_less(left: Variant, right: Variant) -> bool: + var left_cell: Vector3i = left + var right_cell: Vector3i = right + if left_cell.x != right_cell.x: + return left_cell.x < right_cell.x + if left_cell.y != right_cell.y: + return left_cell.y < right_cell.y + return left_cell.z < right_cell.z + + +func _edge_less(left: Variant, right: Variant) -> bool: + var left_edge: Dictionary = left + var right_edge: Dictionary = right + var left_to: Vector3i = left_edge[&"to"] + var right_to: Vector3i = right_edge[&"to"] + if left_to != right_to: + return _coordinate_less(left_to, right_to) + var left_connector := str(left_edge[&"connector_id"]) + var right_connector := str(right_edge[&"connector_id"]) + if left_connector != right_connector: + return left_connector < right_connector + return int(left_edge[&"cost"]) < int(right_edge[&"cost"]) + + +func _string_name_less(left: Variant, right: Variant) -> bool: + return str(left) < str(right) + + +func _sorted_name_keys(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for key: Variant in source: + result.append(StringName(key)) + result.sort_custom(Callable(self, "_string_name_less")) + return result + + +func _profile_value(profile: Dictionary, keys: Array[StringName], default_value: Variant) -> Variant: + for key in keys: + if profile.has(key): + return profile[key] + var string_key := str(key) + if profile.has(string_key): + return profile[string_key] + return default_value + + +func _dictionary_value(source: Dictionary, key: StringName, default_value: Variant) -> Variant: + if source.has(key): + return source[key] + var string_key := str(key) + return source.get(string_key, default_value) diff --git a/scripts/world/path_service.gd.uid b/scripts/world/path_service.gd.uid new file mode 100644 index 0000000..1d9823e --- /dev/null +++ b/scripts/world/path_service.gd.uid @@ -0,0 +1 @@ +uid://q0jnihjkulpv diff --git a/scripts/world/route_subslot_path_adapter.gd b/scripts/world/route_subslot_path_adapter.gd new file mode 100644 index 0000000..2bb23da --- /dev/null +++ b/scripts/world/route_subslot_path_adapter.gd @@ -0,0 +1,1195 @@ +class_name RouteSubslotPathAdapter +extends RefCounted + +## Atomic projection boundary between SpatialSubslotService ROUTE slots and +## PathService. The adapter owns projection metadata and idempotency evidence; +## both domain services remain authoritative for their own state. + +const CommandResultType = preload("res://scripts/core/command_result.gd") +const SpatialSubslotServiceType = preload("res://scripts/world/spatial_subslot_service.gd") +const PathServiceType = preload("res://scripts/world/path_service.gd") + +const SNAPSHOT_SCHEMA_VERSION := 1 +const MAX_REQUEST_ID_LENGTH := 128 +const MAX_PROCESSED_REQUESTS := 4_096 +const MAX_HISTORY_CELL_REFERENCES := 262_144 +const MAX_BASE_WALKABLE_CELLS := 65_536 +const MAX_PROJECTED_CELLS := 131_072 +const MAX_CELLS_PER_ROUTE_REQUEST := 256 +const MAX_COORDINATE_ABS := 100_000 +const MAX_SAFE_INTEGER := 9_007_199_254_740_991 +const MAX_SNAPSHOT_DEPTH := 9 +const MAX_CONTAINER_ITEMS := 131_072 + +const OPERATION_PLACE: StringName = &"PLACE" +const OPERATION_REPLACE: StringName = &"REPLACE" +const OPERATION_REMOVE: StringName = &"REMOVE" +const OPERATION_SYNCHRONIZE: StringName = &"SYNCHRONIZE" +const OPERATIONS: Array[StringName] = [ + OPERATION_PLACE, + OPERATION_REPLACE, + OPERATION_REMOVE, + OPERATION_SYNCHRONIZE, +] + +const QUARANTINE_CONFIGURE_ROLLBACK: StringName = &"CONFIGURE_ROLLBACK_UNCERTAIN" +const QUARANTINE_TRANSACTION_ROLLBACK: StringName = &"TRANSACTION_ROLLBACK_UNCERTAIN" +const QUARANTINE_REASONS: Array[StringName] = [ + QUARANTINE_CONFIGURE_ROLLBACK, + QUARANTINE_TRANSACTION_ROLLBACK, +] + +var source_revision := 0 +var projection_revision := 0 +var last_error := "" + +var _source: SpatialSubslotService +var _path: PathService +var _configured := false +var _base_walkable_cells: Dictionary = {} +var _projected_route_cells: Array[Vector3i] = [] +var _projected_walkable_cells: Array[Vector3i] = [] +var _processed_requests: Dictionary = {} +var _quarantined := false +var _quarantine_reason: StringName = &"" +var _transaction_active := false + + +## Binds the two authoritative services once and establishes projection 1. +## Existing primary World occupancy is intentionally not consulted: ROUTE is +## an independent subslot and is merged with the supplied base walkable cells. +func configure( + source_value: Variant, + path_value: Variant, + base_walkable_cells: Variant +) -> bool: + if _transaction_active: + return _fail("Route projection configuration cannot reenter an active transaction.") + if _configured: + return _fail("Route projection adapter is already configured.") + if not source_value is SpatialSubslotService or not path_value is PathService: + return _fail("Route projection requires SpatialSubslotService and PathService instances.") + var normalized_base := _normalize_cells( + base_walkable_cells, + true, + MAX_BASE_WALKABLE_CELLS, + "Base walkable cells" + ) + if not bool(normalized_base[&"ok"]): + return _fail(String(normalized_base[&"message"])) + var source_service: SpatialSubslotService = source_value + var path_service: PathService = path_value + var source_audit: Dictionary = source_service.validate_invariants() + if not bool(source_audit.get(&"ok", false)): + return _fail("Spatial subslot source invariants are invalid.") + var route_cells := source_service.get_route_cells() + var projected := _union_cells(normalized_base[&"cells"], route_cells) + if projected.size() > MAX_PROJECTED_CELLS: + return _fail("Projected walkable-cell capacity is exceeded.") + + var owned_before := _capture_owned_state() + _transaction_active = true + var guard_state := _capture_owned_state() + var source_snapshot := source_service.create_snapshot() + var path_capture := _capture_path_state(path_service) + if not bool(path_capture[&"ok"]): + _restore_owned_state(owned_before) + return _fail(String(path_capture[&"message"])) + var apply_ok := path_service.rebuild_graph(projected.duplicate()) + var callback_tampered := not _owned_state_matches(guard_state) + var source_tampered := source_service.create_snapshot() != source_snapshot + var path_matches := _path_cells_match(path_service, projected) + if not apply_ok or callback_tampered or source_tampered or not path_matches: + var rollback_ok := _restore_dependencies_exact( + source_service, + path_service, + source_snapshot, + path_capture + ) + _restore_owned_state(owned_before) + if not rollback_ok: + _bind_quarantined_configuration( + source_service, + path_service, + normalized_base[&"cells"], + route_cells, + projected, + QUARANTINE_CONFIGURE_ROLLBACK + ) + return _fail("Route projection configuration failed and rollback is uncertain.") + return _fail( + "Route projection callback tampered with the adapter and was rolled back." + if callback_tampered or source_tampered + else "Path graph rejected the initial route projection; state was rolled back." + ) + + _source = source_service + _path = path_service + _configured = true + _base_walkable_cells = _cell_set(normalized_base[&"cells"]) + _projected_route_cells = route_cells.duplicate() + _projected_walkable_cells = projected.duplicate() + source_revision = source_service.get_revision_for_category(source_service.CATEGORY_ROUTE) + projection_revision = 1 + _processed_requests.clear() + _quarantined = false + _quarantine_reason = &"" + _transaction_active = false + last_error = "" + return true + + +func place_route( + request_id: Variant, + entity_id: Variant, + cells: Variant +) -> CommandResult: + return _execute_route_request(OPERATION_PLACE, request_id, entity_id, cells) + + +func replace_route( + request_id: Variant, + entity_id: Variant, + cells: Variant +) -> CommandResult: + return _execute_route_request(OPERATION_REPLACE, request_id, entity_id, cells) + + +func remove_route(request_id: Variant, entity_id: Variant) -> CommandResult: + return _execute_route_request(OPERATION_REMOVE, request_id, entity_id, []) + + +## Reconciles direct, out-of-adapter ROUTE changes or Path topology drift. A +## supplied base set atomically replaces the prior World walkability projection. +func synchronize_projection( + request_id: Variant, + base_walkable_cells: Variant = null +) -> CommandResult: + var requested_base: Variant = ( + get_base_walkable_cells() if base_walkable_cells == null else base_walkable_cells + ) + return _execute_route_request(OPERATION_SYNCHRONIZE, request_id, &"", requested_base) + + +func is_configured() -> bool: + return _configured + + +## Identity-only binding check for higher-level atomic coordinators. The +## authoritative service references themselves remain encapsulated. +func is_bound_to(source_value: Variant, path_value: Variant) -> bool: + return _configured and _source == source_value and _path == path_value + + +func is_quarantined() -> bool: + return _quarantined + + +func get_quarantine_reason() -> StringName: + return _quarantine_reason + + +func get_base_walkable_cells() -> Array[Vector3i]: + return _sorted_cells(_base_walkable_cells.keys()) + + +func get_projected_route_cells() -> Array[Vector3i]: + return _projected_route_cells.duplicate() + + +func get_projected_walkable_cells() -> Array[Vector3i]: + return _projected_walkable_cells.duplicate() + + +func get_processed_request_ids() -> Array[StringName]: + return _sorted_names(_processed_requests) + + +func get_projection_status() -> Dictionary: + return _projection_status_internal().duplicate(true) + + +func is_projection_stale() -> bool: + return bool(_projection_status_internal()[&"stale"]) + + +func create_snapshot() -> Dictionary: + if not _configured: + return {} + var request_rows: Array[Dictionary] = [] + for request_id: StringName in _sorted_names(_processed_requests): + var record: Dictionary = _processed_requests[request_id] + var details: Dictionary = record[&"details"] + request_rows.append({ + &"request_id": String(request_id), + &"operation": String(record[&"operation"]), + &"entity_id": String(record[&"entity_id"]), + &"input_source_revision": int(record[&"input_source_revision"]), + &"input_cells": (record[&"input_cells"] as Array).duplicate(), + &"fingerprint": String(record[&"fingerprint"]), + &"details": { + &"request_id": String(details[&"request_id"]), + &"operation": String(details[&"operation"]), + &"entity_id": String(details[&"entity_id"]), + &"cells": (details[&"cells"] as Array).duplicate(), + &"changed": bool(details[&"changed"]), + &"source_revision": int(details[&"source_revision"]), + &"projection_revision": int(details[&"projection_revision"]), + }, + }) + return { + &"schema_version": SNAPSHOT_SCHEMA_VERSION, + &"source_revision": source_revision, + &"projection_revision": projection_revision, + &"base_walkable_cells": get_base_walkable_cells(), + &"projected_route_cells": get_projected_route_cells(), + &"projected_walkable_cells": get_projected_walkable_cells(), + &"processed_requests": request_rows, + &"quarantined": _quarantined, + &"quarantine_reason": String(_quarantine_reason), + } + + +## Restores adapter-owned state only. Non-quarantined snapshots must crosslink +## exactly with the already-restored SpatialSubslotService and PathService. +func restore_snapshot(snapshot: Dictionary) -> bool: + if _transaction_active: + return _fail("Route projection snapshot cannot restore during a transaction.") + if not _configured: + return _fail("Route projection adapter must be configured before restore.") + var parsed := _parse_snapshot(snapshot) + if not bool(parsed[&"ok"]): + return _fail(String(parsed[&"message"])) + var owned_before := _capture_owned_state() + _transaction_active = true + var guard_state := _capture_owned_state() + if not bool(parsed[&"quarantined"]): + var live_matches := _live_projection_matches( + int(parsed[&"source_revision"]), + parsed[&"projected_route_cells"], + parsed[&"projected_walkable_cells"] + ) + if not live_matches or not _owned_state_matches(guard_state): + _restore_owned_state(owned_before) + return _fail("Route projection snapshot does not match the live source and Path graph.") + _source_revision_from_parsed(parsed) + _transaction_active = false + last_error = "" + return true + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + if not _configured or _source == null or _path == null: + errors.append("Route projection adapter is not configured.") + return {&"ok": false, &"errors": errors} + var parsed := _parse_snapshot(create_snapshot()) + if not bool(parsed[&"ok"]): + errors.append(String(parsed[&"message"])) + if not _quarantine_state_is_valid(_quarantined, _quarantine_reason): + errors.append("Route projection quarantine state is inconsistent.") + if not _quarantined: + var status := _projection_status_internal() + if bool(status[&"stale"]): + errors.append("Route projection crosslinks are stale.") + return {&"ok": errors.is_empty(), &"errors": errors} + + +func _execute_route_request( + operation: StringName, + request_id_value: Variant, + entity_id_value: Variant, + cells_value: Variant +) -> CommandResult: + if _transaction_active: + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "Route projection transaction is already active." + ) + if not _configured: + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Route projection adapter is not configured.") + if not _stable_id_is_valid(request_id_value): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Route projection request ID is invalid.") + if not OPERATIONS.has(operation): + return _failure(CommandResultType.Code.INVALID_COMMAND, "Route projection operation is invalid.") + var request_id := StringName(request_id_value) + var entity_id: StringName = &"" + var input_cells: Array[Vector3i] = [] + var input_source_revision := -1 + if operation == OPERATION_SYNCHRONIZE: + input_source_revision = _source.get_revision_for_category(_source.CATEGORY_ROUTE) + var normalized_base := _normalize_cells( + cells_value, + true, + MAX_BASE_WALKABLE_CELLS, + "Synchronization base walkable cells" + ) + if not bool(normalized_base[&"ok"]): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(normalized_base[&"message"])) + input_cells = normalized_base[&"cells"] + else: + if not _stable_id_is_valid(entity_id_value): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Route entity ID is invalid.") + entity_id = StringName(entity_id_value) + if operation in [OPERATION_PLACE, OPERATION_REPLACE]: + var normalized := _normalize_cells( + cells_value, + false, + MAX_CELLS_PER_ROUTE_REQUEST, + "Route request cells" + ) + if not bool(normalized[&"ok"]): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(normalized[&"message"])) + input_cells = normalized[&"cells"] + var fingerprint := _request_fingerprint( + operation, + entity_id, + input_source_revision, + input_cells + ) + if _processed_requests.has(request_id): + var previous: Dictionary = _processed_requests[request_id] + if String(previous[&"fingerprint"]) != fingerprint: + return _failure( + CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, + "Route projection request ID conflicts with earlier input." + ) + var replay := _success((previous[&"details"] as Dictionary).duplicate(true)) + replay.is_replay = true + return replay + if _quarantined: + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "Route projection adapter is quarantined until an exact snapshot is restored.", + {&"quarantine_reason": _quarantine_reason} + ) + if _processed_requests.size() >= MAX_PROCESSED_REQUESTS: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Route request history capacity is exhausted.") + if _history_cell_reference_count(_processed_requests) > MAX_HISTORY_CELL_REFERENCES - input_cells.size(): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Route request history cell budget is exhausted.") + + var owned_before := _capture_owned_state() + _transaction_active = true + var guard_state := _capture_owned_state() + var status_before := _projection_status_internal() + if operation != OPERATION_SYNCHRONIZE and bool(status_before[&"stale"]): + _restore_owned_state(owned_before) + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "Route projection is stale and must be synchronized before mutation.", + status_before + ) + var source_audit: Dictionary = _source.validate_invariants() + if not bool(source_audit.get(&"ok", false)): + _restore_owned_state(owned_before) + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial route source invariants are invalid.") + if operation in [OPERATION_REPLACE, OPERATION_REMOVE]: + if not _source.has_entity(entity_id): + _restore_owned_state(owned_before) + return _failure(CommandResultType.Code.NOT_FOUND, "Route entity was not found.") + if _source.get_entity_category(entity_id) != _source.CATEGORY_ROUTE: + _restore_owned_state(owned_before) + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Adapter may mutate only ROUTE entities.") + + var source_service := _source + var path_service := _path + var source_snapshot := source_service.create_snapshot() + var path_capture := _capture_path_state(path_service) + if not bool(path_capture[&"ok"]) or not _owned_state_matches(guard_state): + var capture_rollback_ok := false + if bool(path_capture[&"ok"]): + capture_rollback_ok = _restore_dependencies_exact( + source_service, path_service, source_snapshot, path_capture + ) + else: + capture_rollback_ok = source_service.create_snapshot() == source_snapshot + _restore_owned_state(owned_before) + if not capture_rollback_ok: + _quarantine(QUARANTINE_TRANSACTION_ROLLBACK) + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "Route capture was tampered with and rollback is uncertain.", + {&"rollback_ok": false, &"quarantined": true} + ) + return _failure( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + "Route transaction capture failed without changing authoritative state.", + {&"rollback_ok": true, &"quarantined": false} + ) + + var result_cells: Array[Vector3i] = [] + var source_result: CommandResult + match operation: + OPERATION_PLACE: + result_cells = input_cells.duplicate() + source_result = source_service.place(entity_id, source_service.CATEGORY_ROUTE, input_cells) + OPERATION_REPLACE: + result_cells = input_cells.duplicate() + source_result = source_service.replace_entity_cells(entity_id, input_cells) + OPERATION_REMOVE: + result_cells = source_service.get_entity_cells(entity_id) + source_result = source_service.remove(entity_id) + OPERATION_SYNCHRONIZE: + source_result = CommandResultType.succeeded() + _: + source_result = CommandResultType.failed(CommandResultType.Code.INVALID_COMMAND, 0, "Unknown route operation.") + + var callback_tampered := not _owned_state_matches(guard_state) + if not source_result.is_success() or callback_tampered: + return _rollback_transaction_failure( + owned_before, + source_service, + path_service, + source_snapshot, + path_capture, + "Spatial route apply failed." if not source_result.is_success() else "Spatial callback tampered with the route adapter." + ) + + var next_route_cells := source_service.get_route_cells() + var next_base_cells := ( + input_cells.duplicate() if operation == OPERATION_SYNCHRONIZE else get_base_walkable_cells() + ) + var next_walkable_cells := _union_cells(next_base_cells, next_route_cells) + if next_walkable_cells.size() > MAX_PROJECTED_CELLS: + return _rollback_transaction_failure( + owned_before, + source_service, + path_service, + source_snapshot, + path_capture, + "Projected walkable-cell capacity is exceeded." + ) + var path_apply_ok := path_service.rebuild_graph(next_walkable_cells.duplicate()) + callback_tampered = not _owned_state_matches(guard_state) + var next_source_revision := source_service.get_revision_for_category(source_service.CATEGORY_ROUTE) + var post_source_audit: Dictionary = source_service.validate_invariants() + var postcondition_ok := ( + path_apply_ok + and not callback_tampered + and bool(post_source_audit.get(&"ok", false)) + and source_service.get_route_cells() == next_route_cells + and _path_cells_match(path_service, next_walkable_cells) + ) + if not postcondition_ok: + return _rollback_transaction_failure( + owned_before, + source_service, + path_service, + source_snapshot, + path_capture, + "Path projection failed or violated its postcondition." + ) + + var changed := ( + bool(status_before[&"stale"]) or next_base_cells != get_base_walkable_cells() + if operation == OPERATION_SYNCHRONIZE + else bool(source_result.details.get(&"changed", true)) + ) + if changed: + projection_revision += 1 + source_revision = next_source_revision + _base_walkable_cells = _cell_set(next_base_cells) + _projected_route_cells = next_route_cells.duplicate() + _projected_walkable_cells = next_walkable_cells.duplicate() + var details := { + &"request_id": request_id, + &"operation": operation, + &"entity_id": entity_id, + &"cells": result_cells.duplicate(), + &"changed": changed, + &"source_revision": source_revision, + &"projection_revision": projection_revision, + } + _processed_requests[request_id] = { + &"operation": operation, + &"entity_id": entity_id, + &"input_source_revision": input_source_revision, + &"input_cells": input_cells.duplicate(), + &"fingerprint": fingerprint, + &"details": details.duplicate(true), + } + _transaction_active = false + last_error = "" + return _success(details) + + +func _rollback_transaction_failure( + owned_before: Dictionary, + source_service: SpatialSubslotService, + path_service: PathService, + source_snapshot: Dictionary, + path_capture: Dictionary, + message: String +) -> CommandResult: + var rollback_ok := _restore_dependencies_exact( + source_service, + path_service, + source_snapshot, + path_capture + ) + _restore_owned_state(owned_before) + if not rollback_ok: + _quarantine(QUARANTINE_TRANSACTION_ROLLBACK) + return _failure( + CommandResultType.Code.TRANSACTION_CLOSED, + "%s Rollback is uncertain; adapter was quarantined." % message, + {&"rollback_ok": false, &"quarantined": true, &"quarantine_reason": _quarantine_reason} + ) + return _failure( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + "%s Both services were rolled back exactly." % message, + {&"rollback_ok": true, &"quarantined": false} + ) + + +func _projection_status_internal() -> Dictionary: + if not _configured or _source == null or _path == null: + return { + &"configured": false, + &"stale": true, + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + &"source_revision": -1, + &"projected_source_revision": source_revision, + &"projection_revision": projection_revision, + &"source_changed": true, + &"path_changed": true, + } + var current_source_revision := _source.get_revision_for_category(_source.CATEGORY_ROUTE) + var current_route_cells := _source.get_route_cells() + var path_capture := _capture_path_state(_path) + var source_changed: bool = ( + current_source_revision != source_revision + or current_route_cells != _projected_route_cells + ) + var path_changed: bool = ( + not bool(path_capture[&"ok"]) + or path_capture[&"cells"] != _projected_walkable_cells + ) + var projection_crosslink_ok: bool = ( + _union_cells(get_base_walkable_cells(), _projected_route_cells) + == _projected_walkable_cells + ) + return { + &"configured": true, + &"stale": _quarantined or source_changed or path_changed or not projection_crosslink_ok, + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + &"source_revision": current_source_revision, + &"projected_source_revision": source_revision, + &"projection_revision": projection_revision, + &"source_changed": source_changed, + &"path_changed": path_changed, + } + + +func _live_projection_matches( + expected_source_revision: int, + expected_route_cells: Array[Vector3i], + expected_walkable_cells: Array[Vector3i] +) -> bool: + var audit: Dictionary = _source.validate_invariants() + return ( + bool(audit.get(&"ok", false)) + and _source.get_revision_for_category(_source.CATEGORY_ROUTE) == expected_source_revision + and _source.get_route_cells() == expected_route_cells + and _path_cells_match(_path, expected_walkable_cells) + ) + + +func _source_revision_from_parsed(parsed: Dictionary) -> void: + source_revision = int(parsed[&"source_revision"]) + projection_revision = int(parsed[&"projection_revision"]) + _base_walkable_cells = _cell_set(parsed[&"base_walkable_cells"]) + _projected_route_cells = (parsed[&"projected_route_cells"] as Array).duplicate() + _projected_walkable_cells = (parsed[&"projected_walkable_cells"] as Array).duplicate() + _processed_requests = (parsed[&"processed_requests"] as Dictionary).duplicate(true) + _quarantined = bool(parsed[&"quarantined"]) + _quarantine_reason = parsed[&"quarantine_reason"] + + +func _capture_owned_state() -> Dictionary: + return { + &"source": _source, + &"path": _path, + &"configured": _configured, + &"base_walkable_cells": _base_walkable_cells.duplicate(true), + &"projected_route_cells": _projected_route_cells.duplicate(), + &"projected_walkable_cells": _projected_walkable_cells.duplicate(), + &"source_revision": source_revision, + &"projection_revision": projection_revision, + &"processed_requests": _processed_requests.duplicate(true), + &"quarantined": _quarantined, + &"quarantine_reason": _quarantine_reason, + &"transaction_active": _transaction_active, + } + + +func _owned_state_matches(capture: Dictionary) -> bool: + return ( + _source == capture[&"source"] + and _path == capture[&"path"] + and _configured == bool(capture[&"configured"]) + and _base_walkable_cells == capture[&"base_walkable_cells"] + and _projected_route_cells == capture[&"projected_route_cells"] + and _projected_walkable_cells == capture[&"projected_walkable_cells"] + and source_revision == int(capture[&"source_revision"]) + and projection_revision == int(capture[&"projection_revision"]) + and _processed_requests == capture[&"processed_requests"] + and _quarantined == bool(capture[&"quarantined"]) + and _quarantine_reason == capture[&"quarantine_reason"] + and _transaction_active == bool(capture[&"transaction_active"]) + ) + + +func _restore_owned_state(capture: Dictionary) -> void: + _source = capture[&"source"] + _path = capture[&"path"] + _configured = bool(capture[&"configured"]) + _base_walkable_cells = (capture[&"base_walkable_cells"] as Dictionary).duplicate(true) + _projected_route_cells.assign(capture[&"projected_route_cells"]) + _projected_walkable_cells.assign(capture[&"projected_walkable_cells"]) + source_revision = int(capture[&"source_revision"]) + projection_revision = int(capture[&"projection_revision"]) + _processed_requests = (capture[&"processed_requests"] as Dictionary).duplicate(true) + _quarantined = bool(capture[&"quarantined"]) + _quarantine_reason = capture[&"quarantine_reason"] + _transaction_active = bool(capture[&"transaction_active"]) + + +func _bind_quarantined_configuration( + source_service: SpatialSubslotService, + path_service: PathService, + base_cells: Array[Vector3i], + route_cells: Array[Vector3i], + projected_cells: Array[Vector3i], + reason: StringName +) -> void: + _source = source_service + _path = path_service + _configured = true + _base_walkable_cells = _cell_set(base_cells) + _projected_route_cells = route_cells.duplicate() + _projected_walkable_cells = projected_cells.duplicate() + source_revision = source_service.get_revision_for_category(source_service.CATEGORY_ROUTE) + projection_revision = 1 + _processed_requests.clear() + _transaction_active = false + _quarantine(reason) + + +func _quarantine(reason: StringName) -> void: + _quarantined = true + _quarantine_reason = reason + _transaction_active = false + + +static func _capture_path_state(path_service: PathService) -> Dictionary: + var snapshot := path_service.create_snapshot() + var parsed_cells := _path_cells_from_snapshot(snapshot) + if not bool(parsed_cells[&"ok"]): + return {&"ok": false, &"message": parsed_cells[&"message"]} + for cell: Vector3i in parsed_cells[&"cells"]: + if not path_service.has_cell(cell): + return { + &"ok": false, + &"message": "Path snapshot topology has not been materialized into the live graph.", + } + return { + &"ok": true, + &"snapshot": snapshot.duplicate(true), + &"cells": (parsed_cells[&"cells"] as Array).duplicate(), + } + + +static func _path_cells_from_snapshot(snapshot: Dictionary) -> Dictionary: + var raw_rows: Variant = snapshot.get(&"cell_traffic", null) + if not raw_rows is Array or raw_rows.size() > MAX_PROJECTED_CELLS: + return {&"ok": false, &"message": "Path snapshot does not expose a bounded cell topology."} + var cells: Array[Vector3i] = [] + var seen: Dictionary = {} + for raw_row: Variant in raw_rows: + if not raw_row is Dictionary: + return {&"ok": false, &"message": "Path snapshot cell topology is malformed."} + var raw_cell: Variant = raw_row.get(&"cell", raw_row.get("cell", null)) + if not _cell_is_valid(raw_cell) or seen.has(raw_cell): + return {&"ok": false, &"message": "Path snapshot contains an invalid or duplicate cell."} + seen[raw_cell] = true + cells.append(raw_cell) + cells.sort_custom(_cell_less) + return {&"ok": true, &"cells": cells} + + +static func _path_cells_match(path_service: PathService, expected: Array[Vector3i]) -> bool: + var capture := _capture_path_state(path_service) + if not bool(capture[&"ok"]) or capture[&"cells"] != expected: + return false + for cell: Vector3i in expected: + if not path_service.has_cell(cell): + return false + return true + + +static func _restore_dependencies_exact( + source_service: SpatialSubslotService, + path_service: PathService, + source_snapshot: Dictionary, + path_capture: Dictionary +) -> bool: + for _attempt: int in range(2): + source_service.restore_snapshot(source_snapshot.duplicate(true)) + _restore_path_once(path_service, path_capture) + if ( + source_service.create_snapshot() == source_snapshot + and _path_state_matches_capture(path_service, path_capture) + ): + return true + return false + + +static func _restore_path_once(path_service: PathService, capture: Dictionary) -> void: + var cells: Array = capture[&"cells"] + var snapshot: Dictionary = capture[&"snapshot"] + # The second rebuild materializes PathService's pending graph revision when + # the authoritative graph is empty. Calls are intentionally best-effort; + # exact equality below is the authoritative rollback verdict. + path_service.rebuild_graph(cells.duplicate()) + path_service.restore_snapshot(snapshot.duplicate(true)) + path_service.rebuild_graph(cells.duplicate()) + + +static func _path_state_matches_capture(path_service: PathService, capture: Dictionary) -> bool: + return ( + path_service.create_snapshot() == capture[&"snapshot"] + and _path_cells_match(path_service, capture[&"cells"]) + ) + + +static func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + if not _variant_depth_is_valid(snapshot, 0): + return {&"ok": false, &"message": "Route projection snapshot depth or capacity is exceeded."} + if not _keys_match(snapshot, [ + &"base_walkable_cells", + &"processed_requests", + &"projected_route_cells", + &"projected_walkable_cells", + &"projection_revision", + &"quarantine_reason", + &"quarantined", + &"schema_version", + &"source_revision", + ]): + return {&"ok": false, &"message": "Route projection snapshot root fields are not canonical."} + if typeof(snapshot.get(&"schema_version", null)) != TYPE_INT \ + or int(snapshot[&"schema_version"]) != SNAPSHOT_SCHEMA_VERSION: + return {&"ok": false, &"message": "Unsupported RouteSubslotPathAdapter snapshot version."} + if typeof(snapshot.get(&"source_revision", null)) != TYPE_INT \ + or typeof(snapshot.get(&"projection_revision", null)) != TYPE_INT: + return {&"ok": false, &"message": "Route projection revisions must be integers."} + var next_source_revision := int(snapshot[&"source_revision"]) + var next_projection_revision := int(snapshot[&"projection_revision"]) + if next_source_revision < 0 or next_source_revision > MAX_SAFE_INTEGER \ + or next_projection_revision < 1 or next_projection_revision > MAX_SAFE_INTEGER: + return {&"ok": false, &"message": "Route projection revisions are outside supported bounds."} + if typeof(snapshot.get(&"quarantined", null)) != TYPE_BOOL \ + or typeof(snapshot.get(&"quarantine_reason", null)) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Route projection quarantine state has invalid types."} + var next_quarantined := bool(snapshot[&"quarantined"]) + var next_quarantine_reason := StringName(snapshot[&"quarantine_reason"]) + if not _quarantine_state_is_valid(next_quarantined, next_quarantine_reason): + return {&"ok": false, &"message": "Route projection quarantine state is inconsistent."} + + var base_result := _normalize_cells( + snapshot.get(&"base_walkable_cells", null), + true, + MAX_BASE_WALKABLE_CELLS, + "Snapshot base walkable cells" + ) + var route_result := _normalize_cells( + snapshot.get(&"projected_route_cells", null), + true, + SpatialSubslotServiceType.MAX_TOTAL_SLOTS, + "Snapshot projected route cells" + ) + var projected_result := _normalize_cells( + snapshot.get(&"projected_walkable_cells", null), + true, + MAX_PROJECTED_CELLS, + "Snapshot projected walkable cells" + ) + for result: Dictionary in [base_result, route_result, projected_result]: + if not bool(result[&"ok"]): + return result + if snapshot[&"base_walkable_cells"] != base_result[&"cells"] \ + or snapshot[&"projected_route_cells"] != route_result[&"cells"] \ + or snapshot[&"projected_walkable_cells"] != projected_result[&"cells"]: + return {&"ok": false, &"message": "Route projection snapshot cells are not canonically ordered."} + if _union_cells(base_result[&"cells"], route_result[&"cells"]) != projected_result[&"cells"]: + return {&"ok": false, &"message": "Route projection snapshot union crosslink is invalid."} + + var raw_requests: Variant = snapshot.get(&"processed_requests", null) + if not raw_requests is Array or raw_requests.size() > MAX_PROCESSED_REQUESTS: + return {&"ok": false, &"message": "Route projection request history is malformed or too large."} + var next_requests: Dictionary = {} + var previous_request_id := "" + var history_cell_references := 0 + var latest_recorded_projection_revision := 1 + for raw_record: Variant in raw_requests: + var record_result := _parse_request_record( + raw_record, + next_source_revision, + next_projection_revision, + previous_request_id + ) + if not bool(record_result[&"ok"]): + return record_result + var request_id: StringName = record_result[&"request_id"] + var parsed_record: Dictionary = record_result[&"record"] + history_cell_references += (parsed_record[&"input_cells"] as Array).size() + if history_cell_references > MAX_HISTORY_CELL_REFERENCES: + return {&"ok": false, &"message": "Route projection request history cell budget is exceeded."} + latest_recorded_projection_revision = max( + latest_recorded_projection_revision, + int((parsed_record[&"details"] as Dictionary)[&"projection_revision"]) + ) + next_requests[request_id] = parsed_record + previous_request_id = String(request_id) + if latest_recorded_projection_revision != next_projection_revision: + return {&"ok": false, &"message": "Route projection revision lacks matching request history."} + return { + &"ok": true, + &"source_revision": next_source_revision, + &"projection_revision": next_projection_revision, + &"base_walkable_cells": base_result[&"cells"], + &"projected_route_cells": route_result[&"cells"], + &"projected_walkable_cells": projected_result[&"cells"], + &"processed_requests": next_requests, + &"quarantined": next_quarantined, + &"quarantine_reason": next_quarantine_reason, + } + + +static func _parse_request_record( + raw_record: Variant, + current_source_revision: int, + current_projection_revision: int, + previous_request_id: String +) -> Dictionary: + if not raw_record is Dictionary or not _keys_match(raw_record, [ + &"details", &"entity_id", &"fingerprint", &"input_cells", + &"input_source_revision", &"operation", &"request_id", + ]): + return {&"ok": false, &"message": "Route projection request record fields are invalid."} + for field: StringName in [&"request_id", &"operation", &"entity_id", &"fingerprint"]: + if typeof(raw_record.get(field, null)) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Route projection request record string fields are invalid."} + if typeof(raw_record.get(&"input_source_revision", null)) != TYPE_INT: + return {&"ok": false, &"message": "Route projection request source revision is invalid."} + var request_id := StringName(raw_record[&"request_id"]) + var request_text := String(request_id) + var operation := StringName(raw_record[&"operation"]) + var entity_id := StringName(raw_record[&"entity_id"]) + var input_source_revision := int(raw_record[&"input_source_revision"]) + if not _stable_id_is_valid(request_id) \ + or (not previous_request_id.is_empty() and request_text <= previous_request_id) \ + or not OPERATIONS.has(operation): + return {&"ok": false, &"message": "Route projection request identity/order is invalid."} + if operation == OPERATION_SYNCHRONIZE: + if not entity_id.is_empty() \ + or input_source_revision < 0 \ + or input_source_revision > current_source_revision: + return {&"ok": false, &"message": "Synchronization request input is inconsistent."} + else: + if not _stable_id_is_valid(entity_id) or input_source_revision != -1: + return {&"ok": false, &"message": "Route mutation request input is inconsistent."} + var allow_empty := operation in [OPERATION_REMOVE, OPERATION_SYNCHRONIZE] + var maximum_cells := ( + MAX_BASE_WALKABLE_CELLS + if operation == OPERATION_SYNCHRONIZE + else MAX_CELLS_PER_ROUTE_REQUEST + ) + var cells_result := _normalize_cells( + raw_record.get(&"input_cells", null), + allow_empty, + maximum_cells, + "Route projection request input cells" + ) + if not bool(cells_result[&"ok"]): + return cells_result + var input_cells: Array[Vector3i] = cells_result[&"cells"] + if raw_record[&"input_cells"] != input_cells \ + or (operation == OPERATION_REMOVE and not input_cells.is_empty()): + return {&"ok": false, &"message": "Route projection request cells are not canonical."} + var expected_fingerprint := _request_fingerprint( + operation, + entity_id, + input_source_revision, + input_cells + ) + if String(raw_record[&"fingerprint"]) != expected_fingerprint: + return {&"ok": false, &"message": "Route projection request fingerprint is invalid."} + var details_result := _parse_request_details( + raw_record.get(&"details", null), + request_id, + operation, + entity_id, + input_cells, + current_source_revision, + current_projection_revision + ) + if not bool(details_result[&"ok"]): + return details_result + return { + &"ok": true, + &"request_id": request_id, + &"record": { + &"operation": operation, + &"entity_id": entity_id, + &"input_source_revision": input_source_revision, + &"input_cells": input_cells, + &"fingerprint": expected_fingerprint, + &"details": details_result[&"details"], + }, + } + + +static func _parse_request_details( + raw_details: Variant, + request_id: StringName, + operation: StringName, + entity_id: StringName, + input_cells: Array[Vector3i], + current_source_revision: int, + current_projection_revision: int +) -> Dictionary: + if not raw_details is Dictionary or not _keys_match(raw_details, [ + &"cells", &"changed", &"entity_id", &"operation", &"projection_revision", + &"request_id", &"source_revision", + ]): + return {&"ok": false, &"message": "Route projection request result fields are invalid."} + if typeof(raw_details.get(&"request_id", null)) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(raw_details.get(&"operation", null)) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(raw_details.get(&"entity_id", null)) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or typeof(raw_details.get(&"changed", null)) != TYPE_BOOL \ + or typeof(raw_details.get(&"source_revision", null)) != TYPE_INT \ + or typeof(raw_details.get(&"projection_revision", null)) != TYPE_INT: + return {&"ok": false, &"message": "Route projection request result types are invalid."} + var detail_source_revision := int(raw_details[&"source_revision"]) + var detail_projection_revision := int(raw_details[&"projection_revision"]) + if StringName(raw_details[&"request_id"]) != request_id \ + or StringName(raw_details[&"operation"]) != operation \ + or StringName(raw_details[&"entity_id"]) != entity_id \ + or detail_source_revision < 0 \ + or detail_source_revision > current_source_revision \ + or detail_projection_revision < 1 \ + or detail_projection_revision > current_projection_revision: + return {&"ok": false, &"message": "Route projection request result crosslink is invalid."} + var cells_result := _normalize_cells( + raw_details.get(&"cells", null), + operation == OPERATION_SYNCHRONIZE, + MAX_CELLS_PER_ROUTE_REQUEST, + "Route projection request result cells" + ) + if not bool(cells_result[&"ok"]) or raw_details[&"cells"] != cells_result[&"cells"]: + return {&"ok": false, &"message": "Route projection request result cells are invalid."} + var result_cells: Array[Vector3i] = cells_result[&"cells"] + if operation in [OPERATION_PLACE, OPERATION_REPLACE] and result_cells != input_cells: + return {&"ok": false, &"message": "Route projection request input/result cells diverge."} + if operation == OPERATION_SYNCHRONIZE and not result_cells.is_empty(): + return {&"ok": false, &"message": "Synchronization result cells must be empty."} + var changed := bool(raw_details[&"changed"]) + if operation in [OPERATION_PLACE, OPERATION_REMOVE] and not changed: + return {&"ok": false, &"message": "Place/remove result must record a changed projection."} + if changed and detail_projection_revision < 2: + return {&"ok": false, &"message": "Changed projection result has an invalid revision."} + return { + &"ok": true, + &"details": { + &"request_id": request_id, + &"operation": operation, + &"entity_id": entity_id, + &"cells": result_cells, + &"changed": changed, + &"source_revision": detail_source_revision, + &"projection_revision": detail_projection_revision, + }, + } + + +static func _normalize_cells( + value: Variant, + allow_empty: bool, + maximum: int, + label: String +) -> Dictionary: + if not value is Array or value.size() > maximum or (not allow_empty and value.is_empty()): + return {&"ok": false, &"message": "%s have an invalid collection shape or capacity." % label} + var seen: Dictionary = {} + var cells: Array[Vector3i] = [] + for raw_cell: Variant in value: + if not _cell_is_valid(raw_cell) or seen.has(raw_cell): + return {&"ok": false, &"message": "%s contain an invalid or duplicate cell." % label} + seen[raw_cell] = true + cells.append(raw_cell) + cells.sort_custom(_cell_less) + return {&"ok": true, &"cells": cells} + + +static func _cell_is_valid(value: Variant) -> bool: + if not value is Vector3i: + return false + var cell: Vector3i = value + return ( + cell.z in [SpatialSubslotServiceType.LAYER_FRONT, SpatialSubslotServiceType.LAYER_BACK] + and cell.x >= -MAX_COORDINATE_ABS + and cell.x <= MAX_COORDINATE_ABS + and cell.y >= -MAX_COORDINATE_ABS + and cell.y <= MAX_COORDINATE_ABS + ) + + +static func _cell_set(cells: Array) -> Dictionary: + var result: Dictionary = {} + for raw_cell: Variant in cells: + result[raw_cell] = true + return result + + +static func _union_cells(first: Array, second: Array) -> Array[Vector3i]: + var union: Dictionary = {} + for raw_cell: Variant in first: + if raw_cell is Vector3i: + union[raw_cell] = true + for raw_cell: Variant in second: + if raw_cell is Vector3i: + union[raw_cell] = true + return _sorted_cells(union.keys()) + + +static func _sorted_cells(source: Variant) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + if not source is Array: + return result + for raw_cell: Variant in source: + if raw_cell is Vector3i: + result.append(raw_cell) + result.sort_custom(_cell_less) + return result + + +static func _cell_less(left: Vector3i, right: Vector3i) -> bool: + if left.y != right.y: + return left.y < right.y + if left.x != right.x: + return left.x < right.x + return left.z < right.z + + +static func _sorted_names(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_name: Variant in source: + result.append(StringName(raw_name)) + result.sort_custom(_name_less) + return result + + +static func _history_cell_reference_count(source: Dictionary) -> int: + var total := 0 + for record_value: Variant in source.values(): + if record_value is Dictionary: + var cells: Variant = record_value.get(&"input_cells", []) + if cells is Array: + total += cells.size() + return total + + +static func _name_less(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + + +static func _stable_id_is_valid(value: Variant) -> bool: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var text := String(value) + if text.is_empty() or text.length() > MAX_REQUEST_ID_LENGTH: + return false + for index: int in range(text.length()): + var code := text.unicode_at(index) + var allowed := ( + code >= 48 and code <= 57 + or code >= 65 and code <= 90 + or code >= 97 and code <= 122 + or code in [45, 46, 58, 64, 95] + ) + if not allowed: + return false + return true + + +static func _request_fingerprint( + operation: StringName, + entity_id: StringName, + input_source_revision: int, + cells: Array[Vector3i] +) -> String: + var entity_text := String(entity_id) + var canonical := "%s|%d:%s|source=%d|cells=%d|" % [ + String(operation), entity_text.length(), entity_text, input_source_revision, cells.size(), + ] + for cell: Vector3i in cells: + canonical += "%d,%d,%d;" % [cell.x, cell.y, cell.z] + return canonical.sha256_text() + + +static func _keys_match(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + var seen: Dictionary = {} + for raw_key: Variant in source: + if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var key := StringName(raw_key) + if seen.has(key) or not expected.has(key): + return false + seen[key] = true + return seen.size() == expected.size() + + +static func _quarantine_state_is_valid(value: bool, reason: StringName) -> bool: + return (value and QUARANTINE_REASONS.has(reason)) or (not value and reason.is_empty()) + + +static func _variant_depth_is_valid(value: Variant, depth: int) -> bool: + if depth > MAX_SNAPSHOT_DEPTH: + return false + if value is Array: + if value.size() > MAX_CONTAINER_ITEMS: + return false + for child: Variant in value: + if not _variant_depth_is_valid(child, depth + 1): + return false + elif value is Dictionary: + if value.size() > MAX_CONTAINER_ITEMS: + return false + for key: Variant in value: + if not _variant_depth_is_valid(key, depth + 1) \ + or not _variant_depth_is_valid(value[key], depth + 1): + return false + elif typeof(value) in [TYPE_OBJECT, TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]: + return false + return true + + +func _success(details: Dictionary = {}) -> CommandResult: + last_error = "" + return CommandResultType.succeeded(0, details) + + +func _failure(code: CommandResult.Code, message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed(code, 0, message, details) + + +func _fail(message: String) -> bool: + last_error = message + return false diff --git a/scripts/world/route_subslot_path_adapter.gd.uid b/scripts/world/route_subslot_path_adapter.gd.uid new file mode 100644 index 0000000..84782f4 --- /dev/null +++ b/scripts/world/route_subslot_path_adapter.gd.uid @@ -0,0 +1 @@ +uid://b0ti4l6jf1cbh diff --git a/scripts/world/spatial_subslot_service.gd b/scripts/world/spatial_subslot_service.gd new file mode 100644 index 0000000..6291dee --- /dev/null +++ b/scripts/world/spatial_subslot_service.gd @@ -0,0 +1,890 @@ +class_name SpatialSubslotService +extends RefCounted + +## Independent spatial occupancy for systems that may share a WorldService cell +## with a primary facility. Every layer cell owns exactly one slot per category. + +signal entity_placed(entity_id: StringName, category: StringName, cells: Array[Vector3i]) +signal entity_removed(entity_id: StringName, category: StringName, cells: Array[Vector3i]) +signal entity_cells_replaced( + entity_id: StringName, + category: StringName, + before_cells: Array[Vector3i], + after_cells: Array[Vector3i] +) + +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const SNAPSHOT_SCHEMA_VERSION := 1 + +const CATEGORY_ROUTE: StringName = &"ROUTE" +const CATEGORY_DECORATION: StringName = &"DECORATION" +const CATEGORY_UTILITY: StringName = &"UTILITY" +const CATEGORIES: Array[StringName] = [ + CATEGORY_ROUTE, + CATEGORY_DECORATION, + CATEGORY_UTILITY, +] + +const LAYER_FRONT := 0 +const LAYER_BACK := 1 + +const MAX_ENTITY_ID_LENGTH := 128 +const MAX_ENTITIES := 4_096 +const MAX_CELLS_PER_ENTITY := 256 +const MAX_TOTAL_SLOTS := 65_536 +const MAX_DIRTY_CELLS := 65_536 +const MAX_COORDINATE_ABS := 100_000 +const MAX_REVISION := 9_007_199_254_740_991 +const MAX_SNAPSHOT_DEPTH := 8 +const MAX_CONTAINER_ITEMS := 65_536 + +var revision := 0 +var last_error := "" +var last_callback_report: Dictionary = {} + +var _category_revisions: Dictionary = { + CATEGORY_ROUTE: 0, + CATEGORY_DECORATION: 0, + CATEGORY_UTILITY: 0, +} +var _slots: Dictionary = {} +var _entity_cells: Dictionary = {} +var _entity_categories: Dictionary = {} +var _dirty_cells: Dictionary = {} +var _compatibility_validator := Callable() +var _mutation_active := false + + +## Optional read-only boundary for World-specific rules such as pass-through +## facilities. The callback receives one defensive payload and must return a +## strict `{ok: bool, reason?: StringName, details?: Dictionary}` response. +func configure_compatibility_validator(callback: Callable = Callable()) -> bool: + if _mutation_active: + last_error = "Spatial subslot mutation is already active." + return false + _compatibility_validator = callback + last_callback_report = {} + last_error = "" + return true + + +func place(entity_id: Variant, category: Variant, cells: Variant) -> CommandResult: + if _mutation_active: + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Spatial subslot mutation is already active.") + _mutation_active = true + var result := _place_internal(entity_id, category, cells) + _mutation_active = false + return result + + +func remove(entity_id: Variant) -> CommandResult: + if _mutation_active: + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Spatial subslot mutation is already active.") + _mutation_active = true + var result := _remove_internal(entity_id) + _mutation_active = false + return result + + +func replace_entity_cells(entity_id: Variant, cells: Variant) -> CommandResult: + if _mutation_active: + return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Spatial subslot mutation is already active.") + _mutation_active = true + var result := _replace_internal(entity_id, cells) + _mutation_active = false + return result + + +func has_entity(entity_id: StringName) -> bool: + return _entity_cells.has(entity_id) + + +func get_entity_category(entity_id: StringName) -> StringName: + return StringName(_entity_categories.get(entity_id, &"")) + + +func get_entity_cells(entity_id: StringName) -> Array[Vector3i]: + var source: Variant = _entity_cells.get(entity_id, []) + return _copy_cells(source) if source is Array else [] + + +func get_entity_ids(category: Variant = null) -> Array[StringName]: + var filter_category: StringName = &"" + if category != null: + if not _category_is_valid(category): + return [] + filter_category = StringName(category) + var result: Array[StringName] = [] + for entity_id: StringName in _sorted_entity_ids(_entity_cells): + if filter_category.is_empty() or _entity_categories[entity_id] == filter_category: + result.append(entity_id) + return result + + +func get_entities(category: Variant = null) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for entity_id: StringName in get_entity_ids(category): + result.append({ + &"entity_id": entity_id, + &"category": _entity_categories[entity_id], + &"cells": get_entity_cells(entity_id), + }) + return result + + +func get_slot_entity(cell: Variant, category: Variant) -> StringName: + if not _cell_is_valid(cell) or not _category_is_valid(category): + return &"" + var cell_slots: Variant = _slots.get(cell, {}) + if not cell_slots is Dictionary: + return &"" + return StringName(cell_slots.get(StringName(category), &"")) + + +func get_cell_slots(cell: Variant) -> Dictionary: + if not _cell_is_valid(cell): + return {} + var source: Variant = _slots.get(cell, {}) + if not source is Dictionary: + return {} + var result: Dictionary = {} + for category: StringName in CATEGORIES: + if source.has(category): + result[category] = StringName(source[category]) + return result + + +func get_cells_for_category(category: Variant) -> Array[Vector3i]: + if not _category_is_valid(category): + return [] + var cell_set: Dictionary = {} + var canonical_category := StringName(category) + for entity_id: StringName in _sorted_entity_ids(_entity_cells): + if _entity_categories[entity_id] != canonical_category: + continue + for cell: Vector3i in _entity_cells[entity_id]: + cell_set[cell] = true + return _sorted_cells(cell_set.keys()) + + +## Canonical passable-cell input for a future PathService graph rebuild. +func get_route_cells() -> Array[Vector3i]: + return get_cells_for_category(CATEGORY_ROUTE) + + +func get_revision_for_category(category: Variant) -> int: + if not _category_is_valid(category): + return -1 + return int(_category_revisions[StringName(category)]) + + +func get_dirty_revision() -> int: + return revision + + +func get_dirty_cells() -> Array[Vector3i]: + return _sorted_cells(_dirty_cells.keys()) + + +func consume_dirty_cells() -> Array[Vector3i]: + if _mutation_active: + last_error = "Dirty cells cannot be consumed during a spatial mutation." + return [] + var result := get_dirty_cells() + _dirty_cells.clear() + last_error = "" + return result + + +func get_last_callback_report() -> Dictionary: + return last_callback_report.duplicate(true) + + +func create_snapshot() -> Dictionary: + var entities: Array[Dictionary] = [] + for entity_id: StringName in _sorted_entity_ids(_entity_cells): + entities.append({ + &"entity_id": String(entity_id), + &"category": String(_entity_categories[entity_id]), + &"cells": get_entity_cells(entity_id), + }) + return { + &"schema_version": SNAPSHOT_SCHEMA_VERSION, + &"revision": revision, + &"category_revisions": { + &"ROUTE": int(_category_revisions[CATEGORY_ROUTE]), + &"DECORATION": int(_category_revisions[CATEGORY_DECORATION]), + &"UTILITY": int(_category_revisions[CATEGORY_UTILITY]), + }, + &"dirty_cells": get_dirty_cells(), + &"entities": entities, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _mutation_active: + last_error = "Spatial subslot mutation is already active." + return false + _mutation_active = true + var parsed := _parse_snapshot(snapshot) + if not bool(parsed.get(&"ok", false)): + _mutation_active = false + last_error = String(parsed.get(&"message", "Spatial subslot snapshot is invalid.")) + return false + _slots = parsed[&"slots"] + _entity_cells = parsed[&"entity_cells"] + _entity_categories = parsed[&"entity_categories"] + _dirty_cells = parsed[&"dirty_cells"] + _category_revisions = parsed[&"category_revisions"] + revision = int(parsed[&"revision"]) + _mutation_active = false + last_callback_report = {} + last_error = "" + return true + + +func validate_invariants() -> Dictionary: + var errors: Array[String] = [] + if revision < 0 or revision > MAX_REVISION: + errors.append("Revision is outside the supported range.") + if not _dictionary_has_exact_keys(_category_revisions, CATEGORIES): + errors.append("Category revision keys are not canonical.") + else: + var category_revisions_valid := true + var latest_category_revision := 0 + for category: StringName in CATEGORIES: + var raw_category_revision: Variant = _category_revisions[category] + if typeof(raw_category_revision) != TYPE_INT \ + or int(raw_category_revision) < 0 \ + or int(raw_category_revision) > revision: + errors.append("Category revision is invalid: %s" % category) + category_revisions_valid = false + else: + latest_category_revision = max(latest_category_revision, int(raw_category_revision)) + if category_revisions_valid and revision > 0 and latest_category_revision != revision: + errors.append("No category revision matches the global revision.") + if _entity_cells.size() > MAX_ENTITIES or _slots.size() > MAX_TOTAL_SLOTS: + errors.append("Spatial subslot capacity is exceeded.") + if _entity_cells.size() != _entity_categories.size(): + errors.append("Entity cell/category maps differ in size.") + var expected_slots: Dictionary = {} + var total_slots := 0 + var valid_entity_ids: Array[StringName] = [] + for raw_entity_id: Variant in _entity_cells: + if typeof(raw_entity_id) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or not _stable_id_is_valid(raw_entity_id): + errors.append("Entity cell map contains an invalid ID.") + continue + valid_entity_ids.append(StringName(raw_entity_id)) + valid_entity_ids.sort_custom(_name_less) + for entity_id: StringName in valid_entity_ids: + if not _stable_id_is_valid(entity_id) or not _entity_categories.has(entity_id): + errors.append("Entity ID/category crosslink is invalid: %s" % entity_id) + continue + var category: Variant = _entity_categories[entity_id] + var raw_cells: Variant = _entity_cells[entity_id] + if not _category_is_valid(category) or not raw_cells is Array: + errors.append("Entity category/cells are invalid: %s" % entity_id) + continue + var validation := _validate_cells(raw_cells) + if not bool(validation[&"ok"]) or validation[&"cells"] != raw_cells: + errors.append("Entity cells are invalid or unordered: %s" % entity_id) + continue + for cell: Vector3i in validation[&"cells"]: + var cell_slots: Dictionary = expected_slots.get(cell, {}) + if cell_slots.has(category): + errors.append("Two entities claim one category slot at %s." % cell) + else: + cell_slots[category] = entity_id + expected_slots[cell] = cell_slots + total_slots += 1 + if total_slots > MAX_TOTAL_SLOTS: + errors.append("Total occupied subslots exceed the capacity.") + if expected_slots != _slots: + errors.append("Cell/entity/category crosslinks do not match.") + if _dirty_cells.size() > MAX_DIRTY_CELLS: + errors.append("Dirty-cell capacity is exceeded.") + for raw_cell: Variant in _dirty_cells: + if not _cell_is_valid(raw_cell) \ + or typeof(_dirty_cells[raw_cell]) != TYPE_BOOL \ + or not bool(_dirty_cells[raw_cell]): + errors.append("Dirty cell is invalid.") + return {&"ok": errors.is_empty(), &"errors": errors} + + +func _place_internal(entity_id_value: Variant, category_value: Variant, cells_value: Variant) -> CommandResult: + var identity := _parse_identity(entity_id_value, category_value) + if not bool(identity[&"ok"]): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(identity[&"message"])) + var entity_id: StringName = identity[&"entity_id"] + var category: StringName = identity[&"category"] + if _entity_cells.has(entity_id): + return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Spatial entity ID already exists.") + if _entity_cells.size() >= MAX_ENTITIES: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial entity capacity is exhausted.") + var cell_validation := _validate_cells(cells_value) + if not bool(cell_validation[&"ok"]): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(cell_validation[&"message"])) + var cells: Array[Vector3i] = cell_validation[&"cells"] + var current_total := _total_occupied_slots() + if current_total > MAX_TOTAL_SLOTS - cells.size(): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial subslot capacity is exhausted.") + for cell: Vector3i in cells: + var cell_slots: Dictionary = _slots.get(cell, {}) + if cell_slots.has(category): + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "Spatial category slot is already occupied.", + {&"cell": cell, &"category": category, &"occupant_id": cell_slots[category]} + ) + if not _dirty_capacity_allows(cells): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Dirty-cell capacity is exhausted.") + if revision >= MAX_REVISION: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial revision limit is exhausted.") + var payload := _mutation_payload(&"PLACE", entity_id, category, [], cells) + var boundary := _validate_external_boundary(payload) + if not bool(boundary[&"ok"]): + return _failure( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + String(boundary[&"message"]), + boundary.get(&"details", {}) + ) + for cell: Vector3i in cells: + var cell_slots: Dictionary = _slots.get(cell, {}) + cell_slots[category] = entity_id + _slots[cell] = cell_slots + _entity_cells[entity_id] = cells.duplicate() + _entity_categories[entity_id] = category + _mark_changed(category, cells) + entity_placed.emit(entity_id, category, cells.duplicate()) + last_error = "" + return _success({ + &"entity_id": entity_id, + &"category": category, + &"cells": cells.duplicate(), + &"revision": revision, + &"changed": true, + }) + + +func _remove_internal(entity_id_value: Variant) -> CommandResult: + if not _stable_id_is_valid(entity_id_value): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Spatial entity ID is invalid.") + var entity_id := StringName(entity_id_value) + if not _entity_cells.has(entity_id): + return _failure(CommandResultType.Code.NOT_FOUND, "Spatial entity was not found.") + var category: StringName = _entity_categories[entity_id] + var cells := get_entity_cells(entity_id) + if not _dirty_capacity_allows(cells): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Dirty-cell capacity is exhausted.") + if revision >= MAX_REVISION: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial revision limit is exhausted.") + var boundary := _validate_external_boundary( + _mutation_payload(&"REMOVE", entity_id, category, cells, []) + ) + if not bool(boundary[&"ok"]): + return _failure( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + String(boundary[&"message"]), + boundary.get(&"details", {}) + ) + for cell: Vector3i in cells: + var cell_slots: Dictionary = _slots.get(cell, {}) + if cell_slots.get(category, &"") == entity_id: + cell_slots.erase(category) + if cell_slots.is_empty(): + _slots.erase(cell) + else: + _slots[cell] = cell_slots + _entity_cells.erase(entity_id) + _entity_categories.erase(entity_id) + _mark_changed(category, cells) + entity_removed.emit(entity_id, category, cells.duplicate()) + last_error = "" + return _success({ + &"entity_id": entity_id, + &"category": category, + &"cells": cells, + &"revision": revision, + &"changed": true, + }) + + +func _replace_internal(entity_id_value: Variant, cells_value: Variant) -> CommandResult: + if not _stable_id_is_valid(entity_id_value): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Spatial entity ID is invalid.") + var entity_id := StringName(entity_id_value) + if not _entity_cells.has(entity_id): + return _failure(CommandResultType.Code.NOT_FOUND, "Spatial entity was not found.") + var validation := _validate_cells(cells_value) + if not bool(validation[&"ok"]): + return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(validation[&"message"])) + var after_cells: Array[Vector3i] = validation[&"cells"] + var before_cells := get_entity_cells(entity_id) + var category: StringName = _entity_categories[entity_id] + if before_cells == after_cells: + last_error = "" + return _success({ + &"entity_id": entity_id, + &"category": category, + &"cells": after_cells.duplicate(), + &"revision": revision, + &"changed": false, + }) + var projected_total := _total_occupied_slots() - before_cells.size() + after_cells.size() + if projected_total > MAX_TOTAL_SLOTS: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial subslot capacity is exhausted.") + for cell: Vector3i in after_cells: + var occupant := get_slot_entity(cell, category) + if not occupant.is_empty() and occupant != entity_id: + return _failure( + CommandResultType.Code.VALIDATION_FAILED, + "Spatial category slot is already occupied.", + {&"cell": cell, &"category": category, &"occupant_id": occupant} + ) + var dirty_union: Dictionary = {} + for cell: Vector3i in before_cells: + dirty_union[cell] = true + for cell: Vector3i in after_cells: + dirty_union[cell] = true + var dirty_cells := _sorted_cells(dirty_union.keys()) + if not _dirty_capacity_allows(dirty_cells): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Dirty-cell capacity is exhausted.") + if revision >= MAX_REVISION: + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial revision limit is exhausted.") + var boundary := _validate_external_boundary( + _mutation_payload(&"REPLACE", entity_id, category, before_cells, after_cells) + ) + if not bool(boundary[&"ok"]): + return _failure( + CommandResultType.Code.TRANSACTION_APPLY_FAILED, + String(boundary[&"message"]), + boundary.get(&"details", {}) + ) + for cell: Vector3i in before_cells: + var cell_slots: Dictionary = _slots.get(cell, {}) + if cell_slots.get(category, &"") == entity_id: + cell_slots.erase(category) + if cell_slots.is_empty(): + _slots.erase(cell) + else: + _slots[cell] = cell_slots + for cell: Vector3i in after_cells: + var cell_slots: Dictionary = _slots.get(cell, {}) + cell_slots[category] = entity_id + _slots[cell] = cell_slots + _entity_cells[entity_id] = after_cells.duplicate() + _mark_changed(category, dirty_cells) + entity_cells_replaced.emit( + entity_id, + category, + before_cells.duplicate(), + after_cells.duplicate() + ) + last_error = "" + return _success({ + &"entity_id": entity_id, + &"category": category, + &"before_cells": before_cells, + &"cells": after_cells.duplicate(), + &"revision": revision, + &"changed": true, + }) + + +func _validate_external_boundary(payload: Dictionary) -> Dictionary: + last_callback_report = {} + if not _compatibility_validator.is_valid(): + return {&"ok": true, &"message": "", &"details": {}} + var before := _capture_internal_state() + var raw_response: Variant = _compatibility_validator.call(payload.duplicate(true)) + var tampered := not _internal_state_matches(before) + if tampered: + _restore_internal_state(before) + var response_ok := false + var response_reason: StringName = &"" + var response_details: Dictionary = {} + var response_valid := raw_response is Dictionary + if response_valid: + var response: Dictionary = raw_response + response_valid = _dictionary_has_only_keys(response, [&"ok", &"reason", &"details"]) + var raw_ok: Variant = response.get(&"ok", null) + response_valid = response_valid and typeof(raw_ok) == TYPE_BOOL + if response.has(&"reason"): + response_valid = response_valid and typeof(response[&"reason"]) in [TYPE_STRING, TYPE_STRING_NAME] + if response.has(&"details"): + response_valid = response_valid \ + and response[&"details"] is Dictionary \ + and _variant_depth_is_valid(response[&"details"], 0) + if response_valid: + response_ok = bool(raw_ok) + response_reason = StringName(response.get(&"reason", &"")) + response_details = (response.get(&"details", {}) as Dictionary).duplicate(true) + last_callback_report = { + &"ok": response_ok and not tampered, + &"response_valid": response_valid, + &"tampered": tampered, + &"reason": response_reason, + &"details": response_details.duplicate(true), + } + if tampered: + return { + &"ok": false, + &"message": "Compatibility callback mutated SpatialSubslotService and was rolled back.", + &"details": {&"reason": &"CALLBACK_TAMPERED", &"rollback_ok": true}, + } + if not response_valid: + return { + &"ok": false, + &"message": "Compatibility callback returned an invalid response.", + &"details": {&"reason": &"INVALID_CALLBACK_RESPONSE", &"rollback_ok": true}, + } + if not response_ok: + return { + &"ok": false, + &"message": "Compatibility callback rejected the spatial mutation.", + &"details": { + &"reason": response_reason if not response_reason.is_empty() else &"COMPATIBILITY_REJECTED", + &"callback_details": response_details, + &"rollback_ok": true, + }, + } + return {&"ok": true, &"message": "", &"details": response_details} + + +func _capture_internal_state() -> Dictionary: + return { + &"mutation_active": _mutation_active, + &"compatibility_validator": _compatibility_validator, + &"revision": revision, + &"category_revisions": _category_revisions.duplicate(true), + &"slots": _slots.duplicate(true), + &"entity_cells": _entity_cells.duplicate(true), + &"entity_categories": _entity_categories.duplicate(true), + &"dirty_cells": _dirty_cells.duplicate(true), + } + + +func _internal_state_matches(capture: Dictionary) -> bool: + return ( + _mutation_active == bool(capture[&"mutation_active"]) + and _compatibility_validator == capture[&"compatibility_validator"] + and revision == int(capture[&"revision"]) + and _category_revisions == capture[&"category_revisions"] + and _slots == capture[&"slots"] + and _entity_cells == capture[&"entity_cells"] + and _entity_categories == capture[&"entity_categories"] + and _dirty_cells == capture[&"dirty_cells"] + ) + + +func _restore_internal_state(capture: Dictionary) -> void: + _mutation_active = bool(capture[&"mutation_active"]) + _compatibility_validator = capture[&"compatibility_validator"] + revision = int(capture[&"revision"]) + _category_revisions = (capture[&"category_revisions"] as Dictionary).duplicate(true) + _slots = (capture[&"slots"] as Dictionary).duplicate(true) + _entity_cells = (capture[&"entity_cells"] as Dictionary).duplicate(true) + _entity_categories = (capture[&"entity_categories"] as Dictionary).duplicate(true) + _dirty_cells = (capture[&"dirty_cells"] as Dictionary).duplicate(true) + + +func _mark_changed(category: StringName, cells: Array[Vector3i]) -> void: + revision += 1 + _category_revisions[category] = revision + for cell: Vector3i in cells: + _dirty_cells[cell] = true + + +func _dirty_capacity_allows(cells: Array[Vector3i]) -> bool: + var additional := 0 + for cell: Vector3i in cells: + if not _dirty_cells.has(cell): + additional += 1 + return _dirty_cells.size() <= MAX_DIRTY_CELLS - additional + + +func _total_occupied_slots() -> int: + var total := 0 + for entity_id: StringName in _sorted_entity_ids(_entity_cells): + var cells: Variant = _entity_cells[entity_id] + if cells is Array: + total += cells.size() + return total + + +static func _mutation_payload( + operation: StringName, + entity_id: StringName, + category: StringName, + before_cells: Array[Vector3i], + after_cells: Array[Vector3i] +) -> Dictionary: + return { + &"operation": operation, + &"entity_id": entity_id, + &"category": category, + &"before_cells": before_cells.duplicate(), + &"after_cells": after_cells.duplicate(), + } + + +static func _parse_snapshot(snapshot: Dictionary) -> Dictionary: + if not _variant_depth_is_valid(snapshot, 0): + return {&"ok": false, &"message": "Snapshot depth or container capacity is exceeded."} + if not _dictionary_has_exact_keys(snapshot, [ + &"schema_version", &"revision", &"category_revisions", &"dirty_cells", &"entities", + ]): + return {&"ok": false, &"message": "Snapshot top-level fields are not canonical."} + if typeof(snapshot[&"schema_version"]) != TYPE_INT \ + or int(snapshot[&"schema_version"]) != SNAPSHOT_SCHEMA_VERSION: + return {&"ok": false, &"message": "Unsupported SpatialSubslotService snapshot version."} + if typeof(snapshot[&"revision"]) != TYPE_INT: + return {&"ok": false, &"message": "Snapshot revision must be an integer."} + var next_revision := int(snapshot[&"revision"]) + if next_revision < 0 or next_revision > MAX_REVISION: + return {&"ok": false, &"message": "Snapshot revision is outside the supported range."} + if not snapshot[&"category_revisions"] is Dictionary \ + or not _dictionary_has_exact_keys(snapshot[&"category_revisions"], CATEGORIES): + return {&"ok": false, &"message": "Snapshot category revisions are malformed."} + var next_category_revisions: Dictionary = {} + var latest_category_revision := 0 + for category: StringName in CATEGORIES: + var raw_category_revision: Variant = snapshot[&"category_revisions"][category] + if typeof(raw_category_revision) != TYPE_INT \ + or int(raw_category_revision) < 0 \ + or int(raw_category_revision) > next_revision: + return {&"ok": false, &"message": "Snapshot category revision is invalid."} + next_category_revisions[category] = int(raw_category_revision) + latest_category_revision = max(latest_category_revision, int(raw_category_revision)) + if next_revision > 0 and latest_category_revision != next_revision: + return {&"ok": false, &"message": "Snapshot has no category at the global revision."} + + if not snapshot[&"entities"] is Array or snapshot[&"entities"].size() > MAX_ENTITIES: + return {&"ok": false, &"message": "Snapshot entity collection is malformed or too large."} + var next_slots: Dictionary = {} + var next_entity_cells: Dictionary = {} + var next_entity_categories: Dictionary = {} + var total_slots := 0 + var previous_entity_id := "" + for raw_entity: Variant in snapshot[&"entities"]: + if not raw_entity is Dictionary or not _dictionary_has_exact_keys(raw_entity, [ + &"entity_id", &"category", &"cells", + ]): + return {&"ok": false, &"message": "Snapshot entity entry is malformed."} + if typeof(raw_entity[&"entity_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"message": "Snapshot entity ID must be a string."} + var entity_id := StringName(raw_entity[&"entity_id"]) + var entity_text := String(entity_id) + if not _stable_id_is_valid(entity_id) \ + or (not previous_entity_id.is_empty() and entity_text <= previous_entity_id): + return {&"ok": false, &"message": "Snapshot entity IDs are invalid, duplicated, or unordered."} + if not _category_is_valid(raw_entity[&"category"]): + return {&"ok": false, &"message": "Snapshot entity category is invalid."} + var category := StringName(raw_entity[&"category"]) + var cells_validation := _validate_cells(raw_entity[&"cells"]) + if not bool(cells_validation[&"ok"]): + return {&"ok": false, &"message": String(cells_validation[&"message"])} + var cells: Array[Vector3i] = cells_validation[&"cells"] + if raw_entity[&"cells"] != cells: + return {&"ok": false, &"message": "Snapshot entity cells are not canonically ordered."} + if total_slots > MAX_TOTAL_SLOTS - cells.size(): + return {&"ok": false, &"message": "Snapshot occupied subslot capacity is exceeded."} + for cell: Vector3i in cells: + var cell_slots: Dictionary = next_slots.get(cell, {}) + if cell_slots.has(category): + return {&"ok": false, &"message": "Snapshot contains a duplicate category slot."} + cell_slots[category] = entity_id + next_slots[cell] = cell_slots + next_entity_cells[entity_id] = cells + next_entity_categories[entity_id] = category + total_slots += cells.size() + previous_entity_id = entity_text + + if not snapshot[&"dirty_cells"] is Array or snapshot[&"dirty_cells"].size() > MAX_DIRTY_CELLS: + return {&"ok": false, &"message": "Snapshot dirty-cell collection is malformed or too large."} + var next_dirty: Dictionary = {} + var previous_dirty: Variant = null + for raw_cell: Variant in snapshot[&"dirty_cells"]: + if not _cell_is_valid(raw_cell) or next_dirty.has(raw_cell): + return {&"ok": false, &"message": "Snapshot dirty cell is invalid or duplicated."} + var cell: Vector3i = raw_cell + if previous_dirty is Vector3i and not _cell_less(previous_dirty, cell): + return {&"ok": false, &"message": "Snapshot dirty cells are not canonically ordered."} + next_dirty[cell] = true + previous_dirty = cell + if next_revision == 0 and (not next_entity_cells.is_empty() or not next_dirty.is_empty()): + return {&"ok": false, &"message": "Zero revision cannot own entities or dirty cells."} + for category: StringName in CATEGORIES: + if int(next_category_revisions[category]) == 0: + for entity_id: StringName in _sorted_entity_ids(next_entity_cells): + if next_entity_categories[entity_id] == category: + return {&"ok": false, &"message": "Occupied category must have a positive revision."} + return { + &"ok": true, + &"revision": next_revision, + &"category_revisions": next_category_revisions, + &"slots": next_slots, + &"entity_cells": next_entity_cells, + &"entity_categories": next_entity_categories, + &"dirty_cells": next_dirty, + } + + +static func _parse_identity(entity_id_value: Variant, category_value: Variant) -> Dictionary: + if not _stable_id_is_valid(entity_id_value): + return {&"ok": false, &"message": "Spatial entity ID is invalid."} + if not _category_is_valid(category_value): + return {&"ok": false, &"message": "Spatial category is invalid."} + return { + &"ok": true, + &"entity_id": StringName(entity_id_value), + &"category": StringName(category_value), + } + + +static func _validate_cells(value: Variant) -> Dictionary: + if not value is Array: + return {&"ok": false, &"message": "Spatial cells must be an Array.", &"cells": []} + if value.is_empty() or value.size() > MAX_CELLS_PER_ENTITY: + return { + &"ok": false, + &"message": "Spatial cells must be non-empty and within the per-entity limit.", + &"cells": [], + } + var seen: Dictionary = {} + var cells: Array[Vector3i] = [] + for raw_cell: Variant in value: + if not _cell_is_valid(raw_cell): + return {&"ok": false, &"message": "Spatial cell type, coordinate, or layer is invalid.", &"cells": []} + var cell: Vector3i = raw_cell + if seen.has(cell): + return {&"ok": false, &"message": "Spatial cells contain a duplicate.", &"cells": []} + seen[cell] = true + cells.append(cell) + cells.sort_custom(_cell_less) + return {&"ok": true, &"message": "", &"cells": cells} + + +static func _stable_id_is_valid(value: Variant) -> bool: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var text := String(value) + if text.is_empty() or text.length() > MAX_ENTITY_ID_LENGTH: + return false + for index: int in range(text.length()): + var code := text.unicode_at(index) + var allowed := ( + code >= 48 and code <= 57 + or code >= 65 and code <= 90 + or code >= 97 and code <= 122 + or code in [45, 46, 58, 64, 95] + ) + if not allowed: + return false + return true + + +static func _category_is_valid(value: Variant) -> bool: + return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and CATEGORIES.has(StringName(value)) + + +static func _cell_is_valid(value: Variant) -> bool: + if not value is Vector3i: + return false + var cell: Vector3i = value + return ( + cell.z in [LAYER_FRONT, LAYER_BACK] + and cell.x >= -MAX_COORDINATE_ABS + and cell.x <= MAX_COORDINATE_ABS + and cell.y >= -MAX_COORDINATE_ABS + and cell.y <= MAX_COORDINATE_ABS + ) + + +static func _sorted_entity_ids(source: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for raw_entity_id: Variant in source: + result.append(StringName(raw_entity_id)) + result.sort_custom(_name_less) + return result + + +static func _copy_cells(source: Array) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + for raw_cell: Variant in source: + if raw_cell is Vector3i: + result.append(raw_cell) + result.sort_custom(_cell_less) + return result + + +static func _sorted_cells(source: Variant) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + if not source is Array: + return result + for raw_cell: Variant in source: + if raw_cell is Vector3i: + result.append(raw_cell) + result.sort_custom(_cell_less) + return result + + +static func _cell_less(left: Vector3i, right: Vector3i) -> bool: + if left.y != right.y: + return left.y < right.y + if left.x != right.x: + return left.x < right.x + return left.z < right.z + + +static func _name_less(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + + +static func _dictionary_has_exact_keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + for key: Variant in expected: + if not source.has(key): + return false + return true + + +static func _dictionary_has_only_keys(source: Dictionary, allowed: Array) -> bool: + for key: Variant in source: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or not allowed.has(StringName(key)): + return false + return source.has(&"ok") or source.has("ok") + + +static func _variant_depth_is_valid(value: Variant, depth: int) -> bool: + if depth > MAX_SNAPSHOT_DEPTH: + return false + if value is Array: + if value.size() > MAX_CONTAINER_ITEMS: + return false + for child: Variant in value: + if not _variant_depth_is_valid(child, depth + 1): + return false + elif value is Dictionary: + if value.size() > MAX_CONTAINER_ITEMS: + return false + for key: Variant in value: + if not _variant_depth_is_valid(key, depth + 1) \ + or not _variant_depth_is_valid(value[key], depth + 1): + return false + elif typeof(value) in [TYPE_OBJECT, TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]: + return false + return true + + +func _success(details: Dictionary = {}) -> CommandResult: + last_error = "" + return CommandResultType.succeeded(0, details) + + +func _failure(code: CommandResult.Code, message: String, details: Dictionary = {}) -> CommandResult: + last_error = message + return CommandResultType.failed(code, 0, message, details) diff --git a/scripts/world/spatial_subslot_service.gd.uid b/scripts/world/spatial_subslot_service.gd.uid new file mode 100644 index 0000000..b5b9b6f --- /dev/null +++ b/scripts/world/spatial_subslot_service.gd.uid @@ -0,0 +1 @@ +uid://cb48ceerjvv7h diff --git a/scripts/world/world_service.gd b/scripts/world/world_service.gd new file mode 100644 index 0000000..92df4ac --- /dev/null +++ b/scripts/world/world_service.gd @@ -0,0 +1,793 @@ +class_name WorldService +extends RefCounted + +## Deterministic logical-world storage for S1. +## +## Vector3i uses (x, y, layer), where layer 0 is FRONT and layer 1 is BACK. +## All collection-returning APIs use a stable y/x/layer or entity-id order. + +const SNAPSHOT_SCHEMA_VERSION := 1 + +const LAYER_FRONT := 0 +const LAYER_BACK := 1 + +const ZONE_INVALID := &"INVALID" +const ZONE_ABOVE_GROUND := &"ABOVE_GROUND" +const ZONE_BOUNDARY := &"BOUNDARY" +const ZONE_UNDERGROUND := &"UNDERGROUND" + +const ERROR_EMPTY_CELLS := &"EMPTY_CELLS" +const ERROR_INVALID_CELL_TYPE := &"INVALID_CELL_TYPE" +const ERROR_OUT_OF_BOUNDS := &"OUT_OF_BOUNDS" +const ERROR_DUPLICATE_CELL := &"DUPLICATE_CELL" +const ERROR_LAYER_MISMATCH := &"LAYER_MISMATCH" +const ERROR_OCCUPIED := &"OCCUPIED" +const ERROR_ENTITY_EXISTS := &"ENTITY_EXISTS" +const ERROR_INVALID_ENTITY_ID := &"INVALID_ENTITY_ID" +const ERROR_INVALID_KIND := &"INVALID_KIND" +const ERROR_INVALID_WEIGHT := &"INVALID_WEIGHT" +const ERROR_UNSUPPORTED := &"UNSUPPORTED" + +var revision := 0 +var dirty_revision: int: + get: + return revision +var occupancy_revision := 0 +var walkability_revision := 0 +var excavation_revision := 0 +var support_revision := 0 + +var _bounds := { + &"min_x": 0, + &"max_x": -1, + &"min_y": 0, + &"max_y": -1, +} +var _occupants: Dictionary = {} +var _entity_cells: Dictionary = {} +var _entity_kinds: Dictionary = {} +var _walkable: Dictionary = {} +var _excavated: Dictionary = {} +var _support_capacity: Dictionary = {} +var _support_anchors: Dictionary = {} +var _dirty_cells: Dictionary = {} + + +func reset(bounds: Variant) -> bool: + var normalized := _normalize_bounds(bounds) + if normalized.is_empty(): + return false + _bounds = normalized + _occupants.clear() + _entity_cells.clear() + _entity_kinds.clear() + _walkable.clear() + _excavated.clear() + _support_capacity.clear() + _support_anchors.clear() + _dirty_cells.clear() + revision = 0 + occupancy_revision = 0 + walkability_revision = 0 + excavation_revision = 0 + support_revision = 0 + return true + + +func get_bounds() -> Dictionary: + return _bounds.duplicate(true) + + +func expand_bounds(bounds: Variant) -> bool: + var normalized := _normalize_bounds(bounds) + if normalized.is_empty(): + return false + if ( + int(normalized[&"min_x"]) > int(_bounds[&"min_x"]) + or int(normalized[&"max_x"]) < int(_bounds[&"max_x"]) + or int(normalized[&"min_y"]) > int(_bounds[&"min_y"]) + or int(normalized[&"max_y"]) < int(_bounds[&"max_y"]) + ): + return false + if normalized == _bounds: + return true + _bounds = normalized + revision += 1 + return true + + +func is_in_bounds(cell: Variant) -> bool: + return cell is Vector3i and _is_in_bounds_with(cell, _bounds) + + +func get_zone(cell_or_y: Variant) -> StringName: + var y: int + if cell_or_y is Vector3i or cell_or_y is Vector2i: + y = cell_or_y.y + elif cell_or_y is int: + y = int(cell_or_y) + else: + return ZONE_INVALID + if y > 0: + return ZONE_ABOVE_GROUND + if y < 0: + return ZONE_UNDERGROUND + return ZONE_BOUNDARY + + +func cells_for_footprint(origin: Vector3i, footprint: Variant) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + if origin.z not in [LAYER_FRONT, LAYER_BACK]: + return result + if footprint is Vector2i: + if footprint.x <= 0 or footprint.y <= 0: + return result + for y_offset in range(footprint.y): + for x_offset in range(footprint.x): + result.append(origin + Vector3i(x_offset, y_offset, 0)) + return result + if footprint is Array: + for offset: Variant in footprint: + if not offset is Vector2i: + return [] + result.append(origin + Vector3i(offset.x, offset.y, 0)) + result.sort_custom(_cell_less) + return result + + +func validate_occupancy(cells: Array, entity_id: StringName = &"") -> Dictionary: + var report := _validate_cells(cells) + var occupied_cells: Array[Vector3i] = [] + for cell: Vector3i in report[&"valid_cells"]: + if _occupants.has(cell): + occupied_cells.append(cell) + if not occupied_cells.is_empty(): + _add_error(report[&"errors"], ERROR_OCCUPIED) + if not entity_id.is_empty() and _entity_cells.has(entity_id): + _add_error(report[&"errors"], ERROR_ENTITY_EXISTS) + report[&"occupied_cells"] = occupied_cells + report[&"existing_entity"] = not entity_id.is_empty() and _entity_cells.has(entity_id) + report[&"ok"] = report[&"errors"].is_empty() + return report + + +func occupy(entity_id: StringName, cells: Array, kind: StringName = &"facility") -> bool: + if entity_id.is_empty() or kind.is_empty(): + return false + var report := validate_occupancy(cells, entity_id) + if not report[&"ok"]: + return false + var canonical_cells: Array[Vector3i] = report[&"valid_cells"].duplicate() + canonical_cells.sort_custom(_cell_less) + for cell: Vector3i in canonical_cells: + _occupants[cell] = { + &"entity_id": entity_id, + &"kind": kind, + } + _entity_cells[entity_id] = canonical_cells + _entity_kinds[entity_id] = kind + var structural := _kind_support_capacity(kind) > 0 or _kind_weight(kind) > 0 or _kind_is_anchor(kind) + _mark_dirty(canonical_cells, true, false, false, structural) + return true + + +func release(entity_id: StringName) -> bool: + if entity_id.is_empty() or not _entity_cells.has(entity_id): + return false + var cells: Array[Vector3i] = _copy_cell_array(_entity_cells[entity_id]) + var kind: StringName = _entity_kinds.get(entity_id, &"") + for cell: Vector3i in cells: + var occupant: Dictionary = _occupants.get(cell, {}) + if occupant.get(&"entity_id", &"") == entity_id: + _occupants.erase(cell) + _entity_cells.erase(entity_id) + _entity_kinds.erase(entity_id) + var structural := _kind_support_capacity(kind) > 0 or _kind_weight(kind) > 0 or _kind_is_anchor(kind) + _mark_dirty(cells, true, false, false, structural) + return true + + +func get_occupant(cell: Vector3i) -> StringName: + var occupant: Dictionary = _occupants.get(cell, {}) + return occupant.get(&"entity_id", &"") + + +func get_occupant_kind(cell: Vector3i) -> StringName: + var occupant: Dictionary = _occupants.get(cell, {}) + return occupant.get(&"kind", &"") + + +func get_entity_cells(entity_id: StringName) -> Array[Vector3i]: + if not _entity_cells.has(entity_id): + return [] + return _copy_cell_array(_entity_cells[entity_id]) + + +func get_entity_kind(entity_id: StringName) -> StringName: + return _entity_kinds.get(entity_id, &"") + + +func set_walkable(cell: Vector3i, value: bool) -> bool: + if not is_in_bounds(cell): + return false + var previous := bool(_walkable.get(cell, false)) + if previous == value: + return true + if value: + _walkable[cell] = true + else: + _walkable.erase(cell) + _mark_dirty([cell], false, true, false, false) + return true + + +func is_walkable(cell: Vector3i) -> bool: + return is_in_bounds(cell) and bool(_walkable.get(cell, false)) + + +func get_walkable_cells() -> Array[Vector3i]: + return _sorted_dictionary_cells(_walkable) + + +func set_excavated(cell: Vector3i, value: bool) -> bool: + if not is_in_bounds(cell): + return false + var spatial_key := Vector2i(cell.x, cell.y) + var previous := is_excavated(cell) + if previous == value: + return true + var default_value := cell.y >= 0 + if value == default_value: + _excavated.erase(spatial_key) + else: + _excavated[spatial_key] = value + var affected: Array[Vector3i] = [ + Vector3i(cell.x, cell.y, LAYER_FRONT), + Vector3i(cell.x, cell.y, LAYER_BACK), + ] + _mark_dirty(affected, false, false, true, false) + return true + + +func is_excavated(cell: Vector3i) -> bool: + if not is_in_bounds(cell): + return false + var spatial_key := Vector2i(cell.x, cell.y) + return bool(_excavated.get(spatial_key, cell.y >= 0)) + + +## Sets explicit support on a layer cell. Anchors have unlimited capacity. +## A capacity of zero with anchor=false removes the explicit support record. +func set_support(cell: Vector3i, capacity: int, anchor := false) -> bool: + if not is_in_bounds(cell) or capacity < 0: + return false + var previous_capacity := int(_support_capacity.get(cell, 0)) + var previous_anchor := bool(_support_anchors.get(cell, false)) + if previous_capacity == capacity and previous_anchor == anchor: + return true + if capacity == 0: + _support_capacity.erase(cell) + else: + _support_capacity[cell] = capacity + if anchor: + _support_anchors[cell] = true + else: + _support_anchors.erase(cell) + _mark_dirty([cell], false, false, false, true) + return true + + +func get_support_capacity(cell: Vector3i) -> int: + if not is_in_bounds(cell): + return 0 + var capacity := int(_support_capacity.get(cell, 0)) + if _occupants.has(cell): + capacity += _kind_support_capacity(get_occupant_kind(cell)) + return capacity + + +func is_support_anchor(cell: Vector3i) -> bool: + return ( + is_in_bounds(cell) + and ( + bool(_support_anchors.get(cell, false)) + or (_occupants.has(cell) and _kind_is_anchor(get_occupant_kind(cell))) + ) + ) + + +## Validates connected structural components touched by the proposed cells. +## Existing structural occupant kinds contribute their configured capacity/load. +func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary: + var base_report := _validate_cells(cells) + var errors: Array[StringName] = base_report[&"errors"].duplicate() + if weight_per_cell <= 0: + _add_error(errors, ERROR_INVALID_WEIGHT) + var proposed: Array[Vector3i] = base_report[&"valid_cells"].duplicate() + proposed.sort_custom(_cell_less) + var result := { + &"ok": false, + &"errors": errors, + &"unsupported_cells": [] as Array[Vector3i], + &"required_weight": 0, + &"available_capacity": 0, + &"components": [] as Array[Dictionary], + } + if not errors.is_empty(): + return result + + var structural_set: Dictionary = {} + for proposed_cell: Vector3i in proposed: + structural_set[proposed_cell] = true + for cell: Variant in _sorted_dictionary_cells(_support_capacity): + structural_set[cell] = true + for cell: Variant in _sorted_dictionary_cells(_support_anchors): + structural_set[cell] = true + for entity_id: StringName in _sorted_entity_ids(): + var kind: StringName = _entity_kinds[entity_id] + if _kind_support_capacity(kind) <= 0 and _kind_weight(kind) <= 0 and not _kind_is_anchor(kind): + continue + for occupied_cell: Vector3i in _entity_cells[entity_id]: + structural_set[occupied_cell] = true + + var proposed_set: Dictionary = {} + for proposed_cell: Vector3i in proposed: + proposed_set[proposed_cell] = true + var visited: Dictionary = {} + var unsupported: Array[Vector3i] = [] + var total_required := 0 + var total_capacity := 0 + var all_components_anchored := true + for proposed_cell: Vector3i in proposed: + if visited.has(proposed_cell): + continue + var component := _collect_component(proposed_cell, structural_set, visited) + var component_proposed: Array[Vector3i] = [] + var required := 0 + var capacity := 0 + var anchored := false + for component_cell: Vector3i in component: + if proposed_set.has(component_cell): + component_proposed.append(component_cell) + required += weight_per_cell + if _occupants.has(component_cell) and not proposed_set.has(component_cell): + required += _kind_weight(get_occupant_kind(component_cell)) + capacity += get_support_capacity(component_cell) + anchored = anchored or is_support_anchor(component_cell) + component_proposed.sort_custom(_cell_less) + var supported := anchored or capacity >= required + if not supported: + unsupported.append_array(component_proposed) + result[&"components"].append({ + &"cells": component_proposed, + &"required_weight": required, + &"available_capacity": -1 if anchored else capacity, + &"anchored": anchored, + &"supported": supported, + }) + total_required += required + total_capacity += capacity + all_components_anchored = all_components_anchored and anchored + unsupported.sort_custom(_cell_less) + if not unsupported.is_empty(): + _add_error(result[&"errors"], ERROR_UNSUPPORTED) + result[&"unsupported_cells"] = unsupported + result[&"required_weight"] = total_required + result[&"available_capacity"] = -1 if all_components_anchored else total_capacity + result[&"ok"] = result[&"errors"].is_empty() + return result + + +func get_dirty_revision() -> int: + return revision + + +func get_dirty_cells() -> Array[Vector3i]: + return _sorted_dictionary_cells(_dirty_cells) + + +func consume_dirty_cells() -> Array[Vector3i]: + var result := get_dirty_cells() + _dirty_cells.clear() + return result + + +func create_snapshot() -> Dictionary: + var occupancies: Array[Dictionary] = [] + for entity_id: StringName in _sorted_entity_ids(): + occupancies.append({ + &"entity_id": entity_id, + &"kind": _entity_kinds[entity_id], + &"cells": get_entity_cells(entity_id), + }) + var excavation_overrides: Array[Dictionary] = [] + var spatial_keys: Array[Vector2i] = [] + for key: Variant in _excavated: + spatial_keys.append(key) + spatial_keys.sort_custom(_spatial_less) + for key: Vector2i in spatial_keys: + excavation_overrides.append({&"position": key, &"value": bool(_excavated[key])}) + var support_entries: Array[Dictionary] = [] + var support_cell_set: Dictionary = {} + for cell: Variant in _support_capacity: + support_cell_set[cell] = true + for cell: Variant in _support_anchors: + support_cell_set[cell] = true + for cell: Vector3i in _sorted_dictionary_cells(support_cell_set): + support_entries.append({ + &"cell": cell, + &"capacity": int(_support_capacity.get(cell, 0)), + &"anchor": bool(_support_anchors.get(cell, false)), + }) + return { + &"schema_version": SNAPSHOT_SCHEMA_VERSION, + &"bounds": get_bounds(), + &"occupancies": occupancies, + &"walkable_cells": _sorted_dictionary_cells(_walkable), + &"excavation_overrides": excavation_overrides, + &"support": support_entries, + &"dirty_cells": get_dirty_cells(), + &"revisions": { + &"world": revision, + &"occupancy": occupancy_revision, + &"walkability": walkability_revision, + &"excavation": excavation_revision, + &"support": support_revision, + }, + } + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if int(snapshot.get(&"schema_version", -1)) != SNAPSHOT_SCHEMA_VERSION: + return false + var new_bounds := _normalize_bounds(snapshot.get(&"bounds", {})) + if new_bounds.is_empty(): + return false + var new_occupants: Dictionary = {} + var new_entity_cells: Dictionary = {} + var new_entity_kinds: Dictionary = {} + var occupancies: Variant = snapshot.get(&"occupancies", null) + if not occupancies is Array: + return false + for entry: Variant in occupancies: + if not entry is Dictionary: + return false + var entity_id := StringName(entry.get(&"entity_id", &"")) + var kind := StringName(entry.get(&"kind", &"")) + var cells: Variant = entry.get(&"cells", null) + if entity_id.is_empty() or kind.is_empty() or not cells is Array or new_entity_cells.has(entity_id): + return false + var validation := _validate_cells_against_bounds(cells, new_bounds) + if not validation[&"errors"].is_empty(): + return false + var canonical_cells: Array[Vector3i] = validation[&"valid_cells"].duplicate() + for cell: Vector3i in canonical_cells: + if new_occupants.has(cell): + return false + new_occupants[cell] = {&"entity_id": entity_id, &"kind": kind} + new_entity_cells[entity_id] = canonical_cells + new_entity_kinds[entity_id] = kind + + var new_walkable: Dictionary = {} + var walkable_cells: Variant = snapshot.get(&"walkable_cells", null) + if not walkable_cells is Array: + return false + for cell: Variant in walkable_cells: + if not cell is Vector3i or not _is_in_bounds_with(cell, new_bounds) or new_walkable.has(cell): + return false + new_walkable[cell] = true + + var new_excavated: Dictionary = {} + var excavation_entries: Variant = snapshot.get(&"excavation_overrides", null) + if not excavation_entries is Array: + return false + for entry: Variant in excavation_entries: + if not entry is Dictionary: + return false + var position: Variant = entry.get(&"position", null) + var value: Variant = entry.get(&"value", null) + if not position is Vector2i or not value is bool or new_excavated.has(position): + return false + if not _is_spatial_in_bounds(position, new_bounds): + return false + if bool(value) == (position.y >= 0): + return false + new_excavated[position] = value + + var new_support_capacity: Dictionary = {} + var new_support_anchors: Dictionary = {} + var support_entries: Variant = snapshot.get(&"support", null) + if not support_entries is Array: + return false + var seen_support: Dictionary = {} + for entry: Variant in support_entries: + if not entry is Dictionary: + return false + var cell: Variant = entry.get(&"cell", null) + var capacity: Variant = entry.get(&"capacity", null) + var anchor: Variant = entry.get(&"anchor", null) + if ( + not cell is Vector3i + or not _is_in_bounds_with(cell, new_bounds) + or not capacity is int + or int(capacity) < 0 + or not anchor is bool + or (int(capacity) == 0 and not bool(anchor)) + or seen_support.has(cell) + ): + return false + seen_support[cell] = true + if int(capacity) > 0: + new_support_capacity[cell] = int(capacity) + if bool(anchor): + new_support_anchors[cell] = true + + var new_dirty: Dictionary = {} + var dirty_cells: Variant = snapshot.get(&"dirty_cells", null) + if not dirty_cells is Array: + return false + for cell: Variant in dirty_cells: + if not cell is Vector3i or not _is_in_bounds_with(cell, new_bounds) or new_dirty.has(cell): + return false + new_dirty[cell] = true + + var revisions: Variant = snapshot.get(&"revisions", null) + if not revisions is Dictionary: + return false + var revision_names: Array[StringName] = [ + &"world", &"occupancy", &"walkability", &"excavation", &"support", + ] + for revision_name: StringName in revision_names: + if not revisions.get(revision_name, null) is int or int(revisions[revision_name]) < 0: + return false + if ( + int(revisions[&"occupancy"]) > int(revisions[&"world"]) + or int(revisions[&"walkability"]) > int(revisions[&"world"]) + or int(revisions[&"excavation"]) > int(revisions[&"world"]) + or int(revisions[&"support"]) > int(revisions[&"world"]) + ): + return false + + _bounds = new_bounds + _occupants = new_occupants + _entity_cells = new_entity_cells + _entity_kinds = new_entity_kinds + _walkable = new_walkable + _excavated = new_excavated + _support_capacity = new_support_capacity + _support_anchors = new_support_anchors + _dirty_cells = new_dirty + revision = int(revisions[&"world"]) + occupancy_revision = int(revisions[&"occupancy"]) + walkability_revision = int(revisions[&"walkability"]) + excavation_revision = int(revisions[&"excavation"]) + support_revision = int(revisions[&"support"]) + return true + + +func _validate_cells(cells: Array) -> Dictionary: + return _validate_cells_against_bounds(cells, _bounds) + + +func _validate_cells_against_bounds(cells: Array, bounds: Dictionary) -> Dictionary: + var errors: Array[StringName] = [] + var valid_cells: Array[Vector3i] = [] + var invalid_cells: Array[Vector3i] = [] + var duplicate_cells: Array[Vector3i] = [] + var seen: Dictionary = {} + var layers: Dictionary = {} + if cells.is_empty(): + _add_error(errors, ERROR_EMPTY_CELLS) + for value: Variant in cells: + if not value is Vector3i: + _add_error(errors, ERROR_INVALID_CELL_TYPE) + continue + var cell: Vector3i = value + layers[cell.z] = true + if seen.has(cell): + duplicate_cells.append(cell) + _add_error(errors, ERROR_DUPLICATE_CELL) + continue + seen[cell] = true + valid_cells.append(cell) + if not _is_in_bounds_with(cell, bounds): + invalid_cells.append(cell) + _add_error(errors, ERROR_OUT_OF_BOUNDS) + if layers.size() > 1: + _add_error(errors, ERROR_LAYER_MISMATCH) + valid_cells.sort_custom(_cell_less) + invalid_cells.sort_custom(_cell_less) + duplicate_cells.sort_custom(_cell_less) + return { + &"ok": errors.is_empty(), + &"errors": errors, + &"valid_cells": valid_cells, + &"invalid_cells": invalid_cells, + &"duplicate_cells": duplicate_cells, + &"layer_mismatch": layers.size() > 1, + } + + +func _collect_component(start: Vector3i, structural_set: Dictionary, visited: Dictionary) -> Array[Vector3i]: + var queue: Array[Vector3i] = [start] + var component: Array[Vector3i] = [] + visited[start] = true + var index := 0 + while index < queue.size(): + var current := queue[index] + index += 1 + component.append(current) + var neighbors: Array[Vector3i] = [ + current + Vector3i(-1, 0, 0), + current + Vector3i(0, -1, 0), + current + Vector3i(0, 1, 0), + current + Vector3i(1, 0, 0), + ] + for neighbor: Vector3i in neighbors: + if structural_set.has(neighbor) and not visited.has(neighbor): + visited[neighbor] = true + queue.append(neighbor) + component.sort_custom(_cell_less) + return component + + +func _mark_dirty( + cells: Array, + occupancy_changed: bool, + walkability_changed: bool, + excavation_changed: bool, + support_changed: bool +) -> void: + revision += 1 + if occupancy_changed: + occupancy_revision = revision + if walkability_changed: + walkability_revision = revision + if excavation_changed: + excavation_revision = revision + if support_changed: + support_revision = revision + for cell: Variant in cells: + if cell is Vector3i and is_in_bounds(cell): + _dirty_cells[cell] = true + + +func _normalize_bounds(value: Variant) -> Dictionary: + var min_x: int + var max_x: int + var min_y: int + var max_y: int + if value is Rect2i: + if value.size.x <= 0 or value.size.y <= 0: + return {} + min_x = value.position.x + max_x = value.position.x + value.size.x - 1 + min_y = value.position.y + max_y = value.position.y + value.size.y - 1 + elif value is Dictionary: + if value.has(&"min") and value.has(&"max"): + var minimum: Variant = value[&"min"] + var maximum: Variant = value[&"max"] + if not minimum is Vector2i or not maximum is Vector2i: + return {} + min_x = minimum.x + max_x = maximum.x + min_y = minimum.y + max_y = maximum.y + else: + var names: Array[StringName] = [&"min_x", &"max_x", &"min_y", &"max_y"] + for name: StringName in names: + if not value.get(name, null) is int: + return {} + min_x = int(value[&"min_x"]) + max_x = int(value[&"max_x"]) + min_y = int(value[&"min_y"]) + max_y = int(value[&"max_y"]) + elif value is Vector4i: + min_x = value.x + max_x = value.y + min_y = value.z + max_y = value.w + else: + return {} + if min_x > max_x or min_y > max_y: + return {} + return { + &"min_x": min_x, + &"max_x": max_x, + &"min_y": min_y, + &"max_y": max_y, + } + + +func _sorted_entity_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for entity_id: Variant in _entity_cells: + result.append(StringName(entity_id)) + result.sort_custom(func(left: StringName, right: StringName) -> bool: + return String(left) < String(right) + ) + return result + + +func _sorted_dictionary_cells(source: Dictionary) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + for cell: Variant in source: + if cell is Vector3i: + result.append(cell) + result.sort_custom(_cell_less) + return result + + +func _copy_cell_array(source: Array) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + for cell: Variant in source: + result.append(cell) + result.sort_custom(_cell_less) + return result + + +func _kind_support_capacity(kind: StringName) -> int: + match String(kind).to_lower(): + "artificial_floor", "floor": + return 2 + "reinforced_floor": + return 5 + "support_column", "column": + return 10 + return 0 + + +func _kind_weight(kind: StringName) -> int: + match String(kind).to_lower(): + "facility", "facility_small", "small": + return 1 + "facility_medium", "medium": + return 2 + "facility_large", "facility_heavy", "large", "heavy": + return 3 + return 0 + + +func _kind_is_anchor(kind: StringName) -> bool: + return String(kind).to_lower() in [ + "anchor", "structural_anchor", "natural_branch", "world_tree", "world_tree_anchor", + ] + + +static func _is_in_bounds_with(cell: Vector3i, bounds: Dictionary) -> bool: + return ( + cell.z in [LAYER_FRONT, LAYER_BACK] + and cell.x >= int(bounds.get(&"min_x", 0)) + and cell.x <= int(bounds.get(&"max_x", -1)) + and cell.y >= int(bounds.get(&"min_y", 0)) + and cell.y <= int(bounds.get(&"max_y", -1)) + ) + + +static func _is_spatial_in_bounds(position: Vector2i, bounds: Dictionary) -> bool: + return ( + position.x >= int(bounds.get(&"min_x", 0)) + and position.x <= int(bounds.get(&"max_x", -1)) + and position.y >= int(bounds.get(&"min_y", 0)) + and position.y <= int(bounds.get(&"max_y", -1)) + ) + + +static func _cell_less(left: Vector3i, right: Vector3i) -> bool: + if left.y != right.y: + return left.y < right.y + if left.x != right.x: + return left.x < right.x + return left.z < right.z + + +static func _spatial_less(left: Vector2i, right: Vector2i) -> bool: + if left.y != right.y: + return left.y < right.y + return left.x < right.x + + +static func _add_error(errors: Array[StringName], error_code: StringName) -> void: + if not errors.has(error_code): + errors.append(error_code) diff --git a/scripts/world/world_service.gd.uid b/scripts/world/world_service.gd.uid new file mode 100644 index 0000000..1973863 --- /dev/null +++ b/scripts/world/world_service.gd.uid @@ -0,0 +1 @@ +uid://cpv4h0rgdjl2t diff --git a/tests/fixtures/facilities/invalid_service_profile.tres b/tests/fixtures/facilities/invalid_service_profile.tres new file mode 100644 index 0000000..9e7f303 --- /dev/null +++ b/tests/fixtures/facilities/invalid_service_profile.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +definition_schema_version = 2 +id = &"invalid_service_profile" +display_name = "不正サービス試験施設" +description = "詳細サービス値の拒否を確認する試験用定義。" +category = &"test_facility" +service_types = Array[StringName]([&"test_service"]) +default_service_type = &"test_service" +service_profiles = {&"test_service": {&"duration_minutes": -1, &"base_quality": 50, &"base_price": 0, &"capacity_units": 1, &"need_effects": {&"sleep": 0}}} +footprint = Vector2i(1, 1) +guest_access_offsets = Array[Vector2i]([Vector2i(0, 0)]) +max_level = 1 +upgrade_costs = Array[Dictionary]([]) +capacity = 1 +queue_capacity = 0 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +opening_schedule = {&"morning": true, &"day": true, &"night": true} +statistics_keys = Array[StringName]([&"services_completed"]) +tags = Array[StringName]([&"test_facility"]) diff --git a/tests/mvp/test_mvp_content_catalog.gd b/tests/mvp/test_mvp_content_catalog.gd new file mode 100644 index 0000000..8334b32 --- /dev/null +++ b/tests/mvp/test_mvp_content_catalog.gd @@ -0,0 +1,342 @@ +extends SceneTree + +const CatalogType := preload("res://scripts/data/mvp_content_catalog.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_baseline_catalog() + _test_guest_combinations() + _test_six_need_schema() + _test_staff_hire_boundary() + _test_unique_pair_and_defensive_copies() + _test_invalid_content_is_fail_closed() + _test_type_coercion_is_rejected() + _test_fixed_ids_namespaces_and_resource_paths() + _test_unique_character_safety_contract() + _test_numeric_and_container_limits() + _test_json_key_collisions() + _test_error_order_is_deterministic() + if _failures == 0: + print("PASS: MvpContentCatalog (%d checks)" % _checks) + else: + push_error("FAIL: MvpContentCatalog (%d of %d checks failed)" % [_failures, _checks]) + quit(_failures) + + +func _test_baseline_catalog() -> void: + var catalog := CatalogType.new() + var report: Dictionary = catalog.rebuild() + _expect(bool(report[&"valid"]), "MVP character catalog passes strict schema validation") + _expect(int(report[&"error_count"]) == 0, "baseline character data has no validation errors") + _expect(int(report[&"counts"]["guest_classes"]) == 8, "all eight guest classes are indexed") + _expect(int(report[&"counts"]["species"]) == 4, "all four independent species are indexed") + _expect(int(report[&"counts"]["staff_candidates"]) == 20, "all twenty free-hire candidates are indexed") + _expect(int(report[&"counts"]["unique_characters"]) == 2, "one heroine and one rival are indexed") + _expect(catalog.get_guest_class_ids() == [ + &"cleric", &"diplomat", &"mage", &"merchant", &"noble", &"outsider", &"rogue", &"warrior", + ], "guest classes use stable lexical order independent of JSON order") + _expect(catalog.get_species_ids() == [ + &"beastfolk", &"demonfolk", &"elf", &"human", + ], "species use stable lexical order") + _expect(catalog.get_staff_candidate_ids().front() == &"staff_akane", "candidate IDs are stable and namespaced") + _expect(catalog.get_staff_candidate_ids().back() == &"staff_tessa", "all candidate IDs participate in deterministic order") + + +func _test_guest_combinations() -> void: + var catalog := CatalogType.new() + catalog.rebuild() + var definitions := catalog.build_all_guest_definitions() + _expect(definitions.size() == 32, "eight classes and four species produce 32 independent archetypes") + var ids: Array[StringName] = [] + for definition in definitions: + ids.append(definition.guest_type_id) + _expect(definition.is_valid(), "generated archetype %s satisfies GuestDefinition invariants" % definition.guest_type_id) + _expect(definition.adult_verified, "generated archetype %s is adult-content gated" % definition.guest_type_id) + var unique_ids: Dictionary = {} + for definition_id: StringName in ids: + unique_ids[definition_id] = true + _expect(unique_ids.size() == 32 and not unique_ids.has(&""), "all generated archetype IDs are non-empty and unique") + var warrior_elf = catalog.build_guest_definition(&"warrior", &"elf") + _expect(warrior_elf != null and warrior_elf.guest_type_id == &"warrior_elf", "class and species IDs form a reproducible archetype ID") + _expect(warrior_elf.wealth_min == 330 and warrior_elf.wealth_max == 990, "species wealth multiplier uses integer basis points") + _expect(warrior_elf.base_patience == 70 and warrior_elf.risk_tolerance == 70, "species modifiers compose with class values once") + _expect(&"combatant" in warrior_elf.tags and &"elf" in warrior_elf.tags, "class and species tags remain separate and both survive composition") + _expect(warrior_elf.preference_weights[&"quiet"] == 8, "species preferences extend class preferences") + _expect(warrior_elf.need_rates[&"sleep"] == 5, "species Need modifiers compose on the canonical Sleep axis") + _expect(catalog.build_guest_definition(&"missing", &"human") == null, "unknown class fails closed") + _expect(catalog.build_guest_definition(&"warrior", &"missing") == null, "unknown species fails closed") + + +func _test_six_need_schema() -> void: + var catalog := CatalogType.new() + catalog.rebuild() + var definition = catalog.build_guest_definition(&"diplomat", &"demonfolk") + var need_ids: Array = definition.need_rates.keys() + need_ids.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) + _expect(need_ids == [&"hunger", &"hygiene", &"intimacy", &"leisure", &"medical", &"sleep"], "generated definitions expose exactly the canonical six Need IDs") + _expect(not definition.need_rates.has(&"safety") and not definition.need_rates.has(&"social"), "Safety and social context are not persisted as Needs") + _expect(definition.need_rates[&"leisure"] == 5, "species Leisure modifier composes without being misread as Medical") + + var documents := _load_documents() + documents[&"guest_classes"]["entries"][0]["need_rates"].erase("hygiene") + documents[&"guest_classes"]["entries"][0]["need_rates"]["safety"] = 1 + var report := catalog.rebuild_from_documents(documents) + _expect(&"unknown_map_key" in _issue_codes(report[&"errors"]), "legacy Need names are rejected by the current content schema") + documents = _load_documents() + documents[&"guest_classes"]["entries"][0]["need_rates"].erase("hygiene") + report = catalog.rebuild_from_documents(documents) + _expect(&"missing_map_key" in _issue_codes(report[&"errors"]), "all six class Need rates are required") + documents = _load_documents() + documents[&"species"]["entries"][0]["need_rate_modifiers"]["rest"] = -1 + report = catalog.rebuild_from_documents(documents) + _expect(&"unknown_map_key" in _issue_codes(report[&"errors"]), "species modifiers cannot reintroduce a migration-only alias") + + +func _test_staff_hire_boundary() -> void: + var catalog := CatalogType.new() + catalog.rebuild() + for candidate_id: StringName in catalog.get_staff_candidate_ids(): + var candidate := catalog.get_entry(&"staff_candidates", candidate_id) + _expect(bool(candidate[&"adult_verified"]) and int(candidate[&"age"]) >= 18, "%s carries explicit adult verification" % candidate_id) + _expect(int(candidate[&"daily_wage"]) >= 0 and int(candidate[&"signing_cost"]) >= 0, "%s has non-negative employment costs" % candidate_id) + _expect((candidate[&"skills"] as Dictionary).size() == 4, "%s defines all four dispatch skills" % candidate_id) + var payload := catalog.staff_hire_payload(&"staff_klaus") + _expect(payload[&"staff_id"] == &"staff_klaus" and payload[&"role_id"] == &"guard", "hire payload preserves candidate and role IDs") + _expect(payload[&"adult_verified"] and int(payload[&"age"]) == 36, "hire payload carries the adult gate across the runtime boundary") + _expect(payload[&"preferred_shift"] == &"day", "hire payload exposes the validated preferred shift to the scheduler adapter") + _expect(payload[&"options"][&"daily_wage"] == 155 and payload[&"options"][&"employment_kind"] == &"FREE_HIRE", "runtime options preserve wage liability and ordinary employment kind") + _expect(payload[&"options"][&"skills"][&"combat"] == 82, "hire payload maps static skills into StaffRuntime options") + _expect(&"security" in payload[&"options"][&"permitted_task_tags"], "hire payload preserves permitted task tags") + var runtime := StaffRuntime.new().configure(payload[&"staff_id"], payload[&"role_id"], payload[&"options"]) + _expect(runtime.is_valid() and runtime.daily_wage == 155 and runtime.get_employment_kind_name() == &"FREE_HIRE", "hire payload constructs an exact payroll-bearing StaffRuntime") + _expect(catalog.staff_hire_payload(&"missing").is_empty(), "unknown candidate cannot produce a hire payload") + + +func _test_unique_pair_and_defensive_copies() -> void: + var catalog := CatalogType.new() + catalog.rebuild() + var heroine := catalog.get_entry(&"unique_characters", &"heroine_anri") + var rival := catalog.get_entry(&"unique_characters", &"rival_rai") + _expect(heroine[&"character_kind"] == &"heroine" and rival[&"character_kind"] == &"rival", "unique characters have distinct mechanical kinds") + _expect(heroine[&"counterpart_id"] == &"rival_rai" and rival[&"counterpart_id"] == &"heroine_anri", "heroine and rival references are reciprocal") + _expect(heroine[&"adult_verified"] and rival[&"adult_verified"], "both relationship participants are explicitly adult-verified") + _expect(heroine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] == 9000, "heroine baseline effect is represented in integer basis points") + heroine[&"display_name"] = "tampered" + heroine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] = 1 + var pristine := catalog.get_entry(&"unique_characters", &"heroine_anri") + _expect(pristine[&"display_name"] == "アンリ", "returned definitions cannot mutate catalog identity") + _expect(pristine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] == 9000, "returned nested data is also a defensive copy") + + +func _test_invalid_content_is_fail_closed() -> void: + var documents := _load_documents() + var staff_entries: Array = documents[&"staff_candidates"]["entries"] + staff_entries[0]["adult_verified"] = false + staff_entries[1]["age"] = 17 + staff_entries[2]["traits"].append("unknown_trait") + staff_entries[3]["typo_field"] = 1 + var guest_entries: Array = documents[&"guest_classes"]["entries"] + guest_entries[1]["id"] = guest_entries[0]["id"] + var catalog := CatalogType.new() + var report: Dictionary = catalog.rebuild_from_documents(documents) + _expect(not bool(report[&"valid"]), "invalid character data rejects the whole catalog") + _expect(catalog.get_staff_candidate_ids().is_empty(), "fail-closed rebuild exposes no partially valid candidates") + _expect(catalog.build_all_guest_definitions().is_empty(), "fail-closed rebuild cannot generate partial archetypes") + var codes := _issue_codes(report[&"errors"]) + _expect(&"adult_verification_required" in codes, "missing adult verification has a stable error code") + _expect(&"adult_age_required" in codes, "underage data has a stable error code") + _expect(&"unknown_tag" in codes, "unknown trait is rejected instead of silently ignored") + _expect(&"unknown_field" in codes, "misspelled data fields are rejected") + _expect(&"duplicate_id" in codes, "duplicate stable IDs are rejected") + + +func _test_error_order_is_deterministic() -> void: + var documents := _load_documents() + documents[&"species"]["schema_version"] = 99 + documents[&"unique_characters"]["entries"][0]["counterpart_id"] = "missing_actor" + var catalog := CatalogType.new() + var first: Dictionary = catalog.rebuild_from_documents(documents) + var second: Dictionary = catalog.rebuild_from_documents(documents) + _expect(first[&"errors"] == second[&"errors"], "content error order is deterministic across rebuilds") + _expect(not bool(second[&"valid"]), "unsupported schema and orphan references remain fatal") + _expect(&"unsupported_schema" in _issue_codes(second[&"errors"]), "schema rejection has a stable code") + _expect(&"missing_counterpart" in _issue_codes(second[&"errors"]), "orphan counterpart has a stable code") + + +func _test_type_coercion_is_rejected() -> void: + var documents := _load_documents() + documents[&"species"]["schema_version"] = "1" + documents[&"guest_classes"]["entries"][0]["wealth_min"] = "300" + documents[&"staff_candidates"]["entries"][0]["adult_verified"] = "true" + documents[&"staff_candidates"]["entries"][1]["age"] = "38" + documents[&"staff_candidates"]["entries"][2]["daily_wage"] = "145" + documents[&"unique_characters"]["entries"][0]["character_kind"] = 1 + var catalog := CatalogType.new() + var report: Dictionary = catalog.rebuild_from_documents(documents) + var codes := _issue_codes(report[&"errors"]) + _expect(not bool(report[&"valid"]), "stringified booleans and numbers do not pass strict content validation") + _expect(&"unsupported_schema" in codes, "string schema version is not coerced") + _expect(&"invalid_wealth_range" in codes, "string wealth is not coerced") + _expect(&"adult_verification_required" in codes, "string adult flag is not truthy-coerced") + _expect(&"adult_age_required" in codes, "string age is not coerced") + _expect(&"negative_value" in codes, "string employment cost is not coerced") + _expect(&"unknown_character_kind" in codes, "numeric character kind is not coerced") + + +func _test_fixed_ids_namespaces_and_resource_paths() -> void: + var documents := _load_documents() + documents[&"guest_classes"]["entries"][0]["id"] = "replacement_class" + var catalog := CatalogType.new() + var report := catalog.rebuild_from_documents(documents) + _expect(&"mvp_id_set_mismatch" in _issue_codes(report[&"errors"]), "same-count replacement cannot silently change the fixed MVP ID set") + _expect(not bool(report[&"valid"]), "fixed-ID mismatch fails closed") + + documents = _load_documents() + documents[&"staff_candidates"]["entries"][0]["id"] = "akane" + report = catalog.rebuild_from_documents(documents) + _expect(&"invalid_namespace" in _issue_codes(report[&"errors"]), "free-hire actor IDs must retain the staff namespace") + + documents = _load_documents() + documents[&"species"]["entries"][0]["tags"] = ["elf"] + report = catalog.rebuild_from_documents(documents) + _expect(&"species_identity_mismatch" in _issue_codes(report[&"errors"]), "species identity cannot be changed by swapping its tag") + + var paths := CatalogType.DEFAULT_PATHS.duplicate(true) + paths[&"species"] = "res://data/characters/../characters/mvp_species.json" + report = catalog.rebuild(paths) + _expect(&"invalid_resource_path" in _issue_codes(report[&"errors"]), "resource traversal segments are rejected before file parsing") + _expect(catalog.get_species_ids().is_empty(), "invalid resource paths expose no stale prior catalog") + + paths = CatalogType.DEFAULT_PATHS.duplicate(true) + paths.erase(&"species") + report = catalog.rebuild(paths) + _expect(&"missing_content_type" in _issue_codes(report[&"errors"]), "custom path sets must name every required document") + + +func _test_unique_character_safety_contract() -> void: + var documents := _load_documents() + var heroine: Dictionary = documents[&"unique_characters"]["entries"][0] + heroine["traits"].erase("unsellable") + var catalog := CatalogType.new() + var report := catalog.rebuild_from_documents(documents) + _expect(&"unique_safety_traits_required" in _issue_codes(report[&"errors"]), "unique actors cannot lose the unsellable safety marker") + + documents = _load_documents() + documents[&"staff_candidates"]["entries"][0]["traits"].append("unique") + report = catalog.rebuild_from_documents(documents) + _expect(&"reserved_trait" in _issue_codes(report[&"errors"]), "free-hire candidates cannot impersonate unique actors") + + documents = _load_documents() + heroine = documents[&"unique_characters"]["entries"][0] + heroine["mechanical_effects"]["arbitrary_runtime_hook"] = 1 + report = catalog.rebuild_from_documents(documents) + var codes := _issue_codes(report[&"errors"]) + _expect(&"invalid_mechanical_effect" in codes, "unknown mechanical effect keys are rejected") + _expect(&"effect_kind_mismatch" in codes, "effects cannot cross the heroine/rival capability boundary") + + documents = _load_documents() + documents[&"unique_characters"]["entries"][0]["mechanical_effects"]["assigned_floor_patience_loss_multiplier_bp"] = 2_000 + report = catalog.rebuild_from_documents(documents) + _expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "basis-point effects below the global multiplier floor are rejected") + + documents = _load_documents() + documents[&"unique_characters"]["entries"][1]["mechanical_effects"]["base_shield_attack"] = 101 + report = catalog.rebuild_from_documents(documents) + _expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "relationship attack data cannot exceed its bounded runtime scale") + + +func _test_numeric_and_container_limits() -> void: + var documents := _load_documents() + documents[&"staff_candidates"]["entries"][0]["age"] = CatalogType.MAX_AGE + 1 + var catalog := CatalogType.new() + var report := catalog.rebuild_from_documents(documents) + _expect(&"adult_age_required" in _issue_codes(report[&"errors"]), "implausibly large ages do not enter persistent actor data") + + documents = _load_documents() + documents[&"guest_classes"]["entries"][0]["wealth_max"] = CatalogType.MAX_GOLD_VALUE + 1 + report = catalog.rebuild_from_documents(documents) + _expect(&"invalid_wealth_range" in _issue_codes(report[&"errors"]), "money values are bounded before basis-point multiplication") + + documents = _load_documents() + documents[&"staff_candidates"]["entries"][0]["daily_wage"] = INF + report = catalog.rebuild_from_documents(documents) + _expect(&"negative_value" in _issue_codes(report[&"errors"]), "non-finite numeric input is never accepted as an integer") + + documents = _load_documents() + documents[&"staff_candidates"]["entries"][0]["display_name"] = "unsafe\nname" + report = catalog.rebuild_from_documents(documents) + _expect(&"missing_display_name" in _issue_codes(report[&"errors"]), "control characters are rejected from player-visible names") + + documents = _load_documents() + for raw_document: Variant in documents.values(): + for raw_entry: Variant in raw_document["entries"]: + for index in 40: + raw_entry["unexpected_%02d" % index] = index + report = catalog.rebuild_from_documents(documents) + _expect(int(report[&"error_count"]) == CatalogType.MAX_ERRORS, "adversarial documents cannot amplify validation reports beyond the error cap") + _expect(bool(report[&"errors_truncated"]), "capped validation reports explicitly disclose truncation") + _expect(not bool(report[&"valid"]), "error-report bounding never weakens fail-closed validation") + + +func _test_json_key_collisions() -> void: + var catalog := CatalogType.new() + _expect( + catalog._validate_json_key_structure("{\"id\":1,\"id\":2}") == &"duplicate_json_key", + "duplicate JSON object keys are rejected before the parser can overwrite one value" + ) + _expect( + catalog._validate_json_key_structure("{\"id\":1,\"\\u0069d\":2}") == &"duplicate_json_key", + "escaped and literal forms of the same JSON key collide canonically" + ) + _expect( + catalog._validate_json_key_structure("{\"outer\":{\"age\":1,\"age\":2}}") == &"duplicate_json_key", + "duplicate-key detection covers nested content maps" + ) + _expect( + catalog._validate_json_key_structure("[".repeat(CatalogType.MAX_JSON_DEPTH + 1) + "0" + "]".repeat(CatalogType.MAX_JSON_DEPTH + 1)) == &"json_structure_limit_exceeded", + "deeply nested JSON is rejected before recursive validation can exhaust the stack" + ) + for path: String in CatalogType.DEFAULT_PATHS.values(): + var file := FileAccess.open(path, FileAccess.READ) + _expect(catalog._validate_json_key_structure(file.get_as_text()).is_empty(), "%s has a unique bounded JSON key structure" % path.get_file()) + + var documents := _load_documents() + documents[&"species"]["entries"][0][42] = "not_a_field_name" + var report := catalog.rebuild_from_documents(documents) + _expect(&"invalid_key_type" in _issue_codes(report[&"errors"]), "non-string object keys are rejected without coercion") + + +func _load_documents() -> Dictionary: + var result: Dictionary = {} + for content_type: StringName in [&"guest_classes", &"species", &"staff_candidates", &"unique_characters"]: + var path := String(CatalogType.DEFAULT_PATHS[content_type]) + var file := FileAccess.open(path, FileAccess.READ) + result[content_type] = JSON.parse_string(file.get_as_text()) + return result + + +func _issue_codes(raw_issues: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if raw_issues is Array: + for raw_issue: Variant in raw_issues: + if raw_issue is Dictionary: + var code := StringName(raw_issue.get(&"code", raw_issue.get("code", ""))) + if not result.has(code): + result.append(code) + return result + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/mvp/test_mvp_content_catalog.gd.uid b/tests/mvp/test_mvp_content_catalog.gd.uid new file mode 100644 index 0000000..c8e6c88 --- /dev/null +++ b/tests/mvp/test_mvp_content_catalog.gd.uid @@ -0,0 +1 @@ +uid://ce2yaa87mm67b diff --git a/tests/mvp/test_operations_coordinator.gd b/tests/mvp/test_operations_coordinator.gd new file mode 100644 index 0000000..142c94d --- /dev/null +++ b/tests/mvp/test_operations_coordinator.gd @@ -0,0 +1,1054 @@ +extends SceneTree + +const OperationsCoordinatorType = preload("res://scripts/operations/operations_coordinator.gd") + +var _checks := 0 +var _failures := 0 +var _callback_services: Array[RefCounted] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("MVP: deterministic 14-day operations coordinator") + _test_demand_schema_formula_and_clamp() + _test_boundary_order_leap_and_replay() + _test_fixed_point_arrivals_capacity_and_caps() + _test_arrival_failure_rolls_back_and_retries_same_ids() + _test_daily_close_summary_order_and_rolling_average() + _test_daily_close_failure_and_ledger_imbalance_pause() + _test_arrears_clear_and_three_close_bankruptcy() + _test_morning_boundary_payload_and_failure_retry() + _test_snapshot_round_trip_migration_and_atomic_rejection() + _test_fourteen_day_digest_and_boundary_counts() + _test_external_rollback_quarantine_and_failure_paths() + _test_origin_coverage_limits_and_legacy_population() + _test_fair_arrivals_seed_and_id_high_water() + _test_snapshot_canonical_safety_and_derived_crosschecks() + _test_signal_reentrancy_and_deferred_pause() + for service: RefCounted in _callback_services: + if service != null and service.has_method("configure_callbacks"): + service.call("configure_callbacks") + _callback_services.clear() + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_demand_schema_formula_and_clamp() -> void: + var service := OperationsCoordinatorType.new() + var profile := service.set_demand_input( + &"food", + &"warrior", + &"DAY", + _components(50, 20, 30, 10, 5, 15) + ) + _expect(profile.is_success(), "valid facility-tag × guest-class × time-band demand input is accepted") + _expect(profile.details[&"demand_index"] == 90, "DemandIndex uses the specified additive and subtractive formula") + _expect(service.get_demand_index(&"food", &"warrior", &"DAY") == 90, "DemandIndex is retained by its full three-axis identity") + var detached := service.get_demand_profile(&"food", &"warrior", &"DAY") + detached[&"demand_index"] = 999 + _expect(service.get_demand_index(&"food", &"warrior", &"DAY") == 90, "read API cannot mutate the stored demand profile") + + var high := service.set_demand_input(&"lodging", &"noble", &"NIGHT", _components(190, 50, 30, 20, 0, 0)) + var low := service.set_demand_input(&"bath", &"merchant", &"EVENING", _components(0, -100, 0, 0, 50, 50)) + _expect(high.is_success() and high.details[&"demand_index"] == 200, "DemandIndex clamps high results to 200") + _expect(low.is_success() and low.details[&"demand_index"] == 0, "DemandIndex clamps low results to zero") + var fractional := service.set_demand_input(&"tavern", &"mage", &"NIGHT", _components(40.4, 4.6, 0.0, 0.0, 0.0, 0.0)) + _expect(fractional.is_success() and fractional.details[&"demand_index"] == 45, "finite fractional inputs normalize once to deterministic integers") + + var before := service.create_snapshot() + var missing := _components(10, 0, 0, 0, 0, 0) + missing.erase(&"wait_penalty") + _expect(not service.set_demand_input(&"x", &"x", &"DAY", missing).is_success(), "missing demand component is rejected") + var extra := _components(10, 0, 0, 0, 0, 0) + extra[&"mystery"] = 1 + _expect(not service.set_demand_input(&"x", &"x", &"DAY", extra).is_success(), "unknown demand component is rejected") + _expect(not service.set_demand_input(&"x", &"x", &"DAY", _components(-1, 0, 0, 0, 0, 0)).is_success(), "unsigned demand inputs cannot be negative") + _expect(not service.set_demand_input(&"x", &"x", &"DUSK", _components(1, 0, 0, 0, 0, 0)).is_success(), "unknown time band is rejected") + _expect(not service.set_demand_input(&"x", &"x", &"DAY", _components(NAN, 0, 0, 0, 0, 0)).is_success(), "NaN demand component is rejected") + _expect(not service.set_demand_input(&"x", &"x", &"DAY", _components(INF, 0, 0, 0, 0, 0)).is_success(), "infinite demand component is rejected") + _expect(service.create_snapshot() == before, "all rejected demand schemas preserve coordinator state atomically") + + var invalid_config := OperationsCoordinatorType.new() + _expect(not invalid_config.configure({&"arrival_rate_denominator": 0}).is_success(), "zero fixed-point denominator is rejected") + _expect(not invalid_config.configure({&"unknown_tuning": 1}).is_success(), "unknown tuning key is rejected instead of silently fixing game balance") + _expect(service.validate_invariants()[&"ok"], "valid demand profiles satisfy coordinator invariants") + + +func _test_boundary_order_leap_and_replay() -> void: + var order: Array[String] = [] + var service := OperationsCoordinatorType.new({ + &"population_cap": 100, + &"max_arrivals_per_interval": 5, + }) + service.set_demand_input(&"food", &"warrior", &"ANY", _components(100, 0, 0, 0, 0, 0)) + _track_callbacks(service) + service.configure_callbacks( + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + order.append("daily:%d:capture" % int(payload[&"boundary_minute"])) + return {&"ok": true, &"token": token} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return _daily_result(100, 20, 0) + return {&"ok": true}, + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token, &"capacity_remaining": 100} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return {&"ok": true, &"accepted_count": int(payload[&"total_count"])} + return {&"ok": true}, + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + order.append("morning:%d:capture" % int(payload[&"boundary_minute"])) + return {&"ok": true, &"token": token} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return {&"ok": true, &"event_payload": {&"kind": &"SHIFT_READY"}} + return {&"ok": true} + ) + var result := service.advance_to(1_800) + _expect(result.is_success() and service.current_minute == 1_800, "one leap advances through every crossed operating boundary") + _expect(result.details[&"boundary_counts"][&"daily_closes"] == 1, "leap performs the crossed 06:00 operating-day close exactly once") + _expect(result.details[&"boundary_counts"][&"morning_boundaries"] == 2, "leap performs both crossed 06:00 morning boundaries") + _expect(result.details[&"boundary_counts"][&"demand_updates"] == 30, "leap never thins 60-minute demand updates") + _expect(result.details[&"boundary_counts"][&"arrival_boundaries"] == 60, "leap never thins 30-minute arrival planning") + _expect(order == ["morning:360:capture", "daily:1800:capture", "morning:1800:capture"], "06:00 closes the prior operating day before morning work") + _expect(service.get_day_summaries().size() == 1 and service.get_morning_report(1)[&"minute"] == 360, "the first morning opens Day 1 and the next 06:00 closes it") + var digest_before := service.deterministic_digest() + var replay := service.advance_to(1_800) + _expect(replay.is_success() and replay.is_replay, "same-time advance is an explicit replay success") + _expect(service.deterministic_digest() == digest_before, "same-time replay has no side effects") + var rewind := service.advance_to(1_799) + _expect(not rewind.is_success() and rewind.details[&"code"] == &"TIME_REWIND_REJECTED", "time rewind is rejected explicitly") + _expect(service.current_minute == 1_800 and service.deterministic_digest() == digest_before, "rejected rewind preserves exact state") + _expect(not service.configure({&"world_seed": 7}).is_success(), "tuning is locked after the first committed boundary") + + +func _test_fixed_point_arrivals_capacity_and_caps() -> void: + var service := OperationsCoordinatorType.new({ + &"population_cap": 5, + &"max_arrivals_per_interval": 2, + &"arrival_rate_numerator": 1, + &"arrival_rate_denominator": 100, + }) + service.set_demand_input(&"a_food", &"warrior", &"ANY", _components(75, 0, 0, 0, 0, 0)) + service.set_demand_input(&"b_bath", &"mage", &"ANY", _components(200, 0, 0, 0, 0, 0)) + var first := service.advance_to(30) + var report30 := service.get_arrival_reports()[0] + _expect(first.is_success() and report30[&"total_count"] == 2, "per-interval arrival cap limits a high combined demand batch") + _expect(report30[&"plans"].size() == 1 and report30[&"plans"][0][&"count"] == 2, "sorted demand profiles allocate the capped batch deterministically") + _expect(report30[&"plans"][0][&"guest_ids"][0] == "guest-auto-00000001", "local monotonic guest ID generation starts at a stable value") + _expect(service.get_arrival_carry(&"a_food", &"warrior", &"ANY") == 75, "sub-unit demand is preserved as fixed-point carry") + _expect(service.get_arrival_carry(&"b_bath", &"mage", &"ANY") == 0, "accepted whole arrivals are removed from carry exactly") + service.set_population_count(0) + service.advance_to(60) + var report60 := service.get_arrival_reports()[1] + _expect(report60[&"total_count"] == 2, "next 30-minute boundary evaluates arrivals without thinning") + _expect(report60[&"plans"][0][&"count"] == 1, "fractional 75 + 75 carry becomes one whole arrival") + _expect(service.get_arrival_carry(&"a_food", &"warrior", &"ANY") == 50, "fixed-point remainder survives after generating a whole arrival") + _expect(report60[&"plans"][-1][&"guest_ids"][-1] == "guest-auto-00000004", "guest sequences remain contiguous across boundary batches") + + service.set_population_count(5) + service.advance_to(90) + var blocked := service.get_arrival_reports()[2] + _expect(blocked[&"total_count"] == 0 and blocked[&"plans"].is_empty(), "population cap produces no arrival plan when capacity is zero") + _expect(service.get_arrival_carry(&"b_bath", &"mage", &"ANY") == 300, "capacity-blocked whole demand remains in carry for a later interval") + service.set_population_count(4) + service.advance_to(120) + var one_slot := service.get_arrival_reports()[3] + _expect(one_slot[&"total_count"] == 1 and one_slot[&"population_after"] == 5, "remaining capacity limits a batch below the interval cap") + _expect(one_slot[&"plans"][0][&"plan_id"] == "arrival-plan-00000004", "plan sequence advances only for committed non-empty plans") + _expect(service.validate_invariants()[&"ok"], "capacity and carry histories retain valid invariants") + + +func _test_arrival_failure_rolls_back_and_retries_same_ids() -> void: + var external := {&"population": 0, &"apply_calls": 0, &"rollbacks": 0, &"seen_ids": []} + var phases: Array[StringName] = [] + var service := OperationsCoordinatorType.new({ + &"population_cap": 10, + &"max_arrivals_per_interval": 5, + }) + service.set_demand_input(&"food", &"warrior", &"ANY", _components(100, 0, 0, 0, 0, 0)) + _track_callbacks(service) + service.configure_callbacks( + Callable(), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + phases.append(phase) + if phase == &"capture": + return {&"ok": true, &"token": int(external[&"population"]), &"capacity_remaining": 10} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + external[&"apply_calls"] = int(external[&"apply_calls"]) + 1 + external[&"population"] = int(external[&"population"]) + int(payload[&"total_count"]) + external[&"seen_ids"] = (payload[&"plans"] as Array).duplicate(true) + if int(external[&"apply_calls"]) == 1: + return {&"ok": false, &"reason": &"GUEST_DOMAIN_REJECTED"} + return {&"ok": true, &"accepted_count": int(payload[&"total_count"])} + if phase == &"rollback": + external[&"population"] = int(token) + external[&"rollbacks"] = int(external[&"rollbacks"]) + 1 + return {&"ok": true} + return {&"ok": false} + ) + var failed := service.advance_to(30) + var first_ids: Array = external[&"seen_ids"] + _expect(not failed.is_success() and service.current_minute == 29, "failed arrival boundary stops immediately before the retry minute") + _expect(external[&"population"] == 0 and external[&"rollbacks"] == 1, "failed external apply is rolled back to its capture token") + _expect(service.get_arrival_reports().is_empty() and service.get_population_count() == 0, "failed batch commits no coordinator arrival state") + _expect(service.get_arrival_carry(&"food", &"warrior", &"ANY") == 0, "failed batch consumes no fixed-point carry") + _expect(phases == [&"capture", &"validate", &"apply", &"rollback"], "arrival transaction uses capture, validate, apply, rollback order") + phases.clear() + var retried := service.advance_to(30) + var retry_ids: Array = external[&"seen_ids"] + _expect(retried.is_success() and service.current_minute == 30, "same failed boundary can be retried successfully") + _expect(first_ids == retry_ids and retry_ids[0][&"guest_ids"][0] == "guest-auto-00000001", "rollback preserves deterministic IDs for retry") + _expect(external[&"population"] == 1 and service.get_population_count() == 1, "retry commits external and coordinator population exactly once") + _expect(phases == [&"capture", &"validate", &"apply"], "successful retry omits rollback") + var digest := service.deterministic_digest() + service.advance_to(30) + _expect(service.deterministic_digest() == digest and int(external[&"apply_calls"]) == 2, "same-minute replay cannot apply arrivals twice") + + var partial := OperationsCoordinatorType.new({ + &"population_cap": 10, + &"max_arrivals_per_interval": 5, + }) + partial.set_demand_input(&"food", &"warrior", &"ANY", _components(100, 0, 0, 0, 0, 0)) + _track_callbacks(partial) + partial.configure_callbacks( + Callable(), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token, &"capacity_remaining": 10} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply" and int(payload[&"minute"]) == 60: + return {&"ok": false, &"reason": &"FAIL_AFTER_DEMAND"} + if phase == &"apply": + return {&"ok": true, &"accepted_count": int(payload[&"total_count"])} + return {&"ok": true} + ) + var partial_failure := partial.advance_to(60) + _expect(not partial_failure.is_success() and partial.current_minute == 59, "failure after a same-minute demand commit leaves minute 60 pending") + _expect(partial.get_demand_history().size() == 1 and partial.get_arrival_reports().size() == 1, "earlier same-minute subsystem remains exactly-once while failed arrival stays absent") + _expect(partial.validate_invariants()[&"ok"], "partially committed boundary has a valid persisted pending marker") + var pending_snapshot := partial.create_snapshot() + var pending_restored := OperationsCoordinatorType.new() + _expect(pending_restored.restore_snapshot(pending_snapshot), "snapshot can restore between same-minute subsystem commits") + _track_callbacks(pending_restored) + pending_restored.configure_callbacks( + Callable(), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token, &"capacity_remaining": 10} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return {&"ok": true, &"accepted_count": int(payload[&"total_count"])} + return {&"ok": true} + ) + _expect(pending_restored.advance_to(60).is_success(), "restored pending boundary retries only its unfinished subsystem") + _expect(pending_restored.get_demand_history().size() == 1 and pending_restored.get_arrival_reports().size() == 2, "retry does not duplicate the already committed hourly demand update") + _expect(pending_restored.validate_invariants()[&"ok"], "successful pending-boundary retry clears its marker and validates") + + +func _test_daily_close_summary_order_and_rolling_average() -> void: + var phases_by_day: Dictionary = {} + var service := OperationsCoordinatorType.new() + _track_callbacks(service) + service.configure_callbacks( + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + var day := int(payload[&"day"]) + if not phases_by_day.has(day): + phases_by_day[day] = [] + (phases_by_day[day] as Array).append(phase) + if phase == &"capture": + return {&"ok": true, &"token": {&"day": day}} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return _daily_result(day * 100, day * 10, 0, { + StringName("fac-%02d" % day): { + &"revenue": day * 100, + &"expenses": day * 10, + &"net_profit": day * 90, + }, + }) + return {&"ok": true} + ) + var result := service.advance_to(8 * 1_440 + 360) + _expect(result.is_success() and service.get_day_summaries().size() == 8, "eight crossed 06:00 boundaries create exactly eight DaySummaries") + _expect(phases_by_day[1] == [&"capture", &"validate", &"apply"], "daily close callback uses fixed capture/validate/apply order") + var day1 := service.get_day_summary(1) + _expect(day1[&"net_profit"] == 90 and day1[&"previous_day_delta"] == 0, "Day 1 classified revenue and expense derive net profit with a zero baseline delta") + _expect(day1[&"category_totals"][&"revenue"] == 100 and day1[&"category_totals"][&"cost_of_goods"] == 10, "DaySummary retains classified income and expense totals") + _expect(day1[&"facility_profit_loss"][&"fac-01"][&"net_profit"] == 90, "DaySummary retains reconciled facility-level profit and loss") + _expect(day1[&"period_start_minute"] == 360 and day1[&"period_end_minute_exclusive"] == 1_800, "financial period is exactly the prior 06:00-to-06:00 operating day") + var day2 := service.get_day_summary(2) + _expect(day2[&"net_profit"] == 180 and day2[&"previous_day_delta"] == 90, "previous-day delta compares adjacent closed days") + _expect(day2[&"seven_day_average"] == 135, "moving average uses all available days before seven closes") + var day8 := service.get_day_summary(8) + _expect(day8[&"seven_day_average_detail"][&"count"] == 7, "moving average window is capped to seven days") + _expect(day8[&"seven_day_average"] == 450, "Day 8 moving average contains Days 2 through 8 only") + _expect(day8[&"seven_day_average_detail"][&"sum"] == 3_150, "moving average stores exact integer sum for auditability") + _expect(service.get_arrears_total() == 0 and service.get_consecutive_insolvency_count() == 0, "solvent closes keep arrears and warning count at zero") + _expect(service.can_start_construction_or_investment()[&"allowed"], "solvent running operation permits construction and investment") + _expect(service.validate_invariants()[&"ok"], "multi-day financial summaries reconcile against their history") + + +func _test_daily_close_failure_and_ledger_imbalance_pause() -> void: + var external := {&"posted": 0, &"apply_calls": 0, &"rollbacks": 0} + var service := OperationsCoordinatorType.new() + _track_callbacks(service) + service.configure_callbacks( + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": int(external[&"posted"])} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + external[&"apply_calls"] = int(external[&"apply_calls"]) + 1 + external[&"posted"] = int(external[&"posted"]) + 100 + if int(external[&"apply_calls"]) == 1: + return {&"ok": false, &"reason": &"LEDGER_WRITE_FAILED"} + return _daily_result(200, 50, 0, {}, 7) + if phase == &"rollback": + external[&"posted"] = int(token) + external[&"rollbacks"] = int(external[&"rollbacks"]) + 1 + return {&"ok": true} + return {&"ok": false} + ) + var failed := service.advance_to(1_800) + _expect(not failed.is_success() and service.current_minute == 1_799, "failed daily close leaves its 06:00 boundary pending for exact retry") + _expect(external[&"posted"] == 0 and external[&"rollbacks"] == 1, "partial external daily application rolls back to captured state") + _expect(service.get_day_summaries().is_empty(), "failed daily transaction creates no DaySummary or insolvency state") + var retried := service.advance_to(1_860) + _expect(retried.is_success() and service.get_day_summaries().size() == 1, "retry commits one and only one daily summary") + _expect(service.current_minute == 1_800, "imbalance pause stops a larger requested leap at the closing boundary") + _expect(external[&"posted"] == 100 and int(external[&"apply_calls"]) == 2, "daily retry applies the external transaction once after rollback") + var summary := service.get_day_summary(1) + _expect(summary[&"reconciliation_difference"] == 7 and not summary[&"balanced"], "non-zero ledger difference is preserved instead of corrected") + _expect(service.pause_requested, "ledger imbalance raises a simulation pause request") + var alerts := service.get_alerts() + _expect(alerts.size() == 1 and alerts[0][&"code"] == &"LEDGER_IMBALANCE", "ledger imbalance emits one structured alert") + _expect(not alerts[0][&"automatic_correction"], "imbalance alert proves no balancing entry was invented") + _expect(not service.advance_to(1_801).is_success(), "pause request blocks further automatic advance") + _expect(service.acknowledge_pause().is_success() and not service.pause_requested, "caller can explicitly acknowledge a non-terminal diagnostic pause") + _expect(service.advance_to(1_801).is_success(), "advance resumes only after explicit acknowledgement") + + var malformed_external := {&"value": 0, &"rollback": 0} + var malformed := OperationsCoordinatorType.new() + _track_callbacks(malformed) + malformed.configure_callbacks( + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": int(malformed_external[&"value"])} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + malformed_external[&"value"] = 99 + return {&"ok": true, &"category_totals": {}, &"reconciliation_difference": 0} + if phase == &"rollback": + malformed_external[&"value"] = int(token) + malformed_external[&"rollback"] = 1 + return {&"ok": true} + return {&"ok": false} + ) + _expect(not malformed.advance_to(1_800).is_success(), "malformed successful-looking daily result is rejected") + _expect(malformed_external[&"value"] == 0 and malformed_external[&"rollback"] == 1, "post-apply schema failure also rolls back external state") + _expect(malformed.get_day_summaries().is_empty(), "malformed result cannot partly commit coordinator state") + + +func _test_arrears_clear_and_three_close_bankruptcy() -> void: + var arrears_by_day := {1: 50, 2: 0, 3: 30, 4: 20, 5: 10} + var service := OperationsCoordinatorType.new() + _track_callbacks(service) + service.configure_callbacks( + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return _daily_result(100, 100, int(arrears_by_day[int(payload[&"day"])])) + return {&"ok": true} + ) + service.advance_to(1_800) + _expect(service.get_arrears_total() == 50 and service.get_consecutive_insolvency_count() == 1, "first unpaid close records arrears and one warning") + var blocked := service.can_start_construction_or_investment() + _expect(not blocked[&"allowed"] and blocked[&"reason"] == &"ARREARS_REMAIN", "arrears prohibit new construction and ordinary investment") + service.advance_to(3_240) + _expect(service.get_arrears_total() == 0 and service.get_consecutive_insolvency_count() == 0, "fully paid next close clears the consecutive warning count") + _expect(service.can_start_construction_or_investment()[&"allowed"], "cleared arrears restore construction/investment eligibility") + service.advance_to(4_680) + _expect(service.get_consecutive_insolvency_count() == 1, "new arrears after clearance begin a fresh consecutive sequence") + service.advance_to(6_120) + _expect(service.get_consecutive_insolvency_count() == 2 and not service.is_terminal(), "second consecutive unpaid close warns but remains playable") + var terminal_result := service.advance_to(7_560) + _expect(terminal_result.is_success() and service.get_consecutive_insolvency_count() == 3, "third consecutive unpaid close commits before entering terminal state") + _expect(service.is_terminal() and service.get_terminal_state() == &"BANKRUPT", "three consecutive insolvent closes enter BANKRUPT terminal state") + _expect(service.current_minute == 7_560, "terminal transition stops precisely at the closing boundary") + var terminal_query := service.can_start_construction_or_investment() + _expect(not terminal_query[&"allowed"] and terminal_query[&"reason"] == &"BANKRUPT", "bankrupt terminal always prohibits construction and investment") + _expect(not service.advance_to(7_561).is_success(), "terminal operation rejects subsequent time advance") + _expect(not service.acknowledge_pause().is_success(), "terminal state cannot be cleared through pause acknowledgement") + var warning_count := 0 + var bankrupt_count := 0 + for alert: Dictionary in service.get_alerts(): + warning_count += int(alert[&"code"] == &"ARREARS_REMAIN") + bankrupt_count += int(alert[&"code"] == &"BANKRUPT") + _expect(warning_count == 4, "warning increments only on closes where arrears remain") + _expect(bankrupt_count == 1, "bankruptcy emits exactly one terminal alert") + _expect(service.validate_invariants()[&"ok"], "cleared and consecutive arrears history validates end to end") + + +func _test_morning_boundary_payload_and_failure_retry() -> void: + var external := {&"ap": 0, &"attempt": 0, &"rollback": 0} + var service := OperationsCoordinatorType.new() + _track_callbacks(service) + service.configure_callbacks( + Callable(), + Callable(), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": int(external[&"ap"])} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + external[&"attempt"] = int(external[&"attempt"]) + 1 + external[&"ap"] = int(external[&"ap"]) + 2 + if int(external[&"attempt"]) == 1: + return {&"ok": false, &"reason": &"EVENT_LOCKED"} + return { + &"ok": true, + &"event_payload": { + &"event_id": &"morning.first_shift", + &"day": int(payload[&"day"]), + }, + } + if phase == &"rollback": + external[&"ap"] = int(token) + external[&"rollback"] = int(external[&"rollback"]) + 1 + return {&"ok": true} + return {&"ok": false} + ) + var failed := service.advance_to(360) + _expect(not failed.is_success() and service.current_minute == 359, "failed 06:00 work remains pending at the exact morning boundary") + _expect(external[&"ap"] == 0 and external[&"rollback"] == 1, "failed morning apply rolls AP/event effects back") + _expect(service.get_morning_report(1).is_empty(), "failed morning creates no persisted report") + var retried := service.advance_to(360) + _expect(retried.is_success() and external[&"ap"] == 2, "morning callback succeeds on retry exactly once") + var report := service.get_morning_report(1) + _expect(report[&"minute"] == 360 and report[&"event_payload"][&"event_id"] == &"morning.first_shift", "06:00 report returns and saves structured event payload") + var digest := service.deterministic_digest() + service.advance_to(360) + _expect(service.deterministic_digest() == digest and int(external[&"attempt"]) == 2, "same-time replay cannot restore AP or fire morning event twice") + _expect(service.get_day_summaries().is_empty(), "05:59→06:00 never re-runs financial close") + + var unsafe := OperationsCoordinatorType.new() + var unsafe_external := {&"rollback": 0} + _track_callbacks(unsafe) + unsafe.configure_callbacks( + Callable(), Callable(), + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return {&"ok": true, &"event_payload": {&"object": unsafe}} + if phase == &"rollback": + unsafe_external[&"rollback"] = 1 + return {&"ok": true} + return {&"ok": false} + ) + _expect(not unsafe.advance_to(360).is_success(), "Object in morning payload is rejected before persistence") + _expect(unsafe_external[&"rollback"] == 1 and unsafe.get_morning_report(1).is_empty(), "unsaveable morning payload triggers rollback and no commit") + + +func _test_snapshot_round_trip_migration_and_atomic_rejection() -> void: + var source := _deterministic_fixture(77) + source.advance_to(3_000) + var snapshot := source.create_snapshot() + var restored := OperationsCoordinatorType.new() + _expect(restored.restore_snapshot(snapshot), "valid rich operations snapshot restores") + _expect(restored.create_snapshot() == snapshot, "current-version operations snapshot round trip is exact") + _expect(restored.deterministic_digest() == source.deterministic_digest(), "snapshot round trip preserves deterministic digest") + _expect(restored.validate_invariants()[&"ok"], "restored demand, arrivals, mornings and summaries satisfy invariants") + + var before := restored.create_snapshot() + var corrupt_total := snapshot.duplicate(true) + corrupt_total[&"arrival_reports"][0][&"total_count"] = 999 + _expect(not restored.restore_snapshot(corrupt_total), "arrival count mismatch rejects a corrupt snapshot") + _expect(restored.create_snapshot() == before, "failed corrupt arrival restore is atomic") + var duplicate_profile := snapshot.duplicate(true) + duplicate_profile[&"demand_profiles"].append(duplicate_profile[&"demand_profiles"][0].duplicate(true)) + _expect(not restored.restore_snapshot(duplicate_profile), "duplicate demand identity rejects a corrupt snapshot") + _expect(restored.create_snapshot() == before, "duplicate-profile rejection preserves live state") + var future_time := snapshot.duplicate(true) + future_time[&"day_summaries"][0][&"closed_at"] = 9_999_999 + _expect(not restored.restore_snapshot(future_time), "DaySummary outside restored clock rejects a corrupt snapshot") + _expect(restored.create_snapshot() == before, "future-summary rejection is atomic") + var callable_payload := snapshot.duplicate(true) + callable_payload[&"morning_reports"][0][&"event_payload"][&"callable"] = func() -> void: pass + _expect(not restored.restore_snapshot(callable_payload), "Callable cannot enter a saved morning payload") + _expect(restored.create_snapshot() == before, "unsaveable Variant rejection preserves live state") + var future_version := snapshot.duplicate(true) + future_version[&"schema_version"] = 999 + _expect(not restored.restore_snapshot(future_version), "unknown future snapshot schema is rejected") + _expect(restored.create_snapshot() == before, "future-schema rejection is atomic") + + var legacy := snapshot.duplicate(true) + legacy[&"schema_version"] = 1 + legacy.erase(&"alerts") + legacy.erase(&"demand_history") + var migrated := OperationsCoordinatorType.new() + _expect(migrated.restore_snapshot(legacy), "version 1 operations snapshot migrates with conservative defaults") + _expect(migrated.create_snapshot()[&"schema_version"] == OperationsCoordinatorType.SNAPSHOT_VERSION, "legacy snapshot is re-emitted at the current schema version") + _expect(migrated.get_alerts().is_empty(), "missing legacy diagnostic alerts default to an empty list") + _expect(migrated.validate_invariants()[&"ok"], "migrated legacy state satisfies current invariants") + + +func _test_fourteen_day_digest_and_boundary_counts() -> void: + var left := _deterministic_fixture(9_001, 360) + var right := _deterministic_fixture(9_001, 360) + var left_result := left.advance_to(14 * 1_440 + 360) + var right_result := right.advance_to(14 * 1_440 + 360) + _expect(left_result.is_success() and right_result.is_success(), "two 14-day fixtures complete successfully") + _expect(left.get_day_summaries().size() == 14, "14-day slice closes every financial day exactly once") + _expect(left.get_morning_report(15)[&"minute"] == 14 * 1_440 + 360, "14-day slice processes the closing morning boundary through Day 15") + _expect(left.get_demand_history().size() == 336, "14 days contain every one of 336 hourly demand evaluations") + _expect(left.get_arrival_reports().size() == 672, "14 days contain every one of 672 half-hour arrival evaluations") + _expect(left.deterministic_digest() == right.deterministic_digest(), "same seed and inputs produce exact 14-day digest") + _expect(left.create_snapshot() == right.create_snapshot(), "same deterministic run produces an exact rich snapshot") + var different_seed := _deterministic_fixture(9_002, 360) + different_seed.advance_to(14 * 1_440 + 360) + _expect(left.deterministic_digest() != different_seed.deterministic_digest(), "world seed remains part of deterministic operating identity") + _expect(left.get_population_count() <= 100, "14-day automatic arrivals never exceed population cap") + var max_batch := 0 + for report: Dictionary in left.get_arrival_reports(): + max_batch = maxi(max_batch, int(report[&"total_count"])) + _expect(max_batch <= 10, "every 14-day arrival batch obeys the configured interval upper bound") + _expect(left.get_terminal_state() == &"RUNNING" and left.get_arrears_total() == 0, "solvent 14-day run remains non-terminal") + _expect(left.validate_invariants()[&"ok"], "complete 14-day history validates without thinning or duplicate boundaries") + + +func _test_external_rollback_quarantine_and_failure_paths() -> void: + var phases: Array[StringName] = [] + var external := {&"value": 0, &"apply_calls": 0, &"rollback_calls": 0} + var quarantined := OperationsCoordinatorType.new({ + &"population_cap": 10, + &"max_arrivals_per_interval": 1, + }) + quarantined.set_demand_input(&"food", &"warrior", &"ANY", _components(200, 0, 0, 0, 0, 0)) + _track_callbacks(quarantined) + quarantined.configure_callbacks( + Callable(), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + phases.append(phase) + if phase == &"capture": + return {&"ok": true, &"token": int(external[&"value"]), &"capacity_remaining": 10} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + external[&"apply_calls"] = int(external[&"apply_calls"]) + 1 + external[&"value"] = int(external[&"value"]) + int(payload[&"total_count"]) + return {&"ok": false, &"reason": &"EXTERNAL_APPLY_FAILED"} + if phase == &"rollback": + external[&"rollback_calls"] = int(external[&"rollback_calls"]) + 1 + return {&"ok": false, &"reason": &"ROLLBACK_STORAGE_UNAVAILABLE"} + return {&"ok": false} + ) + var failed := quarantined.advance_to(30) + _expect(not failed.is_success() and failed.details[&"code"] == &"EXTERNAL_ROLLBACK_FAILED", "failed external rollback enters the dedicated hard-failure result") + _expect(phases == [&"capture", &"validate", &"apply", &"rollback"], "rollback quarantine preserves capture/validate/apply/rollback phase order") + _expect(quarantined.is_external_quarantined() and quarantined.pause_requested, "rollback failure hard-pauses and quarantines the coordinator") + _expect(quarantined.get_quarantine_report()[&"original_code"] == &"ARRIVAL_APPLY_FAILED", "quarantine persists the original operation failure diagnosis") + _expect(quarantined.get_alerts().size() == 1 and quarantined.get_alerts()[0][&"code"] == &"EXTERNAL_ROLLBACK_FAILED", "rollback failure emits exactly one durable diagnostic alert") + var apply_count_before := int(external[&"apply_calls"]) + _expect(not quarantined.advance_to(30).is_success() and int(external[&"apply_calls"]) == apply_count_before, "quarantined advance cannot retry and double-apply external work") + _expect(not quarantined.acknowledge_pause().is_success() and quarantined.pause_requested, "ordinary pause acknowledgement cannot clear external uncertainty") + _expect(not quarantined.can_start_construction_or_investment()[&"allowed"], "external quarantine blocks construction and investment queries") + _expect(quarantined.validate_invariants()[&"ok"], "quarantine plus a pending boundary is a valid persistent diagnostic state") + var quarantined_snapshot := quarantined.create_snapshot() + var restored_quarantine := OperationsCoordinatorType.new() + _expect(restored_quarantine.restore_snapshot(quarantined_snapshot) and restored_quarantine.is_external_quarantined(), "snapshot round trip preserves external rollback quarantine") + _expect(not restored_quarantine.advance_to(30).is_success(), "restored quarantine remains non-retryable") + + for mode: StringName in [&"capture", &"validate", &"negative_capacity", &"apply_schema"]: + var rollback_state := {&"value": 0, &"rollbacks": 0} + var mode_phases: Array[StringName] = [] + var service := OperationsCoordinatorType.new({ + &"population_cap": 10, + &"max_arrivals_per_interval": 1, + }) + service.set_demand_input(&"food", &"warrior", &"ANY", _components(200, 0, 0, 0, 0, 0)) + _track_callbacks(service) + service.configure_callbacks( + Callable(), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + mode_phases.append(phase) + if phase == &"capture": + rollback_state[&"value"] = 1 + if mode == &"capture": + return {&"ok": false, &"token": 0} + return { + &"ok": true, + &"token": 0, + &"capacity_remaining": -1 if mode == &"negative_capacity" else 10, + } + if phase == &"validate": + if mode == &"validate": + return {&"ok": false, &"token": token} + return {&"ok": true, &"token": token} + if phase == &"apply": + rollback_state[&"value"] = int(rollback_state[&"value"]) + int(payload[&"total_count"]) + return {&"ok": true} if mode == &"apply_schema" else {&"ok": false} + if phase == &"rollback": + rollback_state[&"value"] = int(token) + rollback_state[&"rollbacks"] = int(rollback_state[&"rollbacks"]) + 1 + return {&"ok": true} + return {&"ok": false} + ) + var mode_result := service.advance_to(30) + _expect(not mode_result.is_success() and not service.is_external_quarantined(), "%s failure rolls back but stays retryable" % mode) + _expect(rollback_state[&"value"] == 0 and rollback_state[&"rollbacks"] == 1, "%s failure restores external state exactly once" % mode) + var expected_phases: Array[StringName] = [&"capture", &"rollback"] + if mode == &"validate": + expected_phases = [&"capture", &"validate", &"rollback"] + elif mode == &"apply_schema": + expected_phases = [&"capture", &"validate", &"apply", &"rollback"] + _expect(mode_phases == expected_phases, "%s failure invokes no later phase after rollback" % mode) + + var daily_external := {&"value": 0, &"rollbacks": 0} + var invalid_category := OperationsCoordinatorType.new() + _track_callbacks(invalid_category) + invalid_category.configure_callbacks( + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": int(daily_external[&"value"])} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + daily_external[&"value"] = 1 + var result := _daily_result(100, 0, 0) + result[&"category_totals"][&"other_revenue"] = 10 + return result + if phase == &"rollback": + daily_external[&"value"] = int(token) + daily_external[&"rollbacks"] = 1 + return {&"ok": true} + return {&"ok": false} + ) + _expect(not invalid_category.advance_to(1_800).is_success(), "unknown financial category is rejected instead of receiving an implicit net classification") + _expect(daily_external[&"value"] == 0 and daily_external[&"rollbacks"] == 1, "unknown financial category rolls back the successful-looking external apply") + + var oversized_rollback := {&"count": 0} + var oversized := OperationsCoordinatorType.new() + _track_callbacks(oversized) + oversized.configure_callbacks( + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase in [&"capture", &"validate"]: + return {&"ok": true, &"token": token} + if phase == &"apply": + var rows: Array = [] + rows.resize(20_001) + var result := _daily_result(0, 0, 0) + result[&"anomalies"] = rows + return result + if phase == &"rollback": + oversized_rollback[&"count"] = int(oversized_rollback[&"count"]) + 1 + return {&"ok": true} + return {&"ok": false} + ) + _expect(not oversized.advance_to(1_800).is_success() and oversized_rollback[&"count"] == 1, "oversized callback payload is budget-rejected and rolled back") + + +func _test_origin_coverage_limits_and_legacy_population() -> void: + var baseline := OperationsCoordinatorType.new({&"start_minute": 360}) + _expect(baseline.get_origin_minute() == 360 and baseline.current_minute == 360, "explicit origin is retained as the authoritative scheduling baseline") + _expect(baseline.advance_to(420).is_success(), "origin baseline advances through only later boundaries") + _expect(baseline.get_morning_report(1).is_empty(), "a boundary equal to the origin is baseline state and is not retroactively processed") + _expect(baseline.get_demand_history().size() == 1 and baseline.get_arrival_reports().size() == 2, "origin 360 covers arrival 390 plus demand/arrival 420 without missing or inventing ticks") + _expect(baseline.validate_invariants()[&"ok"], "non-zero origin coverage validates end to end") + + var target := OperationsCoordinatorType.new() + var good_snapshot := baseline.create_snapshot() + var advanced_clock_only := good_snapshot.duplicate(true) + advanced_clock_only[&"current_minute"] = 480 + _expect(not target.restore_snapshot(advanced_clock_only), "advancing only the saved clock cannot bypass required boundary coverage") + var fabricated_initial := OperationsCoordinatorType.new().create_snapshot() + fabricated_initial[&"current_minute"] = 120 + _expect(not target.restore_snapshot(fabricated_initial), "empty history with a later current minute is atomically rejected") + var missing_marker := good_snapshot.duplicate(true) + missing_marker[&"processed_arrival_minutes"].remove_at(0) + _expect(not target.restore_snapshot(missing_marker), "missing processed marker is rejected even when its report remains") + var future_marker := good_snapshot.duplicate(true) + future_marker[&"processed_arrival_minutes"].append(450) + _expect(not target.restore_snapshot(future_marker), "future processed marker is rejected") + var duplicate_history := good_snapshot.duplicate(true) + duplicate_history[&"arrival_reports"].append(duplicate_history[&"arrival_reports"][0].duplicate(true)) + _expect(not target.restore_snapshot(duplicate_history), "duplicate boundary history is rejected") + + var invalid_start := OperationsCoordinatorType.new({&"start_minute": 361}) + _expect(not invalid_start.advance_to(400).is_success() and invalid_start.create_snapshot().is_empty(), "new games reject origins outside the explicit 0..360 baseline") + var over_horizon := OperationsCoordinatorType.new() + _expect(not over_horizon.advance_to(31 * 1_440 + 1).is_success(), "uncompacted history rejects advances beyond its declared 31-day horizon") + var unsafe_config := OperationsCoordinatorType.new({&"population_cap": 1_001}) + _expect(not unsafe_config.advance_to(30).is_success(), "constructor configuration beyond checked limits cannot silently fall back to defaults") + + var ambiguous_source := OperationsCoordinatorType.new() + ambiguous_source.advance_to(30) + var ambiguous_v1 := ambiguous_source.create_snapshot() + ambiguous_v1[&"schema_version"] = 1 + ambiguous_v1.erase(&"population_count") + ambiguous_v1.erase(&"alerts") + ambiguous_v1.erase(&"demand_history") + _expect(not target.restore_snapshot(ambiguous_v1), "v1 arrival history without population is rejected as ambiguous") + var empty_v1 := OperationsCoordinatorType.new().create_snapshot() + empty_v1[&"schema_version"] = 1 + empty_v1.erase(&"population_count") + empty_v1.erase(&"alerts") + empty_v1.erase(&"demand_history") + var empty_migrated := OperationsCoordinatorType.new() + _expect(empty_migrated.restore_snapshot(empty_v1) and empty_migrated.get_population_count() == 0, "v1 may infer zero population only when arrival history is empty") + + var v2_source := OperationsCoordinatorType.new() + v2_source.advance_to(120) + var v2_snapshot := v2_source.create_snapshot() + v2_snapshot[&"schema_version"] = 2 + for field: StringName in [ + &"origin_minute", &"arrival_plan_high_water", &"guest_high_water", + &"fair_rotation_counter", &"pending_boundary_minute", &"deferred_boundary_minute", + &"pause_reason", &"external_quarantined", &"quarantine_report", + ]: + v2_snapshot.erase(field) + var v2_migrated := OperationsCoordinatorType.new() + _expect(v2_migrated.restore_snapshot(v2_snapshot), "v2 snapshot migrates using origin zero and derived high-water/fairness fields") + _expect(v2_migrated.create_snapshot()[&"schema_version"] == 3 and v2_migrated.validate_invariants()[&"ok"], "v2 migration re-emits a valid current-version snapshot") + var downgraded_v2 := v2_source.create_snapshot() + downgraded_v2[&"schema_version"] = 2 + downgraded_v2[&"origin_minute"] = 360 + downgraded_v2[&"external_quarantined"] = true + downgraded_v2[&"quarantine_report"] = {&"forged": true} + var conservative_v2 := OperationsCoordinatorType.new() + _expect(conservative_v2.restore_snapshot(downgraded_v2) and conservative_v2.get_origin_minute() == 0 and not conservative_v2.is_external_quarantined(), "legacy migration ignores look-alike fields introduced only in v3") + + +func _test_fair_arrivals_seed_and_id_high_water() -> void: + var seed_zero := _fair_fixture(0) + var seed_zero_again := _fair_fixture(0) + var seed_one := _fair_fixture(1) + seed_zero.advance_to(300) + seed_zero_again.advance_to(300) + seed_one.advance_to(300) + var zero_tags := _allocation_tags(seed_zero.get_arrival_reports()) + var zero_again_tags := _allocation_tags(seed_zero_again.get_arrival_reports()) + var one_tags := _allocation_tags(seed_one.get_arrival_reports()) + _expect(zero_tags == ["a", "b", "a", "b", "a", "b", "a", "b", "a", "b"], "seed 0 has a fixed golden round-robin allocation order") + _expect(one_tags == ["b", "a", "b", "a", "b", "a", "b", "a", "b", "a"], "seed 1 changes the fixed golden allocation order") + _expect(zero_tags == zero_again_tags and seed_zero.deterministic_digest() == seed_zero_again.deterministic_digest(), "same seed and inputs reproduce the exact fair allocation and snapshot") + _expect(zero_tags != one_tags, "seed difference changes allocation results independently of config serialization") + var zero_a := zero_tags.count("a") + var zero_b := zero_tags.count("b") + _expect(zero_a == 5 and zero_b == 5, "cap-one demand cannot starve either continuously eligible profile") + + var midpoint := seed_zero.create_snapshot() + var resumed := OperationsCoordinatorType.new() + _expect(resumed.restore_snapshot(midpoint), "fairness cursor and sequence high-water restore from snapshot") + seed_zero.advance_to(600) + resumed.advance_to(600) + _expect(resumed.create_snapshot() == seed_zero.create_snapshot(), "restored fair cursor continues with exact deterministic allocation") + + var lowered_plan := midpoint.duplicate(true) + lowered_plan[&"arrival_plan_high_water"] = 0 + lowered_plan[&"next_arrival_plan_sequence"] = 1 + _expect(not resumed.restore_snapshot(lowered_plan), "plan sequence below historical IDs is rejected") + var lowered_guest := midpoint.duplicate(true) + lowered_guest[&"guest_high_water"] = 0 + lowered_guest[&"next_guest_sequence"] = 1 + _expect(not resumed.restore_snapshot(lowered_guest), "guest sequence below historical IDs is rejected") + var forged_guest := midpoint.duplicate(true) + forged_guest[&"arrival_reports"][0][&"plans"][0][&"guest_ids"][0] = "guest-auto-00009999" + _expect(not resumed.restore_snapshot(forged_guest), "history containing an ID above the saved high-water is rejected") + var tombstones := midpoint.duplicate(true) + tombstones[&"arrival_plan_high_water"] = 100 + tombstones[&"next_arrival_plan_sequence"] = 101 + tombstones[&"guest_high_water"] = 200 + tombstones[&"next_guest_sequence"] = 201 + var tombstone_restored := OperationsCoordinatorType.new() + _expect(tombstone_restored.restore_snapshot(tombstones), "high-water may conservatively retain deleted or pruned ID tombstones") + tombstone_restored.advance_to(330) + var newest_report: Dictionary = tombstone_restored.get_arrival_reports()[-1] + _expect(newest_report[&"plans"][0][&"plan_id"] == "arrival-plan-00000101" and newest_report[&"plans"][0][&"guest_ids"][0] == "guest-auto-00000201", "new IDs continue above persistent tombstones and are never reused") + + +func _test_snapshot_canonical_safety_and_derived_crosschecks() -> void: + var source := _deterministic_fixture(17) + source.advance_to(3_300) + var snapshot := source.create_snapshot() + _expect(_dictionary_tree_has_only_string_keys(snapshot), "current snapshots recursively emit String dictionary keys only") + var restored := OperationsCoordinatorType.new() + _expect(restored.restore_snapshot(snapshot), "derived cross-check fixture begins from a valid snapshot") + var before := restored.create_snapshot() + + var top_collision := snapshot.duplicate(true) + top_collision[1] = "integer key" + top_collision["1"] = "string key" + _expect(not restored.restore_snapshot(top_collision), "top-level int/String canonical key collision is rejected") + var nested_collision := snapshot.duplicate(true) + nested_collision[&"demand_profiles"][0][&"components"][1] = 1 + nested_collision[&"demand_profiles"][0][&"components"]["1"] = 2 + _expect(not restored.restore_snapshot(nested_collision), "nested canonical key collision is detected recursively") + var unknown_category := snapshot.duplicate(true) + unknown_category[&"day_summaries"][0][&"category_totals"][&"other_revenue"] = 1 + _expect(not restored.restore_snapshot(unknown_category), "unknown saved financial category is rejected") + var unknown_root := snapshot.duplicate(true) + unknown_root[&"future_field"] = true + _expect(not restored.restore_snapshot(unknown_root), "current Operations snapshot rejects unknown root fields") + var unknown_anomaly := snapshot.duplicate(true) + unknown_anomaly[&"day_summaries"][0][&"anomalies"] = [{&"code": &"UNKNOWN"}] + _expect(not restored.restore_snapshot(unknown_anomaly), "current DaySummary rejects unknown anomaly codes") + var rogue_anomaly_field := snapshot.duplicate(true) + rogue_anomaly_field[&"day_summaries"][0][&"anomalies"] = [{ + &"code": &"UNALLOCATED_WAGES", + &"staff_id": &"staff-test", + &"amount": 1, + &"future_field": true, + }] + _expect(not restored.restore_snapshot(rogue_anomaly_field), "current DaySummary anomaly rejects unknown fields") + var wrong_net := snapshot.duplicate(true) + wrong_net[&"day_summaries"][0][&"net_profit"] += 1 + _expect(not restored.restore_snapshot(wrong_net), "saved net profit is recomputed and cross-checked") + var wrong_delta := snapshot.duplicate(true) + wrong_delta[&"day_summaries"][1][&"previous_day_delta"] += 1 + _expect(not restored.restore_snapshot(wrong_delta), "saved previous-day delta is recomputed and cross-checked") + var wrong_average := snapshot.duplicate(true) + wrong_average[&"day_summaries"][1][&"seven_day_average_detail"][&"sum"] += 1 + _expect(not restored.restore_snapshot(wrong_average), "saved seven-day sum and average detail are cross-checked") + var wrong_facility := snapshot.duplicate(true) + wrong_facility[&"day_summaries"][0][&"facility_profit_loss"][&"fac-inn"][&"net_profit"] += 1 + _expect(not restored.restore_snapshot(wrong_facility), "saved facility profit/loss is reconciled") + var wrong_settlement := snapshot.duplicate(true) + wrong_settlement[&"day_summaries"][0][&"settlement"][&"arrears_total"] = 1 + _expect(not restored.restore_snapshot(wrong_settlement), "saved settlement and DaySummary arrears are cross-checked") + var wrong_balance := snapshot.duplicate(true) + wrong_balance[&"day_summaries"][0][&"balanced"] = false + _expect(not restored.restore_snapshot(wrong_balance), "saved ledger balanced flag is derived from its exact difference") + var non_finite := snapshot.duplicate(true) + non_finite[&"day_summaries"][0][&"settlement"][&"ratio"] = NAN + _expect(not restored.restore_snapshot(non_finite), "NaN nested in settlement is rejected") + var wrong_type := snapshot.duplicate(true) + wrong_type[&"arrival_reports"][0][&"total_count"] = "1" + _expect(not restored.restore_snapshot(wrong_type), "save-safe but schema-wrong scalar types are rejected") + var oversized := snapshot.duplicate(true) + var oversized_rows: Array = [] + oversized_rows.resize(20_001) + oversized[&"day_summaries"][0][&"anomalies"] = oversized_rows + _expect(not restored.restore_snapshot(oversized), "snapshot collection budget rejects oversized payloads before allocation into state") + _expect(restored.create_snapshot() == before, "all corrupt snapshot rejections leave live state atomic") + + var unsafe_internal := OperationsCoordinatorType.new() + unsafe_internal._alerts.append({&"code": &"TEST", &"callable": func() -> void: pass}) + _expect(unsafe_internal.create_snapshot().is_empty() and not unsafe_internal.validate_invariants()[&"ok"], "internal Callable is hard-rejected instead of being silently dropped from snapshot") + var colliding_components := _components(1, 0, 0, 0, 0, 0) + colliding_components[1] = 1 + colliding_components["1"] = 2 + _expect(not OperationsCoordinatorType.new().set_demand_input(&"x", &"x", &"ANY", colliding_components).is_success(), "live demand input rejects canonical key collisions") + + +func _test_signal_reentrancy_and_deferred_pause() -> void: + var service := OperationsCoordinatorType.new() + var inner_results: Array = [] + var observed_minutes: Array[int] = [] + var handler := func(_report: Dictionary) -> void: + observed_minutes.append(service.current_minute) + inner_results.append(service.advance_to(180)) + service.demand_updated.connect(handler) + var outer := service.advance_to(120) + _expect(outer.is_success() and service.current_minute == 120, "outer advance commits its target before queued signals are emitted") + _expect(observed_minutes == [120, 120], "all signal handlers observe the committed outer clock") + var all_reentrant_rejected := inner_results.size() == 2 + for result: Variant in inner_results: + all_reentrant_rejected = all_reentrant_rejected and not result.is_success() and result.details[&"code"] == &"REENTRANT_ADVANCE" + _expect(all_reentrant_rejected and service.current_minute == 120, "signal-handler reentry is rejected and cannot roll the outer clock backward") + service.demand_updated.disconnect(handler) + + var paused := OperationsCoordinatorType.new() + _track_callbacks(paused) + paused.configure_callbacks( + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase in [&"capture", &"validate"]: + return {&"ok": true, &"token": token} + if phase == &"apply": + return _daily_result(100, 0, 0, {}, 1) + return {&"ok": true} + ) + var paused_result := paused.advance_to(1_860) + _expect(paused_result.is_success() and paused.current_minute == 1_800 and paused.pause_requested, "ledger imbalance commits the 06:00 close then stops a larger leap") + _expect(paused.get_demand_history()[-1][&"minute"] == 1_740 and paused.get_arrival_reports()[-1][&"minute"] == 1_770, "paused 06:00 close runs no same-minute morning, demand or arrival work") + var paused_snapshot := paused.create_snapshot() + var resumed := OperationsCoordinatorType.new() + _expect(resumed.restore_snapshot(paused_snapshot) and resumed.validate_invariants()[&"ok"], "paused partial boundary snapshot retains a valid deferred suffix") + _expect(resumed.acknowledge_pause().is_success(), "explicit acknowledgement releases only the ledger diagnostic pause") + var resumed_result := resumed.advance_to(1_800) + _expect(resumed_result.is_success() and resumed.get_morning_report(2)[&"minute"] == 1_800 and resumed.get_demand_history()[-1][&"minute"] == 1_800 and resumed.get_arrival_reports()[-1][&"minute"] == 1_800, "acknowledged replay completes the deferred morning, demand and arrival suffix") + _expect(not resumed_result.is_replay and resumed_result.details[&"deferred_completed"], "deferred completion is reported as new work rather than a side-effect-free replay") + var resumed_digest := resumed.deterministic_digest() + resumed.advance_to(1_800) + _expect(resumed.deterministic_digest() == resumed_digest, "completed deferred boundary cannot duplicate daily, demand, or arrival work") + + +func _fair_fixture(seed: int) -> OperationsCoordinator: + var service := OperationsCoordinatorType.new({ + &"world_seed": seed, + &"population_cap": 100, + &"max_arrivals_per_interval": 1, + &"arrival_rate_numerator": 1, + &"arrival_rate_denominator": 100, + }) + service.set_demand_input(&"a", &"guest", &"ANY", _components(200, 0, 0, 0, 0, 0)) + service.set_demand_input(&"b", &"guest", &"ANY", _components(200, 0, 0, 0, 0, 0)) + return service + + +func _allocation_tags(reports: Array[Dictionary]) -> Array[String]: + var result: Array[String] = [] + for report: Dictionary in reports: + if not (report[&"plans"] as Array).is_empty(): + result.append(String(report[&"plans"][0][&"facility_tag"])) + return result + + +func _dictionary_tree_has_only_string_keys(value: Variant) -> bool: + if value is Array: + for child: Variant in value: + if not _dictionary_tree_has_only_string_keys(child): + return false + return true + if value is Dictionary: + for key: Variant in value: + if typeof(key) != TYPE_STRING or not _dictionary_tree_has_only_string_keys(value[key]): + return false + return true + + +func _deterministic_fixture(seed: int, start_minute: int = 0) -> OperationsCoordinator: + var service := OperationsCoordinatorType.new({ + &"world_seed": seed, + &"start_minute": start_minute, + &"population_cap": 100, + &"max_arrivals_per_interval": 10, + }) + service.set_demand_input(&"food", &"warrior", &"ANY", _components(80, 10, 10, 0, 0, 0)) + service.set_demand_input(&"lodging", &"merchant", &"NIGHT", _components(120, 5, 0, 0, 10, 0)) + _track_callbacks(service) + service.configure_callbacks( + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + var day := int(payload[&"day"]) + return _daily_result(200 + day * 3, 100 + day, 0, { + &"fac-inn": { + &"revenue": 200 + day * 3, + &"expenses": 100 + day, + &"net_profit": 100 + day * 2, + }, + }) + return {&"ok": true}, + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token, &"capacity_remaining": 100} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return {&"ok": true, &"accepted_count": int(payload[&"total_count"])} + return {&"ok": true}, + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"capture": + return {&"ok": true, &"token": token} + if phase == &"validate": + return {&"ok": true, &"token": token} + if phase == &"apply": + return { + &"ok": true, + &"event_payload": { + &"event_id": "morning-%02d" % int(payload[&"day"]), + }, + } + return {&"ok": true} + ) + return service + + +func _daily_result( + revenue: int, + expenses: int, + arrears: int, + facility_profit_loss: Dictionary = {}, + reconciliation_difference := 0 +) -> Dictionary: + return { + &"ok": true, + &"category_totals": { + &"revenue": revenue, + &"refunds": 0, + &"cost_of_goods": expenses, + &"wages": 0, + &"maintenance": 0, + &"utilities": 0, + &"taxes_and_interest": 0, + &"fines": 0, + &"construction_cash_flow": 0, + }, + &"reconciliation_difference": reconciliation_difference, + &"facility_profit_loss": facility_profit_loss, + &"anomalies": [], + &"settlement": {&"arrears_total": arrears, &"gold_mutated_by_coordinator": false}, + } + + +func _components( + base: Variant, + reputation: Variant, + unmet_need: Variant, + event: Variant, + recent_price_penalty: Variant, + wait_penalty: Variant +) -> Dictionary: + return { + &"base": base, + &"reputation": reputation, + &"unmet_need": unmet_need, + &"event": event, + &"recent_price_penalty": recent_price_penalty, + &"wait_penalty": wait_penalty, + } + + +func _track_callbacks(service: RefCounted) -> void: + if not _callback_services.has(service): + _callback_services.append(service) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/mvp/test_operations_coordinator.gd.uid b/tests/mvp/test_operations_coordinator.gd.uid new file mode 100644 index 0000000..906edce --- /dev/null +++ b/tests/mvp/test_operations_coordinator.gd.uid @@ -0,0 +1 @@ +uid://cbf77pwt7oqjy diff --git a/tests/mvp/test_progression_service.gd b/tests/mvp/test_progression_service.gd new file mode 100644 index 0000000..b47f102 --- /dev/null +++ b/tests/mvp/test_progression_service.gd @@ -0,0 +1,415 @@ +extends SceneTree + +const ProgressionType := preload("res://scripts/progression/progression_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_rank_bounds_and_evidence() + _test_metrics_are_atomic_and_lifetime_monotonic() + _test_rank_one_through_seven() + _test_external_transaction_and_idempotency() + _test_failed_rollback_quarantines_progression() + _test_callback_reentry_and_internal_tamper() + _test_snapshot_round_trip_and_atomic_rejection() + _test_promotion_evidence_and_legacy_migration() + _test_input_and_history_bounds() + if _failures == 0: + print("PASS: ProgressionService (%d checks)" % _checks) + else: + push_error("FAIL: ProgressionService (%d of %d checks failed)" % [_failures, _checks]) + quit(_failures) + + +func _test_rank_bounds_and_evidence() -> void: + var service := ProgressionType.new() + _expect(service.configure(), "progression starts at Rank 1") + _expect(service.current_rank == 1, "configured rank is authoritative") + _expect(service.get_rank_bounds(1) == {&"min_x":-12, &"max_x":12, &"min_y":0, &"max_y":6}, "Rank 1 bounds match the system specification") + _expect(service.get_rank_bounds(3) == {&"min_x":-24, &"max_x":24, &"min_y":-8, &"max_y":16}, "Rank 3 MVP bounds include eight underground levels") + _expect(service.get_rank_bounds(7) == {&"min_x":-60, &"max_x":60, &"min_y":-40, &"max_y":56}, "Rank 7 bounds match the final expansion") + _expect(service.get_rank_bounds(8).is_empty(), "unknown rank has no implicit clamped bounds") + var report := service.evaluate_rank(2) + _expect(not report[&"eligible"] and report[&"reason"] == &"REQUIREMENTS_NOT_MET", "Rank 2 reports unmet requirements") + _expect(_requirement_ids(report) == [&"reputation", &"asset_gold"], "Rank 2 evidence order is stable and player-readable") + _expect(report[&"requirements"][0][&"current"] == 0 and report[&"requirements"][0][&"required"] == 25, "eligibility evidence includes current and required values") + var skip := service.evaluate_rank(3) + _expect(not skip[&"eligible"] and skip[&"reason"] == &"TARGET_MUST_BE_NEXT_RANK", "rank skipping is rejected before metric evaluation") + + +func _test_metrics_are_atomic_and_lifetime_monotonic() -> void: + var service := ProgressionType.new() + service.configure() + _expect(service.update_metrics({&"reputation": 25, &"asset_gold": 10_000, &"faith_lifetime": 100}), "valid metric patch commits atomically") + var before := service.metrics.duplicate(true) + _expect(not service.update_metrics({&"reputation": 101, &"asset_gold": 20_000}), "out-of-range percentage rejects the whole patch") + _expect(service.metrics == before, "failed metric patch preserves every prior metric") + _expect(not service.update_metrics({&"faith_lifetime": 99}), "lifetime Faith cannot decrease after spending current Faith") + _expect(not service.update_metrics({&"guest_lifetime": -1}), "lifetime guest count cannot become negative") + _expect(not service.update_metrics({&"unknown": 1}), "unknown progression metric is rejected") + _expect(not service.update_metrics({&"asset_gold": "10000"}), "string numbers are not coerced into progression facts") + _expect(service.metrics == before, "all rejected patches leave progression facts unchanged") + _expect(service.complete_quest(&"important_rank_5"), "valid stable quest ID records once") + _expect(service.complete_quest(&"important_rank_5"), "completing the same quest is idempotent") + _expect(service.completed_quest_ids.size() == 1, "idempotent quest completion creates no duplicate") + _expect(not service.complete_quest(&"Bad Quest"), "invalid quest ID cannot enter persistent progression") + + +func _test_rank_one_through_seven() -> void: + var service := ProgressionType.new() + service.configure() + service.update_metrics({&"reputation":25, &"asset_gold":10_000}) + _expect(service.request_rank_up(&"rank_up_2", 2).is_success(), "Rank 2 unlocks at Reputation 25 and assets 10,000G") + service.update_metrics({&"faith_lifetime":100, &"guest_lifetime":300}) + _expect(service.request_rank_up(&"rank_up_3", 3).is_success(), "Rank 3 unlocks at lifetime Faith 100 and 300 guests") + service.update_metrics({ + &"reputation":45, + &"asset_gold":50_000, + &"active_above_ground_facilities":1, + &"active_underground_facilities":1, + }) + _expect(service.request_rank_up(&"rank_up_4", 4).is_success(), "Rank 4 additionally requires active facilities above and below ground") + service.update_metrics({&"faith_lifetime":500}) + var rank_five_missing := service.evaluate_rank(5) + _expect(not rank_five_missing[&"eligible"], "Rank 5 does not unlock from numbers alone") + service.complete_quest(&"important_rank_5") + _expect(service.request_rank_up(&"rank_up_5", 5).is_success(), "Rank 5 requires its explicit important quest") + service.update_metrics({&"underground_influence":65}) + var rank_six := service.evaluate_rank(6) + _expect(rank_six[&"eligible"], "Rank 6 accepts underground influence as the specified Reputation alternative") + _expect(service.request_rank_up(&"rank_up_6", 6).is_success(), "Rank 6 commits through the alternative branch") + service.update_metrics({&"faith_lifetime":1500, &"reputation":60}) + var rank_seven_missing := service.evaluate_rank(7) + _expect(not rank_seven_missing[&"eligible"], "Rank 7 still requires the main chapter flag") + service.complete_quest(&"main_chapter_complete") + _expect(service.request_rank_up(&"rank_up_7", 7).is_success(), "Rank 7 requires Faith, both progress axes, dual operation and chapter completion") + _expect(service.current_rank == 7, "rank progression is monotonic through the final rank") + _expect(not service.request_rank_up(&"rank_up_8", 8).is_success(), "Rank 8 is rejected") + + +func _test_external_transaction_and_idempotency() -> void: + var service := _eligible_rank_two_service() + var external := {&"rank":1, &"bounds":service.get_rank_bounds(1), &"apply_calls":0, &"rollback_calls":0} + var capture := func(_current: int, _target: int) -> Dictionary: + return {&"ok":true, &"snapshot":{&"rank":external[&"rank"], &"bounds":external[&"bounds"].duplicate(true)}} + var apply := func(target: int, bounds: Dictionary) -> Dictionary: + external[&"apply_calls"] += 1 + external[&"rank"] = target + external[&"bounds"] = bounds.duplicate(true) + return {&"ok":true} + var rollback := func(snapshot: Dictionary) -> Dictionary: + external[&"rollback_calls"] += 1 + external[&"rank"] = snapshot[&"rank"] + external[&"bounds"] = snapshot[&"bounds"].duplicate(true) + return {&"ok":true} + var result := service.request_rank_up(&"atomic_rank_2", 2, capture, apply, rollback) + _expect(result.is_success(), "eligible rank-up commits through capture/apply boundary") + _expect(service.current_rank == 2 and external[&"rank"] == 2, "internal rank and external world commit together") + _expect(external[&"bounds"] == service.get_rank_bounds(2), "external world receives exact target bounds") + var replay := service.request_rank_up(&"atomic_rank_2", 2) + _expect(replay.is_success() and replay.is_replay, "same request ID and target returns its recorded success") + _expect(external[&"apply_calls"] == 1, "idempotent replay does not apply external expansion twice") + var conflict := service.request_rank_up(&"atomic_rank_2", 3) + _expect(not conflict.is_success() and conflict.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, "same request ID with another target conflicts") + var partial := service.request_rank_up(&"partial_callbacks", 3, capture, Callable(), rollback) + _expect(not partial.is_success() and partial.code == CommandResult.Code.INVALID_PAYLOAD, "partial callback set is rejected before mutation") + var strict_callbacks := _eligible_rank_two_service() + var bad_capture := func(_current: int, _target: int) -> Dictionary: + return {&"ok":"true", &"snapshot":{}} + var bad_capture_result := strict_callbacks.request_rank_up(&"bad_capture_type", 2, bad_capture, apply, rollback) + _expect(not bad_capture_result.is_success() and external[&"apply_calls"] == 1, "truthy string capture status is rejected before external apply") + var string_apply := func(_target: int, _bounds: Dictionary) -> Dictionary: + return {&"ok":"true"} + var strict_rollback := func(_snapshot: Dictionary) -> Dictionary: + return {&"ok":true} + var bad_apply_result := strict_callbacks.request_rank_up(&"bad_apply_type", 2, capture, string_apply, strict_rollback) + _expect(not bad_apply_result.is_success() and strict_callbacks.current_rank == 1, "truthy string apply status is treated as failure and rolled back") + + +func _test_failed_rollback_quarantines_progression() -> void: + var rolled_back := _eligible_rank_two_service() + var external_rank := 1 + var capture := func(_current: int, _target: int) -> Dictionary: + return {&"ok":true, &"snapshot":external_rank} + var failed_apply := func(target: int, _bounds: Dictionary) -> Dictionary: + external_rank = target + return {&"ok":false} + var successful_rollback := func(snapshot: int) -> Dictionary: + external_rank = snapshot + return {&"ok":true} + var rolled_back_result := rolled_back.request_rank_up( + &"failed_but_rolled_back", 2, capture, failed_apply, successful_rollback + ) + _expect(not rolled_back_result.is_success(), "failed external expansion rejects rank-up") + _expect(rolled_back.current_rank == 1 and external_rank == 1, "successful rollback restores both sides to Rank 1") + _expect(not rolled_back.quarantined, "verified rollback keeps progression available for a later request") + + var quarantined := _eligible_rank_two_service() + external_rank = 1 + var failed_rollback := func(_snapshot: int) -> Dictionary: + return {&"ok":false} + var quarantined_result := quarantined.request_rank_up( + &"rollback_failure", 2, capture, failed_apply, failed_rollback + ) + _expect(not quarantined_result.is_success() and quarantined_result.code == CommandResult.Code.TRANSACTION_CLOSED, "rollback failure closes the progression boundary") + _expect(quarantined.quarantined and quarantined.quarantine_reason == &"RANK_ROLLBACK_FAILED", "rollback failure records a durable quarantine reason") + _expect(not quarantined.update_metrics({&"reputation":30}), "quarantine blocks later metric mutation") + _expect(not quarantined.request_rank_up(&"retry_after_quarantine", 2).is_success(), "quarantine blocks retry and prevents double expansion") + + +func _test_callback_reentry_and_internal_tamper() -> void: + var service := _eligible_rank_two_service() + var external_rank := 1 + var observations := {&"metric_update":true, &"nested":null} + var capture := func(_current: int, _target: int) -> Dictionary: + return {&"ok":true, &"snapshot":external_rank} + var reentrant_apply := func(target: int, _bounds: Dictionary) -> Dictionary: + observations[&"metric_update"] = service.update_metrics({&"reputation":99}) + observations[&"nested"] = service.request_rank_up(&"nested_rank_request", 2) + external_rank = target + return {&"ok":true} + var rollback := func(snapshot: int) -> Dictionary: + external_rank = snapshot + return {&"ok":true} + var result := service.request_rank_up(&"guarded_rank_request", 2, capture, reentrant_apply, rollback) + _expect(result.is_success(), "blocked reentry cannot prevent an otherwise valid outer promotion") + _expect(not bool(observations[&"metric_update"]), "rank callback cannot mutate eligibility metrics reentrantly") + var nested := observations[&"nested"] as CommandResult + _expect(nested != null and not nested.is_success() and nested.code == CommandResult.Code.TRANSACTION_CLOSED, "nested promotion is rejected while the outer transaction is active") + _expect(service.metrics[&"reputation"] == 25 and service.current_rank == 2, "blocked callback reentry leaves one exact promotion and original evidence") + _expect(service.create_snapshot()[&"processed_requests"].size() == 1, "reentry guard creates no hidden second idempotency record") + + var tampered := _eligible_rank_two_service() + external_rank = 1 + var tampering_apply := func(target: int, _bounds: Dictionary) -> Dictionary: + external_rank = target + tampered.metrics[&"reputation"] = 99 + return {&"ok":true} + result = tampered.request_rank_up(&"tampered_apply", 2, capture, tampering_apply, rollback) + _expect(not result.is_success() and result.code == CommandResult.Code.TRANSACTION_APPLY_FAILED, "direct internal mutation by an apply callback invalidates the transaction") + _expect(tampered.current_rank == 1 and tampered.metrics[&"reputation"] == 25 and external_rank == 1, "tampered apply rolls both internal and external state back exactly") + _expect(not tampered.quarantined, "verified rollback after callback tamper keeps later promotion available") + + var rollback_tampered := _eligible_rank_two_service() + external_rank = 1 + var failed_apply := func(target: int, _bounds: Dictionary) -> Dictionary: + external_rank = target + return {&"ok":false} + var mutating_rollback := func(snapshot: int) -> Dictionary: + external_rank = snapshot + rollback_tampered.current_rank = 7 + return {&"ok":true} + result = rollback_tampered.request_rank_up(&"tampered_rollback", 2, capture, failed_apply, mutating_rollback) + _expect(not result.is_success() and rollback_tampered.current_rank == 1, "internal state is restored even when a successful rollback callback tampers with it") + _expect(external_rank == 1 and not rollback_tampered.quarantined, "successful external rollback remains authoritative after internal tamper repair") + + var valid_capture_tampered := _eligible_rank_two_service() + var callback_counts := {&"apply":0, &"rollback":0} + var mutating_capture := func(_current: int, _target: int) -> Dictionary: + valid_capture_tampered.metrics[&"reputation"] = 99 + return {&"ok":true, &"snapshot":1} + var counted_apply := func(_target: int, _bounds: Dictionary) -> Dictionary: + callback_counts[&"apply"] += 1 + return {&"ok":true} + var counted_rollback := func(_snapshot: int) -> Dictionary: + callback_counts[&"rollback"] += 1 + return {&"ok":true} + result = valid_capture_tampered.request_rank_up(&"valid_capture_tamper", 2, mutating_capture, counted_apply, counted_rollback) + _expect(not result.is_success() and callback_counts[&"apply"] == 0, "valid-looking capture tamper is detected before external apply") + _expect(callback_counts[&"rollback"] == 1 and valid_capture_tampered.metrics[&"reputation"] == 25, "capture tamper invokes one rollback and restores internal eligibility") + + var capture_tampered := _eligible_rank_two_service() + var bad_capture := func(_current: int, _target: int) -> Dictionary: + capture_tampered.current_rank = 7 + return {&"ok":false} + result = capture_tampered.request_rank_up(&"tampered_capture", 2, bad_capture, reentrant_apply, rollback) + _expect(not result.is_success() and result.code == CommandResult.Code.TRANSACTION_CLOSED, "invalid capture that mutates progression enters quarantine") + _expect(capture_tampered.current_rank == 1 and capture_tampered.quarantine_reason == &"RANK_CALLBACK_TAMPERED", "capture tamper restores the last valid rank and records the uncertainty reason") + + +func _test_snapshot_round_trip_and_atomic_rejection() -> void: + var source := _eligible_rank_two_service() + source.complete_quest(&"important_rank_5") + source.request_rank_up(&"snapshot_rank_2", 2) + var snapshot := source.create_snapshot() + var restored := ProgressionType.new() + _expect(restored.restore_snapshot(snapshot), "valid progression snapshot restores") + _expect(restored.create_snapshot() == snapshot, "progression snapshot round trip is exact") + var json_snapshot: Variant = JSON.parse_string(JSON.stringify(snapshot)) + var json_restored := ProgressionType.new() + var json_ok := json_snapshot is Dictionary and json_restored.restore_snapshot(json_snapshot) + _expect(json_ok, "JSON-decoded string-key snapshot restores through the same strict boundary (%s)" % json_restored.last_error) + _expect(json_restored.create_snapshot() == snapshot, "JSON transport preserves all promotion evidence exactly") + var before := restored.create_snapshot() + + var missing_metric := snapshot.duplicate(true) + missing_metric[&"metrics"].erase("asset_gold") + _expect(not restored.restore_snapshot(missing_metric), "snapshot missing one progression metric is rejected") + _expect(restored.create_snapshot() == before, "missing-metric rejection is atomic") + + var bad_fingerprint := snapshot.duplicate(true) + bad_fingerprint[&"processed_requests"][0][&"fingerprint"] = "rank:7" + _expect(not restored.restore_snapshot(bad_fingerprint), "request fingerprint inconsistent with recorded rank is rejected") + _expect(restored.create_snapshot() == before, "fingerprint rejection preserves live progression") + + var bad_bounds := snapshot.duplicate(true) + bad_bounds[&"processed_requests"][0][&"details"][&"bounds"][&"max_x"] = 999 + _expect(not restored.restore_snapshot(bad_bounds), "derived rank bounds cannot be tampered in a snapshot") + _expect(restored.create_snapshot() == before, "derived-bounds rejection is atomic") + + var bad_quarantine := snapshot.duplicate(true) + bad_quarantine[&"quarantined"] = true + bad_quarantine[&"quarantine_reason"] = "" + _expect(not restored.restore_snapshot(bad_quarantine), "quarantine boolean requires a non-empty reason") + _expect(restored.create_snapshot() == before, "quarantine mismatch rejection is atomic") + + var string_rank := snapshot.duplicate(true) + string_rank[&"current_rank"] = "2" + _expect(not restored.restore_snapshot(string_rank), "string rank is not coerced during restore") + _expect(restored.create_snapshot() == before, "type rejection preserves live progression") + + var string_version := snapshot.duplicate(true) + string_version[&"version"] = "1" + _expect(not restored.restore_snapshot(string_version), "string schema version is not coerced during restore") + _expect(restored.create_snapshot() == before, "schema type rejection is atomic") + + var unknown_root := snapshot.duplicate(true) + unknown_root[&"unexpected"] = true + _expect(not restored.restore_snapshot(unknown_root), "unknown root snapshot field is rejected") + _expect(restored.create_snapshot() == before, "unknown-field rejection preserves live progression") + + var duplicate_rank_history := snapshot.duplicate(true) + var duplicate_record: Dictionary = duplicate_rank_history[&"processed_requests"][0].duplicate(true) + duplicate_record[&"request_id"] = "snapshot_rank_2_b" + duplicate_record[&"details"][&"request_id"] = &"snapshot_rank_2_b" + duplicate_rank_history[&"processed_requests"].append(duplicate_record) + _expect(not restored.restore_snapshot(duplicate_rank_history), "two successful request IDs cannot claim the same one-time rank transition") + _expect(restored.create_snapshot() == before, "duplicate-transition rejection is atomic") + + +func _test_promotion_evidence_and_legacy_migration() -> void: + var source := _eligible_rank_two_service() + source.request_rank_up(&"evidence_rank_2", 2) + var snapshot := source.create_snapshot() + var evidence: Dictionary = snapshot[&"processed_requests"][0][&"details"][&"eligibility"] + _expect(snapshot[&"version"] == 2, "promotion evidence is stored in Progression Snapshot 2") + _expect(evidence[&"eligible"] and evidence[&"current_rank"] == 1 and evidence[&"target_rank"] == 2, "saved eligibility header binds the exact one-step transition") + _expect(_requirement_ids(evidence) == [&"reputation", &"asset_gold"], "saved eligibility retains deterministic condition order") + + var restored := ProgressionType.new() + _expect(restored.configure(), "evidence rejection target starts from a known valid state") + var before := restored.create_snapshot() + var below_threshold := snapshot.duplicate(true) + below_threshold[&"processed_requests"][0][&"details"][&"eligibility"][&"requirements"][0][&"current"] = 24 + _expect(not restored.restore_snapshot(below_threshold), "promotion history below its threshold is rejected") + _expect(restored.create_snapshot() == before, "invalid promotion evidence rejection is atomic") + + var changed_requirement := snapshot.duplicate(true) + changed_requirement[&"processed_requests"][0][&"details"][&"eligibility"][&"requirements"][0][&"required"] = 24 + _expect(not restored.restore_snapshot(changed_requirement), "saved threshold definitions cannot be weakened") + _expect(restored.create_snapshot() == before, "threshold tamper preserves the live progression") + + var missing_history := snapshot.duplicate(true) + missing_history[&"processed_requests"] = [] + _expect(not restored.restore_snapshot(missing_history), "Rank 2 cannot be restored without its Rank 2 promotion record") + _expect(restored.create_snapshot() == before, "incomplete-history rejection is atomic") + + var rank_three := _rank_three_service() + var lifetime_tamper := rank_three.create_snapshot() + lifetime_tamper[&"metrics"]["faith_lifetime"] = 99 + _expect(not restored.restore_snapshot(lifetime_tamper), "current lifetime Faith cannot fall below its saved Rank 3 evidence") + _expect(restored.create_snapshot() == before, "lifetime-history mismatch preserves the live progression") + + var rank_five := _rank_five_service() + var quest_tamper := rank_five.create_snapshot() + quest_tamper[&"completed_quest_ids"].erase("important_rank_5") + _expect(not restored.restore_snapshot(quest_tamper), "a monotonic quest used for Rank 5 cannot disappear from the snapshot") + _expect(restored.create_snapshot() == before, "quest-history mismatch is rejected atomically") + + var legacy_source := ProgressionType.new() + legacy_source.configure(1, {&"reputation":10}, [&"tutorial_complete"]) + var legacy_rank_one := legacy_source.create_snapshot() + legacy_rank_one[&"version"] = 1 + _expect(restored.restore_snapshot(legacy_rank_one), "unambiguous legacy Rank 1 snapshot migrates safely") + _expect(restored.create_snapshot()[&"version"] == 2 and restored.current_rank == 1, "legacy restore is immediately re-emitted as Snapshot 2") + + var legacy_progressed := snapshot.duplicate(true) + legacy_progressed[&"version"] = 1 + legacy_progressed[&"processed_requests"][0][&"details"].erase(&"eligibility") + var migrated_before := restored.create_snapshot() + _expect(not restored.restore_snapshot(legacy_progressed), "legacy progressed snapshot without condition evidence is rejected as ambiguous") + _expect(restored.create_snapshot() == migrated_before, "ambiguous legacy rejection preserves the migrated Rank 1 state") + + +func _test_input_and_history_bounds() -> void: + var service := ProgressionType.new() + _expect(not service.configure(2), "configuration cannot bypass the one-rank-at-a-time promotion boundary") + _expect(service.configure(), "valid Rank 1 configuration remains available after rejection") + _expect(not service.update_metrics({&"asset_gold":ProgressionType.MAX_SAFE_INTEGER + 1}), "metrics above the JSON-safe integer range are rejected") + _expect(not service.update_metrics({&"asset_gold":INF}), "non-finite metrics are rejected") + var oversized_quests: Array = [] + for index in ProgressionType.MAX_COMPLETED_QUESTS + 1: + oversized_quests.append("quest_%03d" % index) + _expect(not service.configure(1, {}, oversized_quests), "oversized initial quest input is rejected before per-entry work") + + service = _eligible_rank_two_service() + var invalid_id := service.request_rank_up(&"rank request with spaces", 2) + _expect(not invalid_id.is_success() and invalid_id.code == CommandResult.Code.INVALID_PAYLOAD, "idempotency keys must use stable lowercase IDs") + _expect(service.current_rank == 1, "invalid idempotency key cannot mutate rank") + + var impossible := service.create_snapshot() + impossible[&"current_rank"] = 7 + var before := service.create_snapshot() + _expect(not service.restore_snapshot(impossible), "fabricated high rank without contiguous promotion history is rejected") + _expect(service.create_snapshot() == before, "fabricated-rank rejection is atomic") + + +func _eligible_rank_two_service() -> ProgressionService: + var service := ProgressionType.new() + service.configure(1, {&"reputation":25, &"asset_gold":10_000}) + return service + + +func _rank_three_service() -> ProgressionService: + var service := _eligible_rank_two_service() + service.request_rank_up(&"history_rank_2", 2) + service.update_metrics({&"faith_lifetime":100, &"guest_lifetime":300}) + service.request_rank_up(&"history_rank_3", 3) + return service + + +func _rank_five_service() -> ProgressionService: + var service := _rank_three_service() + service.update_metrics({ + &"reputation":45, + &"asset_gold":50_000, + &"active_above_ground_facilities":1, + &"active_underground_facilities":1, + }) + service.request_rank_up(&"history_rank_4", 4) + service.update_metrics({&"faith_lifetime":500}) + service.complete_quest(&"important_rank_5") + service.request_rank_up(&"history_rank_5", 5) + return service + + +func _requirement_ids(report: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for requirement: Dictionary in report[&"requirements"]: + result.append(StringName(requirement[&"id"])) + return result + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/mvp/test_progression_service.gd.uid b/tests/mvp/test_progression_service.gd.uid new file mode 100644 index 0000000..68bdaeb --- /dev/null +++ b/tests/mvp/test_progression_service.gd.uid @@ -0,0 +1 @@ +uid://dt1eshc6c6rrf diff --git a/tests/mvp/test_runtime_content_progression.gd b/tests/mvp/test_runtime_content_progression.gd new file mode 100644 index 0000000..5ef28be --- /dev/null +++ b/tests/mvp/test_runtime_content_progression.gd @@ -0,0 +1,500 @@ +extends SceneTree + + +class DenyingOperationsCoordinator: + extends OperationsCoordinator + + func can_start_construction_or_investment() -> Dictionary: + return { + &"allowed": false, + &"reason": &"ARREARS_REMAIN", + &"arrears_total": 25, + &"consecutive_insolvency_count": 1, + } + + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant +var _direct_command_id := 900_000 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("MVP: Runtime content, hiring, and progression integration") + _test_catalog_bootstrap_and_queries() + _test_progression_resource_and_guest_facts() + _test_catalog_staff_hire_transaction() + _test_rank_two_three_and_atomic_failure() + _test_v3_migration_and_v4_tamper_guards() + _test_twenty_four_hour_replay() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed := 82_001) -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _clock.set_paused(true) + _expect(bool(report.get(&"valid", false)), "Runtime initializes with validated content and Progression") + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_catalog_bootstrap_and_queries() -> void: + _reset_runtime(82_001) + var report: Dictionary = _runtime.get_mvp_content_report() + _expect(bool(report[&"valid"]), "MVP catalog report is valid") + _expect(int(report[&"counts"]["guest_classes"]) == 8, "catalog exposes eight Guest classes") + _expect(int(report[&"counts"]["species"]) == 4, "catalog exposes four independent species") + _expect(int(report[&"counts"]["staff_candidates"]) == 20, "catalog exposes twenty fixed Staff candidates") + _expect(int(report[&"counts"]["unique_characters"]) == 2, "catalog exposes both unique characters") + var catalog_ids: Array[StringName] = _runtime.get_mvp_guest_definition_ids() + _expect(catalog_ids.size() == 32, "Runtime registers every class/species Guest definition") + _expect(_runtime.guest_service.get_definitions().size() == 35, "legacy three Guest definitions coexist with the 32 catalog definitions") + var sorted_ids := catalog_ids.duplicate() + sorted_ids.sort_custom(func(left: StringName, right: StringName) -> bool: return String(left) < String(right)) + _expect(catalog_ids == sorted_ids, "Runtime catalog Guest IDs use stable canonical order") + var all_registered := true + for guest_type_id: StringName in catalog_ids: + if _runtime.guest_service.get_definition(guest_type_id) == null: + all_registered = false + break + _expect(all_registered, "all 32 catalog Guest definitions are queryable by stable ID") + + report[&"counts"]["guest_classes"] = 0 + _expect(int(_runtime.get_mvp_content_report()[&"counts"]["guest_classes"]) == 8, "content report query is a defensive copy") + var candidate: Dictionary = _runtime.get_mvp_staff_candidate_payload(&"staff_akane") + (candidate[&"options"][&"skills"] as Dictionary)[&"service"] = -1 + _expect( + int(_runtime.get_mvp_staff_candidate_payload(&"staff_akane")[&"options"][&"skills"][&"service"]) == 68, + "Staff candidate payload query is a defensive copy" + ) + var progression_report: Dictionary = _runtime.get_progression_report() + (progression_report[&"snapshot"][&"metrics"] as Dictionary)["asset_gold"] = 0 + _expect(int(_runtime.progression_service.metrics[&"asset_gold"]) == 5_000, "Progression report query is a defensive copy") + + +func _test_progression_resource_and_guest_facts() -> void: + _reset_runtime(82_002) + var faith: CommandResult = _runtime.submit_command( + &"change_resources", {&"deltas": {&"faith": 40}}, + &"player", &"", 50, -1, &"mvp.progression.faith" + ) + _expect(faith.is_success(), "positive Faith resource change succeeds") + _expect(_state.get_resource(&"faith") == 40, "positive Faith changes live GameState") + _expect(int(_runtime.progression_service.metrics[&"faith_lifetime"]) == 40, "positive Faith increments lifetime progression atomically") + var spawned: CommandResult = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": &"catalog_guest_one", + &"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0], + &"gold": 50, + &"options": {&"current_cell": Vector3i.ZERO}, + }, + &"world", &"catalog_guest_one", 40, -1, &"mvp.progression.guest_one" + ) + _expect(spawned.is_success(), "catalog Guest can spawn through Runtime") + _expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 1, "successful Guest spawn increments lifetime Guest count once") + var duplicate: CommandResult = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": &"catalog_guest_one", + &"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0], + &"options": {&"current_cell": Vector3i.ZERO}, + }, + &"world", &"catalog_guest_one", 40, -1, &"mvp.progression.guest_duplicate" + ) + _expect(not duplicate.is_success(), "duplicate Guest spawn is rejected") + _expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 1, "failed Guest spawn cannot increment lifetime progression") + + _expect( + _runtime.progression_service.update_metrics({&"faith_lifetime": ProgressionService.MAX_SAFE_INTEGER}), + "overflow rollback fixture reaches the maximum lifetime Faith" + ) + var state_before: Dictionary = _state.create_snapshot() + var economy_before: Dictionary = _runtime.economy_service.create_snapshot() + var progression_before: Dictionary = _runtime.progression_service.create_snapshot() + var overflow: CommandResult = _runtime._execute_change_resources(_direct_command( + &"change_resources", {&"deltas": {&"faith": 1}} + )) + _expect(not overflow.is_success(), "lifetime Faith overflow rejects the resource command") + _expect(_state.create_snapshot() == state_before, "failed lifetime update restores GameState exactly") + _expect(_runtime.economy_service.create_snapshot() == economy_before, "failed lifetime update restores Economy exactly") + _expect(_runtime.progression_service.create_snapshot() == progression_before, "failed lifetime update restores Progression exactly") + + +func _test_catalog_staff_hire_transaction() -> void: + _reset_runtime(82_003) + var bypass: CommandResult = _runtime.submit_command( + &"register_staff", + { + &"staff_id": &"staff_akane", + &"role_id": &"guard", + &"options": {&"current_cell": Vector3i.ZERO}, + }, + &"player", &"staff_akane", 80, -1, &"mvp.hire.bypass" + ) + _expect(not bypass.is_success(), "generic Staff registration cannot bypass a reserved catalog candidate") + var cash_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) + var hired: CommandResult = _runtime.submit_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}, + &"player", &"staff_akane", 80, -1, &"mvp.hire.akane" + ) + _expect(hired.is_success(), "validated Staff candidate hires atomically") + var staff: StaffRuntime = _runtime.staff_service.get_staff(&"staff_akane") + var shift: ShiftInstance = _runtime.staff_service.get_shift(StringName(hired.details[&"shift_id"])) + _expect(staff != null and staff.role_id == &"receptionist", "hire uses the catalog role as the sole source of truth") + _expect(staff != null and staff.daily_wage == 120, "FREE_HIRE retains its catalog daily wage") + _expect(staff != null and staff.get_employment_kind_name() == &"FREE_HIRE", "ordinary catalog hire records explicit FREE_HIRE employment") + _expect(staff != null and int(staff.skills[&"service"]) == 68, "hire copies catalog skills without caller inference") + _expect(staff != null and staff.permitted_task_tags == [&"reception", &"service"], "hire copies canonical permitted Task tags") + _expect(shift != null and shift.template_id == &"DAY", "hire creates the candidate's preferred Shift") + _expect(_runtime.staff_service.get_daily_payroll_total() == 120, "hired candidate participates in deterministic daily payroll") + _expect( + _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == cash_before - 180, + "signing cost is paid exactly once through the Gold ledger" + ) + _expect(_state.get_resource(&"gold") == cash_before - 180, "ledger signing cost synchronizes GameState Gold") + _expect(_runtime.path_service.has_actor_projection(&"staff:staff_akane"), "hired Staff is projected into Path traffic") + _expect(_runtime.get_hired_staff_candidate_ids() == [&"staff_akane"], "Runtime records the candidate as permanently hired") + var saw_hire_event := false + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == &"STAFF_CANDIDATE_HIRED" and &"staff_akane" in event.entity_ids: + saw_hire_event = true + _expect(saw_hire_event, "successful hire publishes a structured DomainEvent") + + var replay_cash: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) + var replay: CommandResult = _runtime.submit_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}, + &"player", &"staff_akane", 80, -1, &"mvp.hire.akane" + ) + _expect(not replay.is_success() and replay.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, "resent hire command is recognized by its idempotency key") + _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == replay_cash, "resent hire never charges the signing cost twice") + var duplicate: CommandResult = _runtime.submit_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}, + &"player", &"staff_akane", 80, -1, &"mvp.hire.akane.other" + ) + _expect(not duplicate.is_success(), "same candidate cannot be hired under a different request") + _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == replay_cash, "duplicate-candidate rejection spends no Gold") + var original_coordinator: Variant = _runtime.operations_coordinator + var original_operations_enabled: bool = bool(_runtime._operations_enabled) + var staff_before_gate: Dictionary = _runtime.staff_service.create_snapshot() + var economy_before_gate: Dictionary = _runtime.economy_service.create_snapshot() + var hired_before_gate: Array[StringName] = _runtime.get_hired_staff_candidate_ids() + _runtime.operations_coordinator = DenyingOperationsCoordinator.new() + _runtime._operations_enabled = true + var gated: CommandResult = _runtime._execute_hire_staff_candidate(_direct_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_tessa", &"current_cell": Vector3i.ZERO} + )) + _runtime.operations_coordinator = original_coordinator + _runtime._operations_enabled = original_operations_enabled + _expect( + not gated.is_success() + and StringName(gated.details.get(&"reason", &"")) == &"ARREARS_REMAIN", + "Operations arrears gate rejects a new wage-bearing Staff contract" + ) + _expect( + _runtime.staff_service.create_snapshot() == staff_before_gate + and _runtime.economy_service.create_snapshot() == economy_before_gate + and _runtime.get_hired_staff_candidate_ids() == hired_before_gate, + "rejected Operations-gated hire spends nothing and mutates no Staff history" + ) + + var staff_before: Dictionary = _runtime.staff_service.create_snapshot() + var economy_before: Dictionary = _runtime.economy_service.create_snapshot() + var game_state_before: Dictionary = _state.create_snapshot() + var path_before: Dictionary = _runtime.path_service.create_snapshot() + var credits_before: Dictionary = _runtime._movement_credits.duplicate(true) + var events_before: Dictionary = _runtime.domain_events.create_snapshot() + var ids_before: Dictionary = _runtime.id_generator.create_snapshot() + var hired_ids_before: Array[StringName] = _runtime.get_hired_staff_candidate_ids() + var callback := Callable(_runtime, "_on_staff_registered") + _runtime.staff_service.staff_registered.disconnect(callback) + var forced_failure: CommandResult = _runtime._execute_hire_staff_candidate(_direct_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_bardo", &"current_cell": Vector3i.ZERO} + )) + _runtime.staff_service.staff_registered.connect(callback) + _expect(not forced_failure.is_success(), "missing Path projection forces a post-payment hire failure") + _expect(_runtime.staff_service.create_snapshot() == staff_before, "failed hire restores Staff and Shift state exactly") + _expect(_runtime.economy_service.create_snapshot() == economy_before, "failed hire restores the ledger exactly") + _expect(_state.create_snapshot() == game_state_before, "failed hire restores GameState exactly") + _expect(_runtime.path_service.create_snapshot() == path_before, "failed hire restores Path exactly") + _expect(_runtime._movement_credits == credits_before, "failed hire restores movement credits exactly") + _expect(_runtime.domain_events.create_snapshot() == events_before, "failed hire restores DomainEvents exactly") + _expect(_runtime.id_generator.create_snapshot() == ids_before, "failed hire restores stable ID counters exactly") + _expect(_runtime.get_hired_staff_candidate_ids() == hired_ids_before, "failed hire leaves candidate history unchanged") + var canonical_save: Dictionary = _runtime.create_snapshot() + var wage_tamper: Dictionary = canonical_save.duplicate(true) + for raw_staff: Dictionary in wage_tamper[&"systems"][&"runtime"][&"staff"][&"staff"]: + if String(raw_staff[&"staff_id"]) == "staff_akane": + raw_staff[&"daily_wage"] = 0 + break + var live_before: Dictionary = _runtime.create_snapshot() + _expect(not _runtime.restore_snapshot(wage_tamper), "v4 restore rejects a hired candidate whose catalog wage was altered") + _expect(_runtime.create_snapshot() == live_before, "catalog Staff crosslink rejection is atomic") + + +func _test_rank_two_three_and_atomic_failure() -> void: + _reset_runtime(82_004) + var rank_before: Dictionary = _runtime.progression_service.create_snapshot() + var world_before: Dictionary = _runtime.world_service.create_snapshot() + var unmet: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command( + &"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two_unmet"} + )) + _expect(not unmet.is_success(), "Rank 2 rejects unmet reputation and asset requirements") + _expect(_runtime.progression_service.create_snapshot() == rank_before, "failed eligibility check leaves Progression exact") + _expect(_runtime.world_service.create_snapshot() == world_before, "failed eligibility check leaves World exact") + + _expect(_runtime._execute_update_progression_metrics(_direct_command( + &"update_progression_metrics", {&"metrics": {&"reputation": 25}} + )).is_success(), "Rank 2 reputation fact updates") + _expect(_runtime._execute_change_resources(_direct_command( + &"change_resources", {&"deltas": {&"gold": 5_000}} + )).is_success(), "Rank 2 asset threshold is funded") + var promoted_two: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command( + &"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two"} + )) + _expect(promoted_two.is_success(), "eligible Rank 2 promotion succeeds") + _expect(_runtime.progression_service.current_rank == 2 and _state.world_tree_rank == 2, "Progression and GameState commit Rank 2 together") + _expect(_runtime.world_service.get_bounds() == ProgressionService.RANK_BOUNDS[2], "Rank 2 commits canonical World bounds") + var replay_before: Dictionary = _runtime.create_snapshot() + var replay: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command( + &"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two"} + )) + _expect(replay.is_success() and replay.is_replay, "same rank request ID replays the original success") + _expect(_runtime.create_snapshot() == replay_before, "rank request replay is completely read-only") + var skip: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command( + &"set_world_tree_rank", {&"rank": 4, &"request_id": &"rank_four_skip"} + )) + _expect(not skip.is_success() and _runtime.progression_service.current_rank == 2, "rank progression cannot skip Rank 3") + var rank_three_guests_ok := true + var rank_guest_definition: StringName = _runtime.get_mvp_guest_definition_ids()[0] + for index in range(300): + var guest_id := StringName("rank_three_guest_%03d" % index) + var spawned: CommandResult = _runtime._execute_spawn_guest(_direct_command( + &"spawn_guest", + { + &"guest_id": guest_id, + &"guest_type_id": rank_guest_definition, + &"gold": 0, + &"options": { + &"current_cell": Vector3i.ZERO, + &"planned_exit_at": 10_000, + }, + } + )) + rank_three_guests_ok = rank_three_guests_ok and spawned.is_success() + var lifetime_updated: CommandResult = _runtime._execute_update_progression_metrics(_direct_command( + &"update_progression_metrics", + {&"metrics": {&"faith_lifetime": 100}} + )) + _expect( + rank_three_guests_ok and lifetime_updated.is_success() \ + and int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 300, + "Rank 3 lifetime facts derive from 300 authoritative Guest records" + ) + var promoted_three: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command( + &"set_world_tree_rank", {&"rank": 3, &"request_id": &"rank_three"} + )) + _expect(promoted_three.is_success(), "eligible Rank 3 promotion succeeds") + _expect(_runtime.progression_service.current_rank == 3 and _state.world_tree_rank == 3, "Progression and GameState commit Rank 3 together") + var saw_rank_event := false + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == &"WORLD_TREE_RANK_ADVANCED" and int(event.payload.get(&"rank", 0)) == 3: + saw_rank_event = true + _expect(saw_rank_event, "successful promotion publishes WORLD_TREE_RANK_ADVANCED") + + _reset_runtime(82_005) + _runtime.progression_service.update_metrics({&"reputation": 25}) + _runtime._execute_change_resources(_direct_command(&"change_resources", {&"deltas": {&"gold": 5_000}})) + _expect(_runtime._expand_world_to_rank(3), "rank apply-failure fixture expands World and Path coherently") + var atomic_before := { + &"progression": _runtime.progression_service.create_snapshot(), + &"world": _runtime.world_service.create_snapshot(), + &"path": _runtime.path_service.create_snapshot(), + &"state": _state.create_snapshot(), + &"credits": _runtime._movement_credits.duplicate(true), + &"events": _runtime.domain_events.create_snapshot(), + &"ids": _runtime.id_generator.create_snapshot(), + } + var apply_failure: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command( + &"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two_apply_failure"} + )) + _expect(not apply_failure.is_success(), "World/Path apply failure rejects promotion") + _expect(_runtime.progression_service.create_snapshot() == atomic_before[&"progression"], "failed rank apply restores Progression exactly") + _expect(_runtime.world_service.create_snapshot() == atomic_before[&"world"], "failed rank apply restores World exactly") + _expect(_runtime.path_service.create_snapshot() == atomic_before[&"path"], "failed rank apply restores Path exactly") + _expect(_state.create_snapshot() == atomic_before[&"state"], "failed rank apply restores GameState exactly") + _expect(_runtime._movement_credits == atomic_before[&"credits"], "failed rank apply restores movement credits exactly") + _expect(_runtime.domain_events.create_snapshot() == atomic_before[&"events"], "failed rank apply restores DomainEvents exactly") + _expect(_runtime.id_generator.create_snapshot() == atomic_before[&"ids"], "failed rank apply restores stable IDs exactly") + + +func _test_v3_migration_and_v4_tamper_guards() -> void: + _reset_runtime(82_006) + _runtime._execute_change_resources(_direct_command(&"change_resources", {&"deltas": {&"faith": 12}})) + for index in range(2): + var guest_id := StringName("legacy_guest_%d" % index) + var spawned: CommandResult = _runtime._execute_spawn_guest(_direct_command( + &"spawn_guest", + { + &"guest_id": guest_id, + &"guest_type_id": _runtime.get_mvp_guest_definition_ids()[index], + &"gold": 0, + &"options": {&"current_cell": Vector3i.ZERO}, + } + )) + _expect(spawned.is_success(), "legacy migration fixture Guest %d spawns" % index) + _runtime.guest_service.despawn_guest(&"legacy_guest_1", _clock.absolute_minute) + var v3: Dictionary = _runtime.create_snapshot() + var v3_runtime: Dictionary = v3[&"systems"][&"runtime"] + v3_runtime[&"runtime_schema_version"] = 3 + v3_runtime.erase(&"operations") + v3_runtime.erase(&"catalog_manifest") + v3_runtime.erase(&"progression") + v3_runtime.erase(&"hired_staff_candidate_ids") + for infrastructure_key: StringName in [ + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", + ]: + v3_runtime.erase(infrastructure_key) + _expect(_runtime.restore_snapshot(v3), "Runtime v3 Rank 1 snapshot migrates without guessed promotion evidence") + _expect(_runtime.progression_service.current_rank == 1, "v3 migration creates fresh Rank 1 Progression") + _expect(int(_runtime.progression_service.metrics[&"asset_gold"]) == _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT), "v3 migration derives asset Gold from the authoritative ledger") + _expect(int(_runtime.progression_service.metrics[&"faith_lifetime"]) == 12, "v3 migration derives lifetime Faith from current Faith") + _expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 2, "v3 migration derives lifetime Guests from active plus persistent records") + _expect(_runtime.get_mvp_guest_definition_ids().size() == 32, "v3 migration registers the current 32 catalog definitions") + var migrated: Dictionary = _runtime.create_snapshot() + _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6, "v3 migration emits canonical Runtime schema v6") + var digest := SimulationDigest.compute(migrated) + _expect(_runtime.restore_snapshot(migrated), "canonical v5 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v5 restore is exact") + + var progressed_legacy: Dictionary = v3.duplicate(true) + progressed_legacy[&"game_state"][&"world_tree_rank"] = 2 + var live_before: Dictionary = _runtime.create_snapshot() + _expect(not _runtime.restore_snapshot(progressed_legacy), "v3 Rank 2 snapshot is rejected because promotion evidence is absent") + _expect(_runtime.create_snapshot() == live_before, "rejected progressed v3 migration is atomic") + + var corruptions: Array[Dictionary] = [] + var manifest: Dictionary = migrated.duplicate(true) + manifest[&"systems"][&"runtime"][&"catalog_manifest"][&"guest_definition_ids"][0] = "tampered_guest" + corruptions.append({&"label": "catalog manifest", &"snapshot": manifest}) + var definition: Dictionary = migrated.duplicate(true) + var catalog_id := String(_runtime.get_mvp_guest_definition_ids()[0]) + for raw_definition: Dictionary in definition[&"systems"][&"runtime"][&"guests"][&"definitions"]: + if String(raw_definition[&"guest_type_id"]) == catalog_id: + raw_definition[&"wealth_min"] = int(raw_definition[&"wealth_min"]) + 1 + break + corruptions.append({&"label": "catalog Guest definition", &"snapshot": definition}) + var progression: Dictionary = migrated.duplicate(true) + progression[&"systems"][&"runtime"][&"progression"][&"current_rank"] = 2 + corruptions.append({&"label": "Progression history", &"snapshot": progression}) + var rank: Dictionary = migrated.duplicate(true) + rank[&"game_state"][&"world_tree_rank"] = 2 + corruptions.append({&"label": "GameState rank", &"snapshot": rank}) + var bounds: Dictionary = migrated.duplicate(true) + bounds[&"systems"][&"runtime"][&"world"][&"bounds"][&"max_x"] = 13 + corruptions.append({&"label": "World rank bounds", &"snapshot": bounds}) + var hired_manifest: Dictionary = migrated.duplicate(true) + hired_manifest[&"systems"][&"runtime"][&"hired_staff_candidate_ids"] = ["not_a_candidate"] + corruptions.append({&"label": "hired candidate manifest", &"snapshot": hired_manifest}) + for corruption: Dictionary in corruptions: + live_before = _runtime.create_snapshot() + _expect( + not _runtime.restore_snapshot(corruption[&"snapshot"]) + and _runtime.create_snapshot() == live_before, + "%s tamper is rejected atomically" % corruption[&"label"] + ) + + +func _test_twenty_four_hour_replay() -> void: + _reset_runtime(82_007) + var spawn: CommandResult = _runtime._execute_spawn_guest(_direct_command( + &"spawn_guest", + { + &"guest_id": &"replay_guest", + &"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0], + &"gold": 100, + &"options": {&"current_cell": Vector3i.ZERO, &"planned_exit_at": _clock.absolute_minute + 2_000}, + } + )) + var hire: CommandResult = _runtime._execute_hire_staff_candidate(_direct_command( + &"hire_staff_candidate", {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO} + )) + _expect(spawn.is_success() and hire.is_success(), "24-hour replay fixture creates catalog Guest and Staff") + var start: Dictionary = _runtime.create_snapshot() + var second_clock = load("res://autoload/game_clock.gd").new() + var second_state = load("res://autoload/game_state.gd").new() + var second_runtime = load("res://autoload/simulation_runtime.gd").new() + second_clock.reset(1, 6, 0) + second_state.reset() + second_runtime.initialize(second_clock, second_state, 82_007) + second_clock.set_paused(true) + _expect(second_runtime.restore_snapshot(start), "24-hour replay clone restores exact v4 start") + _clock.set_paused(false) + second_clock.set_paused(false) + _clock.advance_minutes(1_440) + second_clock.advance_minutes(1_440) + _expect( + SimulationDigest.compute(_runtime.create_snapshot()) \ + == SimulationDigest.compute(second_runtime.create_snapshot()), + "catalog, Staff hire, Progression, and six-Need state replay exactly for 24 hours" + ) + second_runtime.free() + second_clock.free() + second_state.free() + + +func _direct_command(command_type: StringName, payload: Dictionary) -> SimulationCommand: + _direct_command_id += 1 + return SimulationCommand.new( + _direct_command_id, + _clock.absolute_minute, + _clock.absolute_minute, + 100, + command_type, + &"test", + &"", + payload, + &"" + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/mvp/test_runtime_content_progression.gd.uid b/tests/mvp/test_runtime_content_progression.gd.uid new file mode 100644 index 0000000..7030ac9 --- /dev/null +++ b/tests/mvp/test_runtime_content_progression.gd.uid @@ -0,0 +1 @@ +uid://b46fufnam07fa diff --git a/tests/mvp/test_runtime_operations_acceptance.gd b/tests/mvp/test_runtime_operations_acceptance.gd new file mode 100644 index 0000000..cd9403d --- /dev/null +++ b/tests/mvp/test_runtime_operations_acceptance.gd @@ -0,0 +1,484 @@ +extends SceneTree + +const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY +const RUNTIME_SNAPSHOT_VERSION := 6 + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("MVP: production Operations acceptance") + _test_waiting_choice_and_command_exactly_once() + _test_twenty_four_hour_actual_tick_smoke() + _test_full_partial_payroll_bankruptcy_and_v6_tamper() + _test_fourteen_day_boundary_snapshot_determinism() + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _new_fixture(seed: int, enable_operations := true) -> Dictionary: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + var initialized: Dictionary = runtime.initialize(clock, state, seed) + clock.set_paused(true) + _expect(bool(initialized.get(&"valid", false)), "fixture Runtime initializes") + var initial_snapshot: Dictionary = runtime.create_snapshot() + _expect( + int(initial_snapshot[&"systems"][&"runtime"].get( + &"runtime_schema_version", -1 + )) == RUNTIME_SNAPSHOT_VERSION, + "fixture emits Runtime snapshot schema v6" + ) + if enable_operations: + var enabled: Dictionary = runtime.enable_production_operations() + _expect(bool(enabled.get(&"ok", false)), "fixture enables production Operations") + return {&"clock": clock, &"state": state, &"runtime": runtime} + + +func _free_fixture(fixture: Dictionary) -> void: + var runtime: Variant = fixture.get(&"runtime") + var clock: Variant = fixture.get(&"clock") + var state: Variant = fixture.get(&"state") + if runtime != null: + runtime.free() + if clock != null: + clock.free() + if state != null: + state.free() + + +# Long acceptance slices deliberately exercise the production Operations +# adapter without claiming to execute unrelated actor AI on all 20,160 minutes. +# Aggregate clocks are aligned solely to preserve the Runtime transaction +# precondition; the separate 24-hour smoke above covers the actual Tick pipeline. +# One final rich snapshot (and the midpoint restore) is the Operations oracle. +func _advance_operations_boundary_slice(fixture: Dictionary, amount: int) -> int: + var clock: Variant = fixture[&"clock"] + var runtime: Variant = fixture[&"runtime"] + var advanced := 0 + while advanced < amount: + if runtime.operations_coordinator != null \ + and runtime.operations_coordinator.is_terminal(): + break + # Operations has no recurring boundary finer than 30 minutes. The + # coordinator still performs its own unthinned internal minute traversal; + # unrelated Runtime AI is intentionally outside this accelerated oracle. + var step := mini(30, amount - advanced) + var target: int = clock.absolute_minute + step + clock.absolute_minute = target + clock.call("_sync_calendar_fields") + clock.snapshot_version += step + runtime.call("_advance_operations_time", target) + if bool(runtime.get_operations_report().get(&"runtime_failure_latched", false)): + break + if runtime.operations_coordinator.is_terminal(): + # A terminal daily close commits TIME_BOUNDARIES and deliberately skips + # the Runtime suffix; its other aggregates therefore remain at X - 1. + _align_operations_fixture_clocks(runtime, target - 1) + advanced += step + break + # These acceptance fixtures contain no Facilities, logistics, raids, or + # Staff tasks. Their clocks are aligned for Operations cross-link checks; + # their skipped actor/Shift behavior is explicitly outside this fast oracle. + _align_operations_fixture_clocks(runtime, target) + advanced += step + return advanced + + +func _align_operations_fixture_clocks(runtime: Variant, minute: int) -> void: + runtime.inventory_service.current_minute = minute + runtime.facility_service.current_minute = minute + runtime.guest_service.current_minute = minute + runtime.staff_service.current_minute = minute + runtime.authority_service.current_minute = minute + runtime.defense_service.current_minute = minute + runtime.construction_service.current_minute = minute + runtime.excavation_service.current_minute = minute + + +func _operations_slice_is_healthy(fixture: Dictionary) -> bool: + var runtime: Variant = fixture[&"runtime"] + var report: Dictionary = runtime.get_operations_report() + var foreground: EventInstance = runtime.event_service.get_foreground_instance() + return not bool(report.get(&"runtime_failure_latched", true)) \ + and int(report.get(&"deferred_runtime_minute", -2)) == -1 \ + and not runtime.operations_coordinator.pause_requested \ + and not runtime.operations_coordinator.is_terminal() \ + and not runtime.operations_coordinator.is_external_quarantined() \ + and (foreground == null or foreground.state != EventInstance.State.WAITING_CHOICE) + + +func _test_waiting_choice_and_command_exactly_once() -> void: + var fixture := _new_fixture(73_001) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var state: Variant = fixture[&"state"] + + var queued: CommandResult = runtime.event_service.enqueue_candidate( + &"relationship.threshold", + [], + clock.absolute_minute, + {&"deduplication_key": &"operations-acceptance-choice"} + ) + var started: CommandResult = runtime.event_service.start_next_event( + clock.absolute_minute, {} + ) + _expect( + queued.is_success() and started.is_success() + and bool(started.details.get(&"waiting_for_choice", false)), + "foreground Event enters WAITING_CHOICE" + ) + clock.set_paused(false) + var before_choice_minute: int = clock.absolute_minute + clock.advance_minutes(5) + var foreground: EventInstance = runtime.event_service.get_foreground_instance() + _expect( + clock.paused and clock.absolute_minute == before_choice_minute + 1, + "WAITING_CHOICE pauses at the first committed minute and stops catch-up" + ) + _expect( + foreground != null and foreground.state == EventInstance.State.WAITING_CHOICE, + "paused foreground ownership remains intact" + ) + if foreground != null: + var choice_result: CommandResult = runtime.submit_command( + &"submit_event_choice", + { + &"event_instance_id": foreground.event_instance_id, + &"choice_instance_id": foreground.choice_instance_id, + &"choice_id": &"acknowledge", + }, + &"player", + foreground.event_instance_id, + 100, + -1, + &"operations.acceptance.choice" + ) + _expect( + choice_result.is_success() + and runtime.event_service.get_foreground_instance() == null + and not clock.paused, + "submitting the stable Choice token resolves the Event and resumes time" + ) + + clock.set_paused(true) + var gold_before: int = state.get_resource(&"gold") + var first: CommandResult = runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": 17}}, + &"player", + &"", + 50, + -1, + &"operations.acceptance.exactly-once" + ) + var gold_after_first: int = state.get_resource(&"gold") + var retry: CommandResult = runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": 17}}, + &"player", + &"", + 50, + -1, + &"operations.acceptance.exactly-once" + ) + _expect( + first.is_success() and gold_after_first == gold_before + 17, + "first public command commits its resource delta once" + ) + _expect( + not retry.is_success() + and retry.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY + and state.get_resource(&"gold") == gold_after_first, + "transport retry with the same idempotency key cannot apply twice" + ) + var command_snapshot: Dictionary = runtime.create_snapshot() + _expect( + runtime.restore_snapshot(command_snapshot), + "command history survives an exact v6 snapshot round trip" + ) + var restored_retry: CommandResult = runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": 17}}, + &"player", + &"", + 50, + -1, + &"operations.acceptance.exactly-once" + ) + _expect( + not restored_retry.is_success() + and restored_retry.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY + and state.get_resource(&"gold") == gold_after_first, + "restored idempotency history still rejects the same command" + ) + _free_fixture(fixture) + + +func _test_twenty_four_hour_actual_tick_smoke() -> void: + var fixture := _new_fixture(73_004) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var start_minute: int = clock.absolute_minute + clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY) + _expect( + clock.absolute_minute - start_minute == SimulationKernel.MINUTES_PER_DAY, + "actual SimulationKernel ticks complete an uninterrupted 24-hour smoke slice" + ) + _expect( + _operations_slice_is_healthy(fixture) + and runtime.operations_coordinator.current_minute == clock.absolute_minute + and runtime.operations_coordinator.get_day_summaries().size() == 1, + "24-hour actual-tick slice has no Choice, deferred minute, quarantine, or terminal stop" + ) + var snapshot: Dictionary = runtime.create_snapshot() + _expect( + runtime.restore_snapshot(snapshot) + and runtime.latest_digest == SimulationDigest.compute(snapshot), + "24-hour actual-tick v6 snapshot round trip is exact" + ) + _free_fixture(fixture) + + +func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void: + var fixture := _new_fixture(73_002, false) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var state: Variant = fixture[&"state"] + var candidate: Dictionary = runtime.get_mvp_staff_candidate_payload(&"staff_akane") + var daily_wage := int(candidate[&"options"][&"daily_wage"]) + var hired: CommandResult = runtime.submit_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}, + &"player", + &"staff_akane", + 80, + -1, + &"operations.acceptance.hire" + ) + _expect(hired.is_success() and daily_wage > 50, "payroll fixture hires one wage-bearing Staff member") + var enabled: Dictionary = runtime.enable_production_operations() + _expect(bool(enabled.get(&"ok", false)), "payroll fixture enables Operations after the pre-opening hire") + + var full_advanced := _advance_operations_boundary_slice( + fixture, SimulationKernel.MINUTES_PER_DAY + ) + var full: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000001") + _expect( + full_advanced == SimulationKernel.MINUTES_PER_DAY + and _operations_slice_is_healthy(fixture) + and clock.absolute_minute == SimulationKernel.MINUTES_PER_DAY + 6 * 60 + and int(full.get(&"new_wages_total", -1)) == daily_wage + and int(full.get(&"paid_total", -1)) == daily_wage + and int(full.get(&"unpaid_after_total", -1)) == 0, + "Day 1 payroll is paid in full and closes at exactly 06:00" + ) + + var cash_now: int = runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) + var drained: CommandResult = runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": -(cash_now - 50)}}, + &"player", + &"", + 90, + -1, + &"operations.acceptance.cash-drain" + ) + _expect( + drained.is_success() + and runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) == 50, + "fixture leaves exactly 50 Gold for a deterministic partial payroll" + ) + var partial_advanced := _advance_operations_boundary_slice( + fixture, SimulationKernel.MINUTES_PER_DAY + ) + var partial: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000002") + _expect( + partial_advanced == SimulationKernel.MINUTES_PER_DAY + and _operations_slice_is_healthy(fixture) + and clock.absolute_minute == 2 * SimulationKernel.MINUTES_PER_DAY + 6 * 60 + and int(partial.get(&"new_wages_total", -1)) == daily_wage + and int(partial.get(&"paid_total", -1)) == 50 + and int(partial.get(&"unpaid_after_total", -1)) == daily_wage - 50, + "Day 2 payroll pays available Gold only and carries exact arrears" + ) + + var terminal_advanced := _advance_operations_boundary_slice( + fixture, 2 * SimulationKernel.MINUTES_PER_DAY + ) + var coordinator: OperationsCoordinator = runtime.operations_coordinator + var last_three_negative := true + for day in [2, 3, 4]: + var summary: Dictionary = coordinator.get_day_summary(day) + last_three_negative = last_three_negative \ + and int(summary.get(&"net_profit", 0)) < 0 \ + and int((summary.get(&"settlement", {}) as Dictionary).get( + &"arrears_total", 0 + )) > 0 + _expect( + terminal_advanced == 2 * SimulationKernel.MINUTES_PER_DAY + and clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60 + and last_three_negative + and coordinator.get_consecutive_insolvency_count() == 3 + and coordinator.get_terminal_state() == &"BANKRUPT", + "three consecutive negative closes with arrears enter BANKRUPT at the third boundary" + ) + var terminal_snapshot: Dictionary = runtime.create_snapshot() + var terminal_digest := SimulationDigest.compute(terminal_snapshot) + _expect( + runtime.restore_snapshot(terminal_snapshot), + "BANKRUPT terminal v6 snapshot restores without clearing terminal state" + ) + clock.advance_minutes(180) + _expect( + clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60 + and SimulationDigest.compute(runtime.create_snapshot()) == terminal_digest, + "BANKRUPT is terminal: later time requests are exact no-ops" + ) + _expect( + not bool(runtime.acknowledge_operations_pause().get(&"ok", true)), + "BANKRUPT cannot be acknowledged away" + ) + + _assert_v6_tamper_rejected( + runtime, + terminal_snapshot, + func(tampered: Dictionary) -> void: + tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99, + "v6 rejects an out-of-range Operations action-point total" + ) + _assert_v6_tamper_rejected( + runtime, + terminal_snapshot, + func(tampered: Dictionary) -> void: + tampered[&"systems"][&"runtime"][&"next_command_id"] = int( + tampered[&"systems"][&"runtime"][&"next_command_id"] + ) + 1, + "v6 rejects command high-water gaps" + ) + _assert_v6_tamper_rejected( + runtime, + terminal_snapshot, + func(tampered: Dictionary) -> void: + var accepted: Array = tampered[&"systems"][&"runtime"][&"command_queue"][&"accepted"] + if not accepted.is_empty(): + accepted[0][&"idempotency_key"] = &"tampered.accepted.key", + "v6 rejects accepted-command/idempotency cross-link tampering" + ) + _assert_v6_tamper_rejected( + runtime, + terminal_snapshot, + func(tampered: Dictionary) -> void: + var summaries: Array = tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"day_summaries"] + summaries[0][&"category_totals"][&"wages"] = int( + summaries[0][&"category_totals"][&"wages"] + ) + 1, + "v6 rejects DaySummary/payroll wage reconciliation tampering" + ) + _assert_v6_tamper_rejected( + runtime, + terminal_snapshot, + func(tampered: Dictionary) -> void: + tampered[&"systems"][&"runtime"][&"unexpected_v6_field"] = true, + "v6 rejects unknown Runtime fields" + ) + _assert_v6_tamper_rejected( + runtime, + terminal_snapshot, + func(tampered: Dictionary) -> void: + tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true, + "v6 rejects unknown Coordinator fields" + ) + _free_fixture(fixture) + + +func _assert_v6_tamper_rejected( + runtime: Variant, + canonical: Dictionary, + mutator: Callable, + label: String +) -> void: + var before: Dictionary = runtime.create_snapshot() + var tampered: Dictionary = canonical.duplicate(true) + mutator.call(tampered) + var rejected: bool = not runtime.restore_snapshot(tampered) + _expect(rejected and runtime.create_snapshot() == before, label + " atomically") + + +func _test_fourteen_day_boundary_snapshot_determinism() -> void: + var seed := 73_003 + var continuous := _new_fixture(seed) + var continuous_clock: Variant = continuous[&"clock"] + var continuous_runtime: Variant = continuous[&"runtime"] + var continuous_start: int = continuous_clock.absolute_minute + var first_advanced := _advance_operations_boundary_slice( + continuous, FOURTEEN_DAYS_MINUTES / 2 + ) + var midpoint: Dictionary = continuous_runtime.create_snapshot() + _expect( + first_advanced == FOURTEEN_DAYS_MINUTES / 2 + and continuous_clock.absolute_minute == continuous_start + FOURTEEN_DAYS_MINUTES / 2 + and _operations_slice_is_healthy(continuous), + "boundary-accelerated midpoint is captured after seven healthy days" + ) + var second_advanced := _advance_operations_boundary_slice( + continuous, FOURTEEN_DAYS_MINUTES / 2 + ) + var continuous_snapshot: Dictionary = continuous_runtime.create_snapshot() + var continuous_digest := SimulationDigest.compute(continuous_snapshot) + _expect( + first_advanced + second_advanced == FOURTEEN_DAYS_MINUTES + and continuous_clock.absolute_minute - continuous_start == FOURTEEN_DAYS_MINUTES + and _operations_slice_is_healthy(continuous), + "continuous boundary-accelerated Operations advances exactly 14 days = 20,160 minutes" + ) + _expect( + continuous_runtime.operations_coordinator.get_day_summaries().size() == 14, + "continuous boundary run commits all fourteen daily closes" + ) + + var resumed := _new_fixture(seed, false) + var resumed_clock: Variant = resumed[&"clock"] + var resumed_runtime: Variant = resumed[&"runtime"] + var restored: bool = resumed_runtime.restore_snapshot(midpoint) + _expect(restored, "fresh Runtime restores the enabled v6 midpoint snapshot") + var resumed_advanced := 0 + if restored: + resumed_advanced = _advance_operations_boundary_slice( + resumed, FOURTEEN_DAYS_MINUTES / 2 + ) + var resumed_snapshot: Dictionary = resumed_runtime.create_snapshot() + _expect( + restored and resumed_advanced == FOURTEEN_DAYS_MINUTES / 2 + and resumed_clock.absolute_minute == 6 * 60 + FOURTEEN_DAYS_MINUTES + and _operations_slice_is_healthy(resumed), + "restored boundary-accelerated Operations completes seven healthy days" + ) + _expect( + restored and SimulationDigest.compute(resumed_snapshot) == continuous_digest + and resumed_snapshot == continuous_snapshot, + "continuous and midpoint-restored 14-day boundary runs have identical snapshot and Digest" + ) + _free_fixture(continuous) + _free_fixture(resumed) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/mvp/test_runtime_operations_acceptance.gd.uid b/tests/mvp/test_runtime_operations_acceptance.gd.uid new file mode 100644 index 0000000..1a33aa0 --- /dev/null +++ b/tests/mvp/test_runtime_operations_acceptance.gd.uid @@ -0,0 +1 @@ +uid://cmfsedkuddbhg diff --git a/tests/mvp/test_runtime_operations_integration.gd b/tests/mvp/test_runtime_operations_integration.gd new file mode 100644 index 0000000..1b4564b --- /dev/null +++ b/tests/mvp/test_runtime_operations_integration.gd @@ -0,0 +1,381 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("MVP: Runtime operations integration") + _reset_runtime() + _test_disabled_compatibility_and_enable() + _test_enabled_snapshot_round_trip() + _test_seed_424242_second_arrival_boundary() + _test_after_mutation_notifications_are_atomic() + _test_deferred_failure_backlog_and_exact_retry() + _test_daily_payroll_financials_and_crosslink_tamper() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed: int = 20260827) -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _expect(bool(report.get(&"valid", false)), "Runtime initializes before Operations opt-in") + _clock.set_paused(true) + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_disabled_compatibility_and_enable() -> void: + var disabled: Dictionary = _runtime.get_operations_report() + _expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests") + var disabled_snapshot: Dictionary = _runtime.create_snapshot() + var disabled_runtime: Dictionary = disabled_snapshot[&"systems"][&"runtime"] + _expect(int(disabled_runtime[&"runtime_schema_version"]) == 6, "Runtime snapshot advances to schema v6") + _expect( + disabled_runtime[&"operations"] == { + &"wrapper_version": 1, + &"enabled": false, + &"personal_action_points": 0, + &"runtime_failure_latched": false, + &"runtime_failure_report": {}, + &"deferred_runtime_minute": -1, + &"coordinator": {}, + }, + "disabled Operations wrapper has one canonical shape" + ) + var enabled: Dictionary = _runtime.enable_production_operations() + var enabled_ok := bool(enabled.get(&"ok", false)) + _expect(enabled_ok, "Production Operations opt in succeeds only at Day 1 06:00") + if not enabled_ok: + print(" enable diagnostic: %s" % JSON.stringify(enabled, "", true, true)) + return + _expect( + _runtime.operations_coordinator.current_minute == 360, + "enable processes the 06:00 boundary from minute 359 exactly once" + ) + _expect( + int(_runtime.get_operations_report()[&"personal_action_points"]) == 2, + "first morning boundary restores two personal action points" + ) + + +func _test_enabled_snapshot_round_trip() -> void: + if _runtime.operations_coordinator == null: + return + var snapshot: Dictionary = _runtime.create_snapshot() + var expected_digest := SimulationDigest.compute(snapshot) + _expect(_runtime.restore_snapshot(snapshot), "enabled Runtime v5 snapshot restores") + _expect(_runtime.latest_digest == expected_digest, "enabled Runtime v5 round trip is exact") + var operations: Dictionary = _runtime.get_operations_report() + _expect( + operations[&"enabled"] and _runtime.operations_coordinator.current_minute == _clock.absolute_minute, + "restored Coordinator clock cross-link remains exact" + ) + + +func _test_seed_424242_second_arrival_boundary() -> void: + _reset_runtime(424242) + var enabled: Dictionary = _runtime.enable_production_operations() + _expect(bool(enabled.get(&"ok", false)), "seed 424242 enables production Operations") + if not bool(enabled.get(&"ok", false)): + return + var before_count: int = _runtime.guest_service.guest_count() + _clock.advance_minutes(30) + var report: Dictionary = _runtime.get_operations_report() + _expect( + _clock.absolute_minute == 390 \ + and int(report.get(&"deferred_runtime_minute", -2)) == -1 \ + and not bool(report.get(&"runtime_failure_latched", true)) \ + and not _runtime.operations_coordinator.is_external_quarantined(), + "seed 424242 completes the minute 390 arrival boundary without quarantine" + ) + _expect( + _runtime.operations_coordinator.get_population_count() \ + == _runtime.guest_service.guest_count() \ + and _runtime.guest_service.guest_count() >= before_count, + "second arrival keeps Coordinator and Guest population cross-linked" + ) + + +func _test_after_mutation_notifications_are_atomic() -> void: + _reset_runtime(424242) + _expect( + bool(_runtime.enable_production_operations().get(&"ok", false)), + "after-mutation rollback fixture enables Operations" + ) + _clock.advance_minutes(29) + var before := { + &"economy": _runtime.economy_service.create_snapshot(), + &"guests": _runtime.guest_service.create_snapshot(), + &"path": _runtime.path_service.create_snapshot(), + &"movement": _runtime._movement_credits.duplicate(true), + &"progression": _runtime.progression_service.create_snapshot(), + &"events": _runtime.domain_events.create_snapshot(), + &"ids": _runtime.id_generator.create_snapshot(), + } + var signals := {&"guest": 0, &"economy": 0, &"arrival": 0} + _runtime.guest_service.guest_spawned.connect( + func(_guest: GuestRuntime) -> void: signals[&"guest"] += 1 + ) + _runtime.economy_service.transaction_posted.connect( + func(_transaction_id: StringName, _entries: Array) -> void: + signals[&"economy"] += 1 + ) + _runtime.operations_coordinator.arrivals_committed.connect( + func(_report: Dictionary) -> void: signals[&"arrival"] += 1 + ) + _runtime.set_operations_test_boundary_hook( + &"ARRIVAL_PLAN", + func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + return { + &"ok": phase != _runtime.OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE, + &"reason": &"FORCED_AFTER_MUTATION_FAILURE", + } + ) + _clock.advance_minutes(1) + var after := { + &"economy": _runtime.economy_service.create_snapshot(), + &"guests": _runtime.guest_service.create_snapshot(), + &"path": _runtime.path_service.create_snapshot(), + &"movement": _runtime._movement_credits.duplicate(true), + &"progression": _runtime.progression_service.create_snapshot(), + &"events": _runtime.domain_events.create_snapshot(), + &"ids": _runtime.id_generator.create_snapshot(), + } + _expect( + _clock.absolute_minute == 390 and _clock.paused and before == after, + "failure after the first arrival mutation restores every external domain exactly" + ) + _expect( + int(signals[&"guest"]) == 0 and int(signals[&"economy"]) == 0 \ + and int(signals[&"arrival"]) == 0, + "rolled-back arrival leaks no Guest, Economy, or Coordinator success signal" + ) + _runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", Callable()) + var retried: Dictionary = _runtime.acknowledge_operations_pause() + _expect( + bool(retried.get(&"ok", false)) and int(signals[&"guest"]) > 0 \ + and int(signals[&"economy"]) > 0 and int(signals[&"arrival"]) == 1, + "successful retry flushes each staged service notification after commit" + ) + + +func _test_deferred_failure_backlog_and_exact_retry() -> void: + _reset_runtime(424242) + _expect( + bool(_runtime.enable_production_operations().get(&"ok", false)), + "deferred retry fixture enables Operations" + ) + _clock.advance_minutes(29) + var scheduled: CommandResult = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": &"scheduled_boundary_guest", + &"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0], + &"gold": 25, + &"options": { + &"current_cell": Vector3i.ZERO, + &"planned_exit_at": 2_000, + }, + }, + &"test", &"scheduled_boundary_guest", 100, 390, + &"operations.test.scheduled.spawn" + ) + _expect(scheduled.is_success(), "minute 390 command is queued before the boundary") + var counts := {&"guest": 0, &"arrival": 0, &"hook": 0} + _runtime.guest_service.guest_spawned.connect( + func(_guest: GuestRuntime) -> void: counts[&"guest"] += 1 + ) + _runtime.operations_coordinator.arrivals_committed.connect( + func(_report: Dictionary) -> void: counts[&"arrival"] += 1 + ) + _runtime.set_operations_test_boundary_hook( + &"ARRIVAL_PLAN", + func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + counts[&"hook"] += 1 + return { + &"ok": false, + &"reason": &"FORCED_PREPARE_FAILURE", + &"phase": phase, + } + ) + _clock.set_speed(4) + var processed: int = _clock.process_real_delta(5.0) + var failed_report: Dictionary = _runtime.get_operations_report() + _expect( + processed == 1 and _clock.absolute_minute == 390 and _clock.paused, + "boundary failure stops an eight-tick backlog at the failed minute" + ) + _expect( + int(failed_report[&"deferred_runtime_minute"]) == 390 \ + and failed_report[&"runtime_failure_latched"] \ + and _runtime.operations_coordinator.current_minute == 389, + "failed boundary persists one canonical deferred shape" + ) + _expect( + _runtime.guest_service.has_guest(&"scheduled_boundary_guest") \ + and int(counts[&"guest"]) == 1 \ + and int(counts[&"arrival"]) == 0, + "pre-boundary command commits once while failed callback emits no arrival notification" + ) + var interrupted: Dictionary = _runtime.create_snapshot() + _expect( + _runtime.restore_snapshot(interrupted), + "deferred v5 snapshot restores before acknowledgement" + ) + _runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", Callable()) + var retry_guest_signals := {&"count": 0} + _runtime.guest_service.guest_spawned.connect( + func(_guest: GuestRuntime) -> void: retry_guest_signals[&"count"] += 1 + ) + var acknowledged: Dictionary = _runtime.acknowledge_operations_pause() + _expect( + bool(acknowledged.get(&"ok", false)) \ + and bool(acknowledged.get(&"suffix_replayed", false)) \ + and _clock.absolute_minute == 390 \ + and _runtime.operations_coordinator.current_minute == 390, + "acknowledgement retries the same boundary and suffix exactly once" + ) + var completion_count := 0 + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == &"COMMAND_COMPLETED" \ + and event.causation_command_id == scheduled.command_id: + completion_count += 1 + _expect( + completion_count == 1 \ + and _runtime.command_queue.processed_count() == 1 \ + and _runtime.guest_service.current_minute == 390 \ + and int(retry_guest_signals[&"count"]) \ + == _runtime.operations_coordinator.get_arrival_reports()[-1][&"total_count"], + "retry never replays the command and advances the skipped Runtime suffix once" + ) + var completed: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(completed), "completed deferred retry round-trips exactly") + + +func _test_daily_payroll_financials_and_crosslink_tamper() -> void: + _reset_runtime(811_004) + var hired: CommandResult = _runtime.submit_command( + &"hire_staff_candidate", + {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}, + &"player", &"staff_akane", 100, -1, &"operations.payroll.hire.akane" + ) + _expect(hired.is_success(), "daily payroll fixture hires one catalog Staff member") + _expect( + bool(_runtime.enable_production_operations().get(&"ok", false)), + "daily payroll fixture enables Operations after hiring" + ) + _clock.advance_minutes(1_440) + var summary: Dictionary = _runtime.operations_coordinator.get_day_summary(1) + var settlement: Dictionary = summary.get(&"settlement", {}) + _expect( + _clock.absolute_minute == 1_800 \ + and int(summary.get(&"closed_at", -1)) == 1_800 \ + and int(summary[&"category_totals"][&"wages"]) == 120, + "06:00 closes Day 1 once and accrues only the new daily wage" + ) + _expect( + int(settlement.get(&"paid_total", -1)) == 120 \ + and int(settlement.get(&"arrears_total", -1)) == 0 \ + and _runtime.staff_service.get_staff(&"staff_akane").unpaid_wages == 0, + "sufficient Gold pays the full lexical payroll with no arrears" + ) + var wage_transaction: StringName = _runtime.economy_service.get_transaction_id_for_idempotency_key( + &"operations.wage.day.00000001" + ) + var wage_entries: Array[LedgerEntry] = _runtime.economy_service.get_transaction_entries(wage_transaction) + _expect( + wage_entries.size() == 1 \ + and wage_entries[0].category == &"WAGE" \ + and wage_entries[0].absolute_minute == 1_800 \ + and wage_entries[0].amount == 120, + "paid payroll posts one canonical WAGE ledger line at the closing boundary" + ) + var canonical: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(canonical), "Day 1 payroll/P&L v5 snapshot round-trips") + + var signing_category: Dictionary = canonical.duplicate(true) + for raw_entry: Dictionary in signing_category[&"systems"][&"runtime"][&"economy"][&"entries"]: + if String(raw_entry[&"category"]) == "STAFF_SIGNING_COST": + raw_entry[&"category"] = "TRANSFER" + break + _expect( + _rejects_atomically(signing_category), + "closed non-WAGE ledger category tamper is rejected atomically" + ) + var wage_category: Dictionary = canonical.duplicate(true) + for raw_entry: Dictionary in wage_category[&"systems"][&"runtime"][&"economy"][&"entries"]: + if String(raw_entry[&"category"]) == "WAGE": + raw_entry[&"category"] = "TRANSFER" + break + _expect( + _rejects_atomically(wage_category), + "Staff settlement cannot outlive its canonical WAGE ledger evidence" + ) + var lifetime: Dictionary = canonical.duplicate(true) + lifetime[&"systems"][&"runtime"][&"progression"][&"metrics"][&"guest_lifetime"] += 1 + _expect( + _rejects_atomically(lifetime), + "Progression lifetime Guest inflation is rejected against active plus persistent Guests" + ) + var plan_class: Dictionary = canonical.duplicate(true) + var arrival_reports: Array = plan_class[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"arrival_reports"] + var changed_plan := false + for report: Dictionary in arrival_reports: + if not (report[&"plans"] as Array).is_empty(): + var plan: Dictionary = report[&"plans"][0] + plan[&"guest_class"] = "noble" if String(plan[&"guest_class"]) != "noble" else "laborer" + changed_plan = true + break + _expect( + changed_plan and _rejects_atomically(plan_class), + "arrival plan class cannot diverge from its persisted Guest definition" + ) + var downgraded: Dictionary = canonical.duplicate(true) + downgraded[&"systems"][&"runtime"][&"runtime_schema_version"] = 4 + _expect( + _rejects_atomically(downgraded), + "relabelled enabled v5 cannot discard Operations history through v4 migration" + ) + + +func _rejects_atomically(candidate: Dictionary) -> bool: + var before: Dictionary = _runtime.create_snapshot() + return not _runtime.restore_snapshot(candidate) \ + and _runtime.create_snapshot() == before + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/mvp/test_runtime_operations_integration.gd.uid b/tests/mvp/test_runtime_operations_integration.gd.uid new file mode 100644 index 0000000..fd5be86 --- /dev/null +++ b/tests/mvp/test_runtime_operations_integration.gd.uid @@ -0,0 +1 @@ +uid://dxchxjl371ti diff --git a/tests/performance/README.md b/tests/performance/README.md new file mode 100644 index 0000000..bcd9447 --- /dev/null +++ b/tests/performance/README.md @@ -0,0 +1,22 @@ +# PERF-MVP-001 acceptance harness + +This standalone harness composes the real World, Path, Facility, Guest, Staff, +Inventory, Logistics, Event, Economy, and performance-budget services through +their public APIs. + +The default command runs a smaller 24-hour smoke tier: + +```sh +godot --headless --path . --script res://tests/performance/test_perf_mvp_001.gd +``` + +The opt-in command runs the exact PERF-MVP-001 fixture: + +```sh +PERF_MVP_FULL=1 godot --headless --path . --script res://tests/performance/test_perf_mvp_001.gd +``` + +Exit status `0` means the selected tier passed, `1` means a validation or +performance budget failed, and `2` means the requested fixture was incomplete. +Every run prints a machine-readable `PERF_MVP_001_REPORT=` line with exact +fixture sizes, timings, backlog ages, validation results, and limitations. diff --git a/tests/performance/perf_mvp_001_harness.gd b/tests/performance/perf_mvp_001_harness.gd new file mode 100644 index 0000000..5ec24b6 --- /dev/null +++ b/tests/performance/perf_mvp_001_harness.gd @@ -0,0 +1,1031 @@ +class_name PerfMvp001Harness +extends RefCounted + +## Standalone PERF-MVP-001 fixture and acceptance harness. +## +## The harness deliberately uses only public service APIs. It does not reach +## into SimulationRuntime or service-private dictionaries to manufacture load. + +const WorldServiceType = preload("res://scripts/world/world_service.gd") +const PathServiceType = preload("res://scripts/world/path_service.gd") +const FacilityServiceType = preload("res://scripts/facility/facility_service.gd") +const GuestServiceType = preload("res://scripts/guest/guest_service.gd") +const StaffServiceType = preload("res://scripts/staff/staff_service.gd") +const InventoryServiceType = preload("res://scripts/inventory/inventory_service.gd") +const LogisticsRequestType = preload("res://scripts/inventory/logistics_request.gd") +const LogisticsDispatcherType = preload("res://scripts/inventory/logistics_dispatcher.gd") +const EventServiceType = preload("res://scripts/events/event_service.gd") +const EventInstanceType = preload("res://scripts/events/event_instance.gd") +const EconomyServiceType = preload("res://scripts/economy/economy_service.gd") +const PerformanceBudgetType = preload("res://scripts/core/performance_budget.gd") + +const FULL_TARGET := { + "open_cells": 20_000, + "guests": 200, + "staff": 50, + "facilities": 150, + "queue_entries": 500, + "unfinished_logistics": 200, + "active_events": 50, + "minutes": 1_440, + "speed": 4, +} + +const SMOKE_TARGET := { + "open_cells": 2_000, + "guests": 20, + "staff": 10, + "facilities": 15, + "queue_entries": 50, + "unfinished_logistics": 20, + "active_events": 5, + "minutes": 1_440, + "speed": 4, +} + +const STALE_BACKLOG_LIMIT_MINUTES := 120 +const FACILITY_SERVICE_MINUTES := 15 +const EVENT_DEFINITION_ID := &"perf_mvp_waiting_event" +const WAREHOUSE_ID := &"perf_warehouse" +const DESTINATION_ID := &"perf_destination" +const SUPPLY_ITEM_ID := &"perf_supply" +const CUSTOMER_ACCOUNT_ID := &"perf_customers" +const OPERATOR_ACCOUNT_ID := &"perf_operator" + +var _economy: Variant +var _runtime_errors: Array[Dictionary] = [] +var _logistics_requests: Array = [] +var _dispatcher: Variant +var _carriers: Dictionary = {} +var _maximum_facility_queue_age := 0 +var _maximum_logistics_backlog_age := 0 +var _stale_facility_queue_entries := 0 +var _stale_logistics_requests := 0 +var _path_query_failures := 0 +var _path_duration_samples: Array[int] = [] +var _domain_samples: Dictionary = {} + + +func run(tier: StringName = &"smoke") -> Dictionary: + _reset_run_state() + var normalized_tier := &"full" if String(tier).to_lower() == "full" else &"smoke" + var target: Dictionary = ( + FULL_TARGET.duplicate(true) if normalized_tier == &"full" else SMOKE_TARGET.duplicate(true) + ) + var report := { + "acceptance_id": "PERF-MVP-001", + "tier": String(normalized_tier), + "status": "INCOMPLETE", + "target": target.duplicate(true), + "actual_initial": {}, + "actual_final": {}, + "unmet_fixture_fields": [], + "validations": [], + "performance": {}, + "backlog": {}, + "runtime_errors": [], + "limitations": [ + "Standalone public-API composition; this does not measure scene rendering or UI work.", + "The logistics lifecycle is orchestrated by the harness because the current domain exposes LogisticsRequest and LogisticsDispatcher, but no owning LogisticsService tick API.", + ], + } + + var fixture_started := Time.get_ticks_usec() + var services := _create_services() + var dimensions := _dimensions_for_cell_count(int(target["open_cells"])) + var cells := _populate_world(services["world"], dimensions) + if cells.size() != int(target["open_cells"]): + _record_runtime_error(&"WORLD_FIXTURE", "Could not create the requested walkable-cell cardinality.") + if not services["path"].rebuild_graph(cells): + _record_runtime_error(&"PATH_FIXTURE", services["path"].last_error) + _populate_economy(services["economy"], int(target["queue_entries"])) + _populate_guests(services["guests"], services["path"], int(target["guests"]), dimensions) + _populate_staff(services["staff"], services["path"], int(target["staff"]), dimensions) + _populate_facilities( + services["facilities"], + services["world"], + int(target["facilities"]), + int(target["queue_entries"]), + int(target["staff"]), + dimensions + ) + _populate_facility_queues( + services["facilities"], + int(target["facilities"]), + int(target["queue_entries"]), + int(target["guests"]) + ) + _populate_logistics( + services["inventory"], + services["staff"], + int(target["unfinished_logistics"]) + ) + _populate_events(services["events"], int(target["active_events"])) + + var initial := _fixture_counts(services) + report["actual_initial"] = initial + report["fixture_build_ms"] = (Time.get_ticks_usec() - fixture_started) / 1_000.0 + var unmet := _compare_fixture(target, initial) + report["unmet_fixture_fields"] = unmet + + if not unmet.is_empty() or not _runtime_errors.is_empty(): + report["runtime_errors"] = _runtime_errors.duplicate(true) + report["status"] = "INCOMPLETE" + _finalize_check_summary(report) + return report + + var profiler := PerformanceBudgetType.new(512) + services["path"].path_query_measured.connect( + func(duration_us: int) -> void: + _path_duration_samples.append(duration_us) + ) + var simulation_started := Time.get_ticks_usec() + var completed_minutes := _simulate_day(services, target, dimensions, profiler) + var simulation_duration_us := Time.get_ticks_usec() - simulation_started + # Feed raw path samples after timing the simulation. PerformanceBudget returns + # a full percentile report per record, so doing this inside each path callback + # would make the profiler itself dominate the x4 measurement. + for duration_us: int in _path_duration_samples: + profiler.record_sample(&"path_query", duration_us) + + report["simulation"] = { + "requested_minutes": int(target["minutes"]), + "completed_minutes": completed_minutes, + "x4_frames": int(ceil(float(completed_minutes) / 4.0)), + "duration_ms": simulation_duration_us / 1_000.0, + "game_minutes_per_real_second": ( + float(completed_minutes) * 1_000_000.0 / float(maxi(simulation_duration_us, 1)) + ), + } + report["performance"] = _performance_report(profiler) + report["backlog"] = { + "stale_limit_minutes": STALE_BACKLOG_LIMIT_MINUTES, + "maximum_facility_queue_age": _maximum_facility_queue_age, + "maximum_logistics_backlog_age": _maximum_logistics_backlog_age, + "facility_entries_observed_over_limit": _stale_facility_queue_entries, + "logistics_requests_observed_over_limit": _stale_logistics_requests, + } + report["actual_final"] = _fixture_counts(services) + report["runtime_errors"] = _runtime_errors.duplicate(true) + + var validation_started := Time.get_ticks_usec() + _run_validations(report, services, cells, target, completed_minutes, profiler) + report["validation_duration_ms"] = (Time.get_ticks_usec() - validation_started) / 1_000.0 + _finalize_status(report, normalized_tier) + _finalize_check_summary(report) + return report + + +func _create_services() -> Dictionary: + _economy = EconomyServiceType.new() + _dispatcher = LogisticsDispatcherType.new() + var facilities := FacilityServiceType.new() + facilities.configure_callbacks(Callable(), Callable(self, "_on_facility_service_completed")) + return { + "world": WorldServiceType.new(), + "path": PathServiceType.new(), + "facilities": facilities, + "guests": GuestServiceType.new(), + "staff": StaffServiceType.new(), + "inventory": InventoryServiceType.new(), + "events": EventServiceType.new(), + "economy": _economy, + } + + +func _dimensions_for_cell_count(cell_count: int) -> Vector2i: + # Both acceptance tiers are exactly divisible by two layers and 50 columns. + var width := 200 if cell_count >= FULL_TARGET["open_cells"] else 50 + var height := cell_count / (width * 2) + return Vector2i(width, height) + + +func _populate_world(world: Variant, dimensions: Vector2i) -> Array[Vector3i]: + var cells: Array[Vector3i] = [] + if not world.reset({ + &"min_x": 0, + &"max_x": dimensions.x - 1, + &"min_y": 0, + &"max_y": dimensions.y - 1, + }): + _record_runtime_error(&"WORLD_RESET", "WorldService rejected PERF bounds.") + return cells + for layer: int in [WorldServiceType.LAYER_FRONT, WorldServiceType.LAYER_BACK]: + for y: int in range(dimensions.y): + for x: int in range(dimensions.x): + var cell := Vector3i(x, y, layer) + if not world.set_walkable(cell, true): + _record_runtime_error(&"WORLD_WALKABLE", "WorldService rejected cell %s." % cell) + return cells + cells.append(cell) + return cells + + +func _populate_economy(economy: Variant, queue_count: int) -> void: + if not economy.register_account(CUSTOMER_ACCOUNT_ID): + _record_runtime_error(&"ECONOMY_ACCOUNT", "Could not register customer account.") + if not economy.register_account(OPERATOR_ACCOUNT_ID): + _record_runtime_error(&"ECONOMY_ACCOUNT", "Could not register operator account.") + var customer_seed = economy.initialize_account( + CUSTOMER_ACCOUNT_ID, + maxi(queue_count * 2, 100), + 0, + &"perf_customer_seed" + ) + var operator_seed = economy.initialize_account( + OPERATOR_ACCOUNT_ID, + 100, + 0, + &"perf_operator_seed" + ) + if not customer_seed.is_success() or not operator_seed.is_success(): + _record_runtime_error(&"ECONOMY_SEED", "Could not seed PERF ledger accounts.") + + +func _populate_guests( + guest_service: Variant, + path_service: Variant, + count: int, + dimensions: Vector2i +) -> void: + var definition = guest_service.create_definition(&"perf_guest", { + &"wealth_min": 100, + &"wealth_max": 100, + &"base_patience": 180, + &"need_rates": { + &"hunger": 0, + &"sleep": 0, + &"hygiene": 0, + &"medical": 0, + &"leisure": 0, + &"intimacy": 0, + }, + }) + if definition == null: + _record_runtime_error(&"GUEST_DEFINITION", guest_service.last_error) + return + for index: int in range(count): + var guest_id := StringName("perf_guest_%04d" % index) + var cell := Vector3i(index % dimensions.x, (index / dimensions.x) % dimensions.y, 0) + var guest = guest_service.spawn_guest(guest_id, &"perf_guest", 0, { + &"current_cell": cell, + &"wallet_gold": 100, + &"planned_exit_at": 2_880, + }) + if guest == null: + _record_runtime_error(&"GUEST_SPAWN", guest_service.last_error) + continue + if not path_service.project_actor_position(guest_id, cell, 0): + _record_runtime_error(&"GUEST_PATH_PROJECTION", path_service.last_error) + + +func _populate_staff( + staff_service: Variant, + path_service: Variant, + count: int, + dimensions: Vector2i +) -> void: + _carriers.clear() + var carrier_capacity := maxi(int(ceil(float(maxi(_target_logistics_hint(count), 1)) / float(maxi(count, 1)))), 1) + for index: int in range(count): + var staff_id := StringName("perf_staff_%04d" % index) + var cell := Vector3i( + (index * 3) % dimensions.x, + ((index * 3) / dimensions.x) % dimensions.y, + 0 + ) + var result = staff_service.create_staff(staff_id, &"logistics", { + &"current_cell": cell, + &"state": &"AVAILABLE", + &"role_tags": [&"logistics"], + &"permitted_task_tags": [&"delivery"], + }) + if not result.is_success(): + _record_runtime_error(&"STAFF_CREATE", staff_service.last_error) + continue + if not path_service.project_actor_position(staff_id, cell, 0): + _record_runtime_error(&"STAFF_PATH_PROJECTION", path_service.last_error) + _carriers[staff_id] = { + &"position": cell, + &"capacity": carrier_capacity, + &"current_load": 0, + &"role": &"logistics", + &"role_fit": 1, + &"path_minutes": 1, + } + + +func _target_logistics_hint(staff_count: int) -> int: + return FULL_TARGET["unfinished_logistics"] if staff_count >= FULL_TARGET["staff"] else SMOKE_TARGET["unfinished_logistics"] + + +func _populate_facilities( + facility_service: Variant, + world: Variant, + count: int, + queue_count: int, + staff_count: int, + dimensions: Vector2i +) -> void: + var queue_capacity := int(ceil(float(queue_count) / float(maxi(count, 1)))) + 1 + for index: int in range(count): + var facility_id := StringName("perf_facility_%04d" % index) + var cell := Vector3i(index % dimensions.x, (index / dimensions.x) % dimensions.y, 1) + var active_staff: Array[StringName] = [] + if staff_count > 0: + active_staff.append(StringName("perf_staff_%04d" % (index % staff_count))) + var result = facility_service.create_instance({ + &"instance_id": facility_id, + &"definition_id": &"perf_service_room", + &"origin": cell, + &"occupied_cells": [cell], + &"capacity": 1, + &"queue_capacity": queue_capacity, + &"base_service_minutes": FACILITY_SERVICE_MINUTES, + &"base_quality": 50, + &"base_price": 1, + &"staff_min": 1 if not active_staff.is_empty() else 0, + &"staff_max": 1 if not active_staff.is_empty() else 0, + &"active_staff_ids": active_staff, + }) + if not result.is_success(): + _record_runtime_error(&"FACILITY_CREATE", facility_service.last_error) + continue + if not world.occupy(facility_id, [cell], &"facility"): + _record_runtime_error(&"FACILITY_OCCUPANCY", "World occupancy rejected %s." % facility_id) + + +func _populate_facility_queues( + facility_service: Variant, + facility_count: int, + queue_count: int, + guest_count: int +) -> void: + for index: int in range(queue_count): + var facility_id := StringName("perf_facility_%04d" % (index % facility_count)) + var guest_ids: Array[StringName] = [ + StringName("perf_guest_%04d" % (index % guest_count)), + ] + var request_id := StringName("perf_service_request_%05d" % index) + var request = facility_service.create_request( + request_id, + facility_id, + guest_ids, + &"perf_service", + 0, + { + &"quoted_price": 1, + &"service_minutes": FACILITY_SERVICE_MINUTES, + &"priority": index % 3, + } + ) + if request == null: + _record_runtime_error(&"FACILITY_REQUEST", "Could not create %s." % request_id) + continue + var result = facility_service.enqueue_request(request, 0) + if not result.is_success(): + _record_runtime_error(&"FACILITY_QUEUE", facility_service.last_error) + + +func _populate_logistics( + inventory: Variant, + staff_service: Variant, + count: int +) -> void: + _logistics_requests.clear() + var stock = inventory.add_stack( + WAREHOUSE_ID, + SUPPLY_ITEM_ID, + count + 100, + 100, + -1, + 0, + 1, + &"perf_supply_stack" + ) + if not bool(stock.get(&"ok", false)): + _record_runtime_error(&"INVENTORY_STOCK", String(stock.get(&"message", "Stock creation failed."))) + return + for index: int in range(count): + var request_id := StringName("perf_logistics_%04d" % index) + var reservation = inventory.reserve_item( + WAREHOUSE_ID, + SUPPLY_ITEM_ID, + 1, + request_id, + &"perf_delivery", + 0, + STALE_BACKLOG_LIMIT_MINUTES, + StringName("perf_reserve_%04d" % index) + ) + if not bool(reservation.get(&"ok", false)): + _record_runtime_error(&"LOGISTICS_RESERVE", String(reservation.get(&"message", "Reservation failed."))) + continue + var reservation_ids: Array[StringName] = [] + for raw_id: Variant in reservation.get(&"reservation_ids", []): + reservation_ids.append(StringName(raw_id)) + var stack_ids: Array[StringName] = [] + for raw_id: Variant in reservation.get(&"stack_ids", []): + stack_ids.append(StringName(raw_id)) + var request = LogisticsRequestType.new().configure( + request_id, + WAREHOUSE_ID, + DESTINATION_ID, + {SUPPLY_ITEM_ID: 1}, + 100 - (index % 20), + 0, + STALE_BACKLOG_LIMIT_MINUTES, + 1 + ) + if not request.mark_reserved(reservation_ids, stack_ids, 0): + _record_runtime_error(&"LOGISTICS_REQUEST", "Could not reserve %s." % request_id) + continue + _logistics_requests.append(request) + # Ensure the carrier adapter was derived from real registered Staff records. + if _carriers.size() != staff_service.get_staff_members().size(): + _record_runtime_error(&"LOGISTICS_CARRIERS", "Carrier projection does not match Staff cardinality.") + + +func _populate_events(event_service: Variant, count: int) -> void: + var definition_result = _register_perf_event_definition(event_service) + if not definition_result.is_success(): + _record_runtime_error(&"EVENT_DEFINITION", event_service.last_error) + return + for index: int in range(count): + var event_instance_id := StringName("perf_event_instance_%04d" % index) + var enqueue = event_service.enqueue_candidate( + EVENT_DEFINITION_ID, + [], + 0, + { + &"event_instance_id": event_instance_id, + &"deduplication_key": StringName("perf_event_key_%04d" % index), + } + ) + if not enqueue.is_success(): + _record_runtime_error(&"EVENT_ENQUEUE", event_service.last_error) + continue + var started = event_service.start_next_event(0) + if not started.is_success() or not bool(started.details.get(&"waiting_for_choice", false)): + _record_runtime_error(&"EVENT_START", event_service.last_error) + + +func _register_perf_event_definition(event_service: Variant) -> Variant: + return event_service.create_definition(EVENT_DEFINITION_ID, { + &"category": &"PERFORMANCE", + &"priority": 10, + &"foreground": false, + &"choices": [ + { + &"choice_id": &"hold", + &"text_key": &"perf.hold", + &"cost": {}, + &"actions": [], + }, + ], + }) + + +func _simulate_day( + services: Dictionary, + target: Dictionary, + dimensions: Vector2i, + profiler: Variant +) -> int: + var requested_minutes := int(target["minutes"]) + var current_minute := 0 + var paths_per_minute := 4 if int(target["guests"]) >= FULL_TARGET["guests"] else 1 + while current_minute < requested_minutes and _runtime_errors.is_empty(): + var frame_started := Time.get_ticks_usec() + var ticks_this_frame := mini(int(target["speed"]), requested_minutes - current_minute) + for _tick: int in range(ticks_this_frame): + current_minute += 1 + _advance_one_minute(services, current_minute, dimensions, paths_per_minute) + if not _runtime_errors.is_empty(): + break + var frame_duration := Time.get_ticks_usec() - frame_started + profiler.record_simulation_frame(frame_duration, int(target["speed"]), ticks_this_frame) + return current_minute + + +func _advance_one_minute( + services: Dictionary, + absolute_minute: int, + dimensions: Vector2i, + paths_per_minute: int +) -> void: + var domain_started := Time.get_ticks_usec() + _track_backlog(services["facilities"], absolute_minute) + services["economy"].set_current_minute(absolute_minute) + _record_domain_sample(&"backlog_and_clock", Time.get_ticks_usec() - domain_started) + + domain_started = Time.get_ticks_usec() + var facility_results: Array = services["facilities"].advance_one_minute(absolute_minute) + _record_domain_sample(&"facility", Time.get_ticks_usec() - domain_started) + for result: Variant in facility_results: + if result != null and result.has_method("is_success") and not result.is_success(): + _record_runtime_error(&"FACILITY_TICK", result.message) + return + domain_started = Time.get_ticks_usec() + var guest_report: Dictionary = services["guests"].advance_one_minute(absolute_minute) + _record_domain_sample(&"guest", Time.get_ticks_usec() - domain_started) + if not bool(guest_report.get(&"ok", false)): + _record_runtime_error(&"GUEST_TICK", services["guests"].last_error) + return + domain_started = Time.get_ticks_usec() + var staff_report: Dictionary = services["staff"].advance_one_minute(absolute_minute) + _record_domain_sample(&"staff", Time.get_ticks_usec() - domain_started) + if not bool(staff_report.get(&"ok", false)): + _record_runtime_error(&"STAFF_TICK", services["staff"].last_error) + return + domain_started = Time.get_ticks_usec() + services["inventory"].process_expired(absolute_minute) + services["events"].process_expired(absolute_minute) + services["events"].audit_actor_availability(absolute_minute) + _record_domain_sample(&"inventory_and_events", Time.get_ticks_usec() - domain_started) + domain_started = Time.get_ticks_usec() + _advance_logistics(services["inventory"], absolute_minute) + _record_domain_sample(&"logistics", Time.get_ticks_usec() - domain_started) + domain_started = Time.get_ticks_usec() + _run_path_queries(services["path"], absolute_minute, dimensions, paths_per_minute) + _record_domain_sample(&"path", Time.get_ticks_usec() - domain_started) + + +func _track_backlog(facility_service: Variant, absolute_minute: int) -> void: + for facility: Variant in facility_service.get_instances(): + for request: Variant in facility_service.get_queue(facility.instance_id): + var age := maxi(absolute_minute - int(request.enqueued_at), 0) + _maximum_facility_queue_age = maxi(_maximum_facility_queue_age, age) + if age > STALE_BACKLOG_LIMIT_MINUTES: + _stale_facility_queue_entries += 1 + for request: Variant in _logistics_requests: + if request.is_terminal(): + continue + var age := maxi(absolute_minute - int(request.created_at), 0) + _maximum_logistics_backlog_age = maxi(_maximum_logistics_backlog_age, age) + if age > STALE_BACKLOG_LIMIT_MINUTES: + _stale_logistics_requests += 1 + + +func _advance_logistics(inventory: Variant, absolute_minute: int) -> void: + var dispatch_started := Time.get_ticks_usec() + if absolute_minute == 1: + var assignments: Array[Dictionary] = _dispatcher.dispatch( + _logistics_requests, + _carriers, + absolute_minute + ) + if assignments.size() != _logistics_requests.size(): + _record_runtime_error( + &"LOGISTICS_DISPATCH", + "Assigned %d of %d requests." % [assignments.size(), _logistics_requests.size()] + ) + return + _record_domain_sample(&"logistics_dispatch", Time.get_ticks_usec() - dispatch_started) + var lifecycle_started := Time.get_ticks_usec() + var pickup_requests: Array = [] + var delivery_requests: Array = [] + for request: Variant in _logistics_requests: + var state_age := absolute_minute - int(request.state_changed_at) + match request.state: + LogisticsRequestType.State.ASSIGNED: + if state_age >= 1: + pickup_requests.append(request) + LogisticsRequestType.State.PICKING_UP: + if state_age >= 1 and not request.transition_to( + LogisticsRequestType.State.DELIVERING, + "", + absolute_minute + ): + _record_runtime_error(&"LOGISTICS_DELIVERY_STATE", "Could not begin delivery for %s." % request.request_id) + return + LogisticsRequestType.State.DELIVERING: + if state_age >= 1: + delivery_requests.append(request) + + if not pickup_requests.is_empty(): + var pickup_entries: Array[Dictionary] = [] + for request: Variant in pickup_requests: + pickup_entries.append({ + &"reservation_ids": request.reservation_ids, + &"destination_inventory_id": _cargo_inventory_id(request.assigned_carrier_id), + &"operation_id": StringName("perf_pickup_%s" % request.request_id), + }) + var pickup_started := Time.get_ticks_usec() + var pickup: Dictionary = inventory.transfer_reserved_batch(pickup_entries, absolute_minute) + _record_domain_sample(&"logistics_pickup_batch", Time.get_ticks_usec() - pickup_started) + if not bool(pickup.get(&"ok", false)): + _record_runtime_error(&"LOGISTICS_PICKUP", String(pickup.get(&"message", "Pickup failed."))) + return + for request: Variant in pickup_requests: + if not request.transition_to(LogisticsRequestType.State.PICKING_UP, "", absolute_minute): + _record_runtime_error(&"LOGISTICS_PICKUP_STATE", "Could not begin pickup for %s." % request.request_id) + return + + if not delivery_requests.is_empty(): + var delivery_entries: Array[Dictionary] = [] + for request: Variant in delivery_requests: + delivery_entries.append({ + &"source_inventory_id": _cargo_inventory_id(request.assigned_carrier_id), + &"destination_inventory_id": request.destination_inventory_id, + &"item_id": SUPPLY_ITEM_ID, + &"quantity": 1, + &"operation_id": StringName("perf_delivery_%s" % request.request_id), + }) + var delivery_started := Time.get_ticks_usec() + var delivered: Dictionary = inventory.transfer_available_batch(delivery_entries, absolute_minute) + _record_domain_sample(&"logistics_delivery_batch", Time.get_ticks_usec() - delivery_started) + if not bool(delivered.get(&"ok", false)): + _record_runtime_error(&"LOGISTICS_DELIVERY", String(delivered.get(&"message", "Delivery failed."))) + return + for request: Variant in delivery_requests: + var carrier_id: StringName = request.assigned_carrier_id + if not request.transition_to(LogisticsRequestType.State.COMPLETED, "", absolute_minute): + _record_runtime_error(&"LOGISTICS_COMPLETE", "Could not complete %s." % request.request_id) + return + _dispatcher.release_assignment_for_request( + request.request_id, + carrier_id, + request.total_weight + ) + _record_domain_sample(&"logistics_lifecycle", Time.get_ticks_usec() - lifecycle_started) + + +func _run_path_queries( + path_service: Variant, + absolute_minute: int, + dimensions: Vector2i, + query_count: int +) -> void: + for query_index: int in range(query_count): + var x := (absolute_minute * 7 + query_index * 17) % dimensions.x + var y := (absolute_minute * 3 + query_index * 11) % dimensions.y + var goal_x := mini(x + 4, dimensions.x - 1) + var start := Vector3i(x, y, 0) + var goal := Vector3i(goal_x, y, 0) + var path: Array[Vector3i] = path_service.find_path(start, goal) + if path.is_empty(): + _path_query_failures += 1 + _record_runtime_error( + &"PATH_QUERY", + "No route from %s to %s (%s)." % [start, goal, path_service.last_no_route_reason] + ) + return + + +func _on_facility_service_completed( + _service: Variant, + request: Variant, + _instance: Variant, + absolute_minute: int +) -> Variant: + return _economy.transfer( + CUSTOMER_ACCOUNT_ID, + OPERATOR_ACCOUNT_ID, + int(request.quoted_price), + absolute_minute, + &"FACILITY_REVENUE", + request.facility_id, + StringName("perf_revenue_%s" % request.request_id), + &"perf.facility_revenue" + ) + + +func _cargo_inventory_id(carrier_id: StringName) -> StringName: + return StringName("perf_cargo_%s" % carrier_id) + + +func _fixture_counts(services: Dictionary) -> Dictionary: + var queue_entries := 0 + var active_services := 0 + for facility: Variant in services["facilities"].get_instances(): + queue_entries += services["facilities"].get_queue(facility.instance_id).size() + active_services += services["facilities"].get_active_services(facility.instance_id).size() + var unfinished_logistics := 0 + var completed_logistics := 0 + for request: Variant in _logistics_requests: + if request.is_terminal(): + completed_logistics += 1 + else: + unfinished_logistics += 1 + var active_events := 0 + var waiting_events := 0 + var queued_events := 0 + for instance: Variant in services["events"].get_instances(): + if not instance.is_terminal(): + active_events += 1 + if instance.state == EventInstanceType.State.WAITING_CHOICE: + waiting_events += 1 + elif instance.state == EventInstanceType.State.QUEUED: + queued_events += 1 + return { + "open_cells": services["world"].get_walkable_cells().size(), + "path_cells": services["path"].create_snapshot().get(&"cell_traffic", []).size(), + "guests": services["guests"].guest_count(), + "staff": services["staff"].get_staff_members().size(), + "facilities": services["facilities"].get_instances().size(), + "queue_entries": queue_entries, + "active_services": active_services, + "unfinished_logistics": unfinished_logistics, + "completed_logistics": completed_logistics, + "active_events": active_events, + "waiting_choice_events": waiting_events, + "queued_events": queued_events, + } + + +func _compare_fixture(target: Dictionary, actual: Dictionary) -> Array[Dictionary]: + var unmet: Array[Dictionary] = [] + for field: String in [ + "open_cells", + "guests", + "staff", + "facilities", + "queue_entries", + "unfinished_logistics", + "active_events", + ]: + if int(actual.get(field, -1)) != int(target[field]): + unmet.append({ + "field": field, + "target": int(target[field]), + "actual": int(actual.get(field, -1)), + }) + return unmet + + +func _run_validations( + report: Dictionary, + services: Dictionary, + cells: Array[Vector3i], + target: Dictionary, + completed_minutes: int, + profiler: Variant +) -> void: + var validations: Array = report["validations"] + _add_validation( + validations, + &"day_completed_without_crash", + completed_minutes == int(target["minutes"]) and _runtime_errors.is_empty(), + {"completed_minutes": completed_minutes, "runtime_error_count": _runtime_errors.size()} + ) + _add_validation( + validations, + &"fixture_cardinality_exact", + (report["unmet_fixture_fields"] as Array).is_empty(), + {"actual_initial": report["actual_initial"]} + ) + _add_validation( + validations, + &"facility_queue_not_stale", + _stale_facility_queue_entries == 0 and _maximum_facility_queue_age <= STALE_BACKLOG_LIMIT_MINUTES, + {"maximum_age": _maximum_facility_queue_age, "observations_over_limit": _stale_facility_queue_entries} + ) + _add_validation( + validations, + &"logistics_backlog_not_stale", + _stale_logistics_requests == 0 and _maximum_logistics_backlog_age <= STALE_BACKLOG_LIMIT_MINUTES, + {"maximum_age": _maximum_logistics_backlog_age, "observations_over_limit": _stale_logistics_requests} + ) + _add_validation( + validations, + &"logistics_all_completed", + _count_terminal_logistics() == _logistics_requests.size(), + {"completed": _count_terminal_logistics(), "total": _logistics_requests.size()} + ) + + var inventory_validation: Dictionary = services["inventory"].validate_invariants() + var inventory_nonnegative := _inventory_is_nonnegative(services["inventory"]) + _add_validation( + validations, + &"inventory_invariants", + bool(inventory_validation.get(&"ok", false)) and inventory_nonnegative, + {"service_report": inventory_validation, "nonnegative": inventory_nonnegative} + ) + var ledger_validation: Dictionary = services["economy"].validate_ledger() + _add_validation( + validations, + &"ledger_balanced", + bool(ledger_validation.get(&"ok", false)) + and int(ledger_validation.get(&"total_debit", -1)) == int(ledger_validation.get(&"total_credit", -2)), + ledger_validation + ) + _add_validation(validations, &"guest_invariants", bool(services["guests"].validate_invariants().get(&"valid", false)), services["guests"].validate_invariants()) + _add_validation(validations, &"staff_invariants", bool(services["staff"].validate_invariants().get(&"ok", false)), services["staff"].validate_invariants()) + _add_validation(validations, &"event_invariants", bool(services["events"].validate_invariants().get(&"ok", false)), services["events"].validate_invariants()) + _add_validation(validations, &"path_queries_reachable", _path_query_failures == 0, {"failures": _path_query_failures}) + + _validate_snapshot_round_trips(validations, services, cells) + var x4_report: Dictionary = profiler.get_report(&"simulation_x4") + var path_report: Dictionary = profiler.get_report(&"path_query") + var stall_report: Dictionary = profiler.get_report(&"main_thread_stall") + _add_validation(validations, &"simulation_x4_budget", not bool(x4_report[&"budget_exceeded"]), _primitive_dictionary(x4_report)) + _add_validation(validations, &"path_query_budget", not bool(path_report[&"budget_exceeded"]), _primitive_dictionary(path_report)) + _add_validation(validations, &"main_thread_stall_budget", not bool(stall_report[&"budget_exceeded"]), _primitive_dictionary(stall_report)) + + +func _validate_snapshot_round_trips( + validations: Array, + services: Dictionary, + cells: Array[Vector3i] +) -> void: + var world_snapshot: Dictionary = services["world"].create_snapshot() + var world_clone = WorldServiceType.new() + var world_ok := world_clone.restore_snapshot(world_snapshot) + _add_validation(validations, &"world_snapshot", world_ok and world_clone.create_snapshot() == world_snapshot, {"restored": world_ok}) + + var path_snapshot: Dictionary = services["path"].create_snapshot() + var path_clone = PathServiceType.new() + var path_restored := path_clone.restore_snapshot(path_snapshot) + var path_rebuilt := path_restored and path_clone.rebuild_graph(cells) + _add_validation(validations, &"path_snapshot", path_rebuilt and path_clone.create_snapshot() == path_snapshot, {"restored": path_restored, "rebuilt": path_rebuilt}) + + var facility_snapshot: Dictionary = services["facilities"].create_snapshot() + var facility_clone = FacilityServiceType.new() + var facility_ok := facility_clone.restore_snapshot(facility_snapshot) + _add_validation(validations, &"facility_snapshot", facility_ok and facility_clone.create_snapshot() == facility_snapshot, {"restored": facility_ok}) + + var guest_snapshot: Dictionary = services["guests"].create_snapshot() + var guest_clone = GuestServiceType.new() + var guest_ok := guest_clone.restore_snapshot(guest_snapshot) + _add_validation(validations, &"guest_snapshot", guest_ok and guest_clone.create_snapshot() == guest_snapshot, {"restored": guest_ok}) + + var staff_snapshot: Dictionary = services["staff"].create_snapshot() + var staff_clone = StaffServiceType.new() + var staff_ok := staff_clone.restore_snapshot(staff_snapshot) + _add_validation(validations, &"staff_snapshot", staff_ok and staff_clone.create_snapshot() == staff_snapshot, {"restored": staff_ok}) + + var inventory_snapshot: Dictionary = services["inventory"].create_snapshot() + var inventory_clone = InventoryServiceType.new() + var inventory_ok := inventory_clone.restore_snapshot(inventory_snapshot) + _add_validation(validations, &"inventory_snapshot", inventory_ok and inventory_clone.create_snapshot() == inventory_snapshot, {"restored": inventory_ok}) + + var event_snapshot: Dictionary = services["events"].create_snapshot() + var event_clone = EventServiceType.new() + var event_definition_ok: bool = _register_perf_event_definition(event_clone).is_success() + var event_ok: bool = event_definition_ok and event_clone.restore_snapshot(event_snapshot) + _add_validation(validations, &"event_snapshot", event_ok and event_clone.create_snapshot() == event_snapshot, {"definition_registered": event_definition_ok, "restored": event_ok}) + + var economy_snapshot: Dictionary = services["economy"].create_snapshot() + var economy_clone = EconomyServiceType.new() + var economy_ok := economy_clone.restore_snapshot(economy_snapshot) + _add_validation(validations, &"economy_snapshot", economy_ok and economy_clone.create_snapshot() == economy_snapshot, {"restored": economy_ok}) + + var dispatcher_snapshot: Dictionary = _dispatcher.create_snapshot() + var dispatcher_clone = LogisticsDispatcherType.new() + var dispatcher_ok := dispatcher_clone.restore_snapshot(dispatcher_snapshot) + _add_validation(validations, &"logistics_dispatcher_snapshot", dispatcher_ok and dispatcher_clone.create_snapshot() == dispatcher_snapshot, {"restored": dispatcher_ok}) + + var logistics_round_trip_ok := true + for request: Variant in _logistics_requests: + var clone = LogisticsRequestType.from_snapshot(request.create_snapshot()) + if clone == null or clone.create_snapshot() != request.create_snapshot(): + logistics_round_trip_ok = false + break + _add_validation(validations, &"logistics_request_snapshots", logistics_round_trip_ok, {"request_count": _logistics_requests.size()}) + + +func _inventory_is_nonnegative(inventory: Variant) -> bool: + for stack: Dictionary in inventory.get_stacks(): + if int(stack.get(&"quantity", -1)) < 0: + return false + if int(stack.get(&"reserved_quantity", -1)) < 0: + return false + if int(stack.get(&"reserved_quantity", 0)) > int(stack.get(&"quantity", -1)): + return false + return true + + +func _count_terminal_logistics() -> int: + var count := 0 + for request: Variant in _logistics_requests: + if request.is_terminal(): + count += 1 + return count + + +func _performance_report(profiler: Variant) -> Dictionary: + return { + "simulation_x4": _primitive_dictionary(profiler.get_report(&"simulation_x4")), + "path_query": _primitive_dictionary(profiler.get_report(&"path_query")), + "main_thread_stall": _primitive_dictionary(profiler.get_report(&"main_thread_stall")), + "domain_timings_per_game_minute": { + "backlog_and_clock": _raw_sample_report(&"backlog_and_clock"), + "facility": _raw_sample_report(&"facility"), + "guest": _raw_sample_report(&"guest"), + "staff": _raw_sample_report(&"staff"), + "inventory_and_events": _raw_sample_report(&"inventory_and_events"), + "logistics": _raw_sample_report(&"logistics"), + "logistics_dispatch": _raw_sample_report(&"logistics_dispatch"), + "logistics_lifecycle": _raw_sample_report(&"logistics_lifecycle"), + "logistics_pickup_batch": _raw_sample_report(&"logistics_pickup_batch"), + "logistics_delivery_batch": _raw_sample_report(&"logistics_delivery_batch"), + "path": _raw_sample_report(&"path"), + }, + } + + +func _record_domain_sample(metric_id: StringName, duration_us: int) -> void: + if not _domain_samples.has(metric_id): + _domain_samples[metric_id] = [] + (_domain_samples[metric_id] as Array).append(maxi(duration_us, 0)) + + +func _raw_sample_report(metric_id: StringName) -> Dictionary: + var raw_values: Array = _domain_samples.get(metric_id, []) + var values: Array[int] = [] + var total := 0 + var maximum := 0 + for raw_value: Variant in raw_values: + var value := int(raw_value) + values.append(value) + total += value + maximum = maxi(maximum, value) + values.sort() + var p95 := 0 + if not values.is_empty(): + var index := maxi((95 * values.size() + 99) / 100 - 1, 0) + p95 = values[mini(index, values.size() - 1)] + return { + "sample_count": values.size(), + "average_us": total / values.size() if not values.is_empty() else 0, + "p95_us": p95, + "maximum_us": maximum, + } + + +func _primitive_dictionary(source: Dictionary) -> Dictionary: + var result: Dictionary = {} + for raw_key: Variant in source: + result[String(raw_key)] = source[raw_key] + return result + + +func _add_validation( + validations: Array, + validation_id: StringName, + ok: bool, + details: Dictionary = {}, + skipped := false +) -> void: + validations.append({ + "id": String(validation_id), + "status": "SKIPPED" if skipped else ("PASS" if ok else "FAIL"), + "details": details, + }) + + +func _finalize_status(report: Dictionary, tier: StringName) -> void: + if not (report["unmet_fixture_fields"] as Array).is_empty(): + report["status"] = "INCOMPLETE" + return + if not _runtime_errors.is_empty(): + report["status"] = "FAILED" + return + for validation: Dictionary in report["validations"]: + if validation["status"] == "SKIPPED": + report["status"] = "INCOMPLETE" + return + if validation["status"] == "FAIL": + report["status"] = "FAILED" + return + report["status"] = "PASS" if tier == &"full" else "SMOKE_PASS" + + +func _finalize_check_summary(report: Dictionary) -> void: + var passed := 0 + var failed := 0 + var skipped := 0 + for validation: Dictionary in report["validations"]: + match String(validation.get("status", "FAIL")): + "PASS": + passed += 1 + "SKIPPED": + skipped += 1 + _: + failed += 1 + report["check_summary"] = { + "total": passed + failed + skipped, + "passed": passed, + "failed": failed, + "skipped": skipped, + } + + +func _record_runtime_error(code: StringName, message: String) -> void: + _runtime_errors.append({"code": String(code), "message": message}) + + +func _reset_run_state() -> void: + _economy = null + _dispatcher = null + _runtime_errors.clear() + _logistics_requests.clear() + _carriers.clear() + _maximum_facility_queue_age = 0 + _maximum_logistics_backlog_age = 0 + _stale_facility_queue_entries = 0 + _stale_logistics_requests = 0 + _path_query_failures = 0 + _path_duration_samples.clear() + _domain_samples.clear() diff --git a/tests/performance/perf_mvp_001_harness.gd.uid b/tests/performance/perf_mvp_001_harness.gd.uid new file mode 100644 index 0000000..e2bc0f9 --- /dev/null +++ b/tests/performance/perf_mvp_001_harness.gd.uid @@ -0,0 +1 @@ +uid://b1s2fuc7d2x4i diff --git a/tests/performance/test_perf_mvp_001.gd b/tests/performance/test_perf_mvp_001.gd new file mode 100644 index 0000000..bd71b22 --- /dev/null +++ b/tests/performance/test_perf_mvp_001.gd @@ -0,0 +1,76 @@ +extends SceneTree + +const HarnessType = preload("res://tests/performance/perf_mvp_001_harness.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var tier := &"full" if _full_tier_requested() else &"smoke" + print("PERF-MVP-001: %s tier" % String(tier)) + var report: Dictionary = HarnessType.new().run(tier) + _print_summary(report) + print("PERF_MVP_001_REPORT=%s" % JSON.stringify(report)) + match String(report.get("status", "INCOMPLETE")): + "PASS", "SMOKE_PASS": + quit(0) + "INCOMPLETE": + quit(2) + _: + quit(1) + + +func _full_tier_requested() -> bool: + var environment_value := OS.get_environment("PERF_MVP_FULL").strip_edges().to_lower() + if environment_value in ["1", "true", "yes", "full"]: + return true + for argument: String in OS.get_cmdline_user_args(): + if argument in ["--full", "full"]: + return true + return false + + +func _print_summary(report: Dictionary) -> void: + var initial: Dictionary = report.get("actual_initial", {}) + var summary: Dictionary = report.get("check_summary", {}) + print( + " status=%s cells=%d guests=%d staff=%d facilities=%d queue=%d logistics=%d events=%d" + % [ + report.get("status", "INCOMPLETE"), + int(initial.get("open_cells", 0)), + int(initial.get("guests", 0)), + int(initial.get("staff", 0)), + int(initial.get("facilities", 0)), + int(initial.get("queue_entries", 0)), + int(initial.get("unfinished_logistics", 0)), + int(initial.get("active_events", 0)), + ] + ) + if report.has("simulation"): + var simulation: Dictionary = report["simulation"] + print( + " simulated=%d/%d minutes runtime=%.3f ms" + % [ + int(simulation.get("completed_minutes", 0)), + int(simulation.get("requested_minutes", 0)), + float(simulation.get("duration_ms", 0.0)), + ] + ) + print( + " checks=%d passed=%d failed=%d skipped=%d" + % [ + int(summary.get("total", 0)), + int(summary.get("passed", 0)), + int(summary.get("failed", 0)), + int(summary.get("skipped", 0)), + ] + ) + for validation: Dictionary in report.get("validations", []): + if validation.get("status", "FAIL") != "PASS": + print(" %s: %s %s" % [validation["status"], validation["id"], validation["details"]]) + for unmet: Dictionary in report.get("unmet_fixture_fields", []): + print(" INCOMPLETE fixture: %s target=%s actual=%s" % [unmet["field"], unmet["target"], unmet["actual"]]) + for error: Dictionary in report.get("runtime_errors", []): + print(" ERROR %s: %s" % [error["code"], error["message"]]) diff --git a/tests/performance/test_perf_mvp_001.gd.uid b/tests/performance/test_perf_mvp_001.gd.uid new file mode 100644 index 0000000..046185e --- /dev/null +++ b/tests/performance/test_perf_mvp_001.gd.uid @@ -0,0 +1 @@ +uid://irdmv2oju0ts diff --git a/tests/run_tests.gd b/tests/run_tests.gd new file mode 100644 index 0000000..2bbc138 --- /dev/null +++ b/tests/run_tests.gd @@ -0,0 +1,61 @@ +extends SceneTree + +var _failures := 0 +var _checks := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("The Tree: automated checks") + _test_game_clock() + _test_game_state() + _test_facility_data() + load("res://tests/s0/test_kernel_and_save.gd").run(Callable(self, "_expect")) + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + +func _test_game_clock() -> void: + var clock = load("res://autoload/game_clock.gd").new() + clock.reset(1, 6, 0) + _expect(clock.get_phase() == clock.Phase.MORNING, "06:00 is morning") + clock.advance_minutes(4 * 60) + _expect(clock.get_hour() == 10, "clock advances to 10:00") + _expect(clock.get_phase() == clock.Phase.DAY, "10:00 is day phase") + clock.set_speed(4) + _expect(clock.speed == 4, "clock accepts the specified x4 speed") + clock.set_speed(3) + _expect(clock.speed == 4, "clock rejects unsupported speed values") + clock.advance_minutes(14 * 60) + _expect(clock.day == 2 and clock.get_hour() == 0, "midnight starts the next day") + clock.free() + +func _test_game_state() -> void: + var state = load("res://autoload/game_state.gd").new() + state.reset() + _expect(state.can_afford({&"gold": 500, &"materials": 5}), "default resources can afford starter room") + _expect(state.spend({&"gold": 500, &"materials": 5}), "spending succeeds") + _expect(state.get_resource(&"gold") == 4500, "gold is deducted atomically") + _expect(not state.spend({&"mana": 1000}), "overspending is rejected") + state.free() + +func _test_facility_data() -> void: + var catalog_script = load("res://scripts/data/facility_catalog.gd") + var facilities: Array = catalog_script.load_all() + _expect(facilities.size() == 16, "MVP facility catalog loads all baseline definitions") + var seen_ids := {} + for facility in facilities: + _expect(facility.is_valid(), "%s has valid data" % facility.resource_path) + _expect(not seen_ids.has(facility.id), "%s has a unique id" % facility.id) + seen_ids[facility.id] = true + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/run_tests.gd.uid b/tests/run_tests.gd.uid new file mode 100644 index 0000000..c195f31 --- /dev/null +++ b/tests/run_tests.gd.uid @@ -0,0 +1 @@ +uid://pd4ln0bwygun diff --git a/tests/s0/test_command_transaction.gd b/tests/s0/test_command_transaction.gd new file mode 100644 index 0000000..ae71325 --- /dev/null +++ b/tests/s0/test_command_transaction.gd @@ -0,0 +1,203 @@ +extends SceneTree + +const CommandType = preload("res://scripts/core/command.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") +const CommandQueueType = preload("res://scripts/core/command_queue.gd") +const TransactionType = preload("res://scripts/core/state_transaction.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S0: Command Queue and State Transaction") + _test_deterministic_queue_order() + _test_idempotency_and_result_retention() + _test_snapshot_preserves_accepted_idempotency_history() + _test_legacy_snapshot_migrates_accepted_metadata() + _test_transaction_commit() + _test_validation_is_atomic() + _test_apply_failure_rolls_back() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_deterministic_queue_order() -> void: + var queue = CommandQueueType.new() + _expect(queue.enqueue(_command(3, 11, 100, &"three")).is_success(), "command 3 accepted") + _expect(queue.enqueue(_command(2, 10, 10, &"two")).is_success(), "command 2 accepted") + _expect(queue.enqueue(_command(1, 10, 10, &"one")).is_success(), "command 1 accepted") + _expect(queue.enqueue(_command(4, 10, 90, &"four")).is_success(), "command 4 accepted") + var ready: Array = queue.drain_ready(10) + var ids: Array[int] = [] + for command in ready: + ids.append(command.command_id) + _expect(ids == [4, 1, 2], "ready order is tick, descending priority, command id") + _expect(queue.next_execute_tick() == 11, "future command remains queued") + + +func _test_idempotency_and_result_retention() -> void: + var queue = CommandQueueType.new() + var original = _command(10, 1, 0, &"same-operation") + _expect(queue.enqueue(original).is_success(), "idempotent command accepted once") + var duplicate = _command(11, 1, 0, &"same-operation") + var duplicate_result = queue.enqueue(duplicate) + _expect( + duplicate_result.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, + "duplicate idempotency key rejected" + ) + queue.drain_ready(1) + queue.complete(10, CommandResultType.succeeded(10, {"value": 42})) + var retained = queue.get_result_by_idempotency_key(&"same-operation") + _expect(retained != null and retained.details["value"] == 42, "processed result retained by key") + _expect(retained.is_replay, "retained lookup is marked as replay") + + +func _test_snapshot_preserves_accepted_idempotency_history() -> void: + var queue = CommandQueueType.new() + _expect(queue.enqueue(_command(1, 0, 10, &"processed-key")).is_success(), "snapshot fixture accepts keyed command") + _expect(queue.enqueue(_command(2, 0, 5, &"")).is_success(), "snapshot fixture accepts keyless command") + _expect(queue.enqueue(_command(3, 5, 1, &"pending-key")).is_success(), "snapshot fixture accepts pending keyed command") + var completed: Array = queue.execute_ready(0, func(command: SimulationCommand) -> CommandResult: + return CommandResultType.succeeded(command.command_id, {"completed": true}) + ) + _expect(completed.size() == 2, "snapshot fixture completes its ready commands") + var snapshot: Dictionary = queue.create_snapshot() + _expect(int(snapshot["version"]) == CommandQueueType.SNAPSHOT_VERSION, "Command Queue writes the current snapshot version") + _expect(snapshot["accepted"] == [ + {"command_id": 1, "idempotency_key": "processed-key"}, + {"command_id": 2, "idempotency_key": ""}, + {"command_id": 3, "idempotency_key": "pending-key"}, + ], "snapshot retains accepted metadata after Command inputs leave the queue") + var restored = CommandQueueType.new() + _expect(restored.restore_snapshot(snapshot), "strict current Command Queue snapshot restores") + _expect(restored.create_snapshot() == snapshot, "current Command Queue snapshot round trip is exact") + var metadata: Array[Dictionary] = restored.get_accepted_command_metadata() + metadata[0]["idempotency_key"] = "changed" + metadata.clear() + _expect(restored.get_accepted_command_metadata() == snapshot["accepted"], "accepted metadata query is a defensive deep copy") + var live_before: Dictionary = restored.create_snapshot() + + var missing_map: Dictionary = snapshot.duplicate(true) + missing_map["idempotency"].erase("processed-key") + _expect(not restored.restore_snapshot(missing_map), "processed idempotency mapping deletion is rejected") + _expect(restored.create_snapshot() == live_before, "mapping deletion rejection is restore-atomic") + var changed_metadata: Dictionary = snapshot.duplicate(true) + changed_metadata["accepted"][0]["idempotency_key"] = "" + _expect(not restored.restore_snapshot(changed_metadata), "accepted metadata cannot disagree with the reverse map") + _expect(restored.create_snapshot() == live_before, "metadata mismatch rejection is restore-atomic") + var missing_metadata: Dictionary = snapshot.duplicate(true) + missing_metadata["accepted"].remove_at(0) + _expect(not restored.restore_snapshot(missing_metadata), "every known Command requires one accepted metadata row") + var unordered_metadata: Dictionary = snapshot.duplicate(true) + var first: Dictionary = unordered_metadata["accepted"][0] + unordered_metadata["accepted"][0] = unordered_metadata["accepted"][1] + unordered_metadata["accepted"][1] = first + _expect(not restored.restore_snapshot(unordered_metadata), "accepted metadata must be ordered by Command ID") + var unknown_field: Dictionary = snapshot.duplicate(true) + unknown_field["extra"] = true + _expect(not restored.restore_snapshot(unknown_field), "current snapshot rejects unknown root fields") + var string_version: Dictionary = snapshot.duplicate(true) + string_version["version"] = "2" + _expect(not restored.restore_snapshot(string_version), "current snapshot rejects a coerced version") + var float_result_id: Dictionary = snapshot.duplicate(true) + float_result_id["processed"][0]["command_id"] = 1.0 + _expect(not restored.restore_snapshot(float_result_id), "current snapshot rejects a coerced processed Command ID") + _expect(restored.create_snapshot() == live_before, "all malformed current snapshots preserve live queue state") + + +func _test_legacy_snapshot_migrates_accepted_metadata() -> void: + var source = CommandQueueType.new() + source.enqueue(_command(7, 0, 1, &"legacy-processed")) + source.drain_ready(0) + source.complete(7, CommandResultType.succeeded(7)) + source.enqueue(_command(9, 20, 1, &"legacy-pending")) + var legacy: Dictionary = source.create_snapshot() + legacy.erase("version") + legacy.erase("accepted") + var restored = CommandQueueType.new() + _expect(restored.restore_snapshot(legacy), "legacy unversioned Command Queue snapshot restores") + var migrated: Dictionary = restored.create_snapshot() + _expect(int(migrated["version"]) == CommandQueueType.SNAPSHOT_VERSION, "legacy restore writes the current version next time") + _expect(migrated["accepted"] == [ + {"command_id": 7, "idempotency_key": "legacy-processed"}, + {"command_id": 9, "idempotency_key": "legacy-pending"}, + ], "legacy reverse map reconstructs processed accepted idempotency metadata") + _expect(restored.get_result_by_idempotency_key(&"legacy-processed") != null, "legacy processed key remains exactly-once after migration") + + +func _test_transaction_commit() -> void: + var state := {"gold": 100, "stock": 3} + var transaction = TransactionType.new() + transaction.stage_dictionary_increment(state, "gold", -40, 0) + transaction.stage_dictionary_increment(state, "stock", -1, 0) + _expect(state == {"gold": 100, "stock": 3}, "staging does not mutate state") + var result = transaction.commit() + _expect(result.is_success(), "valid transaction commits") + _expect(state == {"gold": 60, "stock": 2}, "all staged changes commit together") + + +func _test_validation_is_atomic() -> void: + var state := {"gold": 100, "stock": 3} + var transaction = TransactionType.new() + transaction.stage_dictionary_set(state, "gold", 60) + transaction.stage_dictionary_set(state, "stock", 2) + transaction.add_validator(func() -> bool: return false) + var result = transaction.commit() + _expect(result.code == CommandResultType.Code.VALIDATION_FAILED, "validation failure reported") + _expect(state == {"gold": 100, "stock": 3}, "validation failure applies no changes") + + +func _test_apply_failure_rolls_back() -> void: + var state := {"gold": 100, "stock": 3} + var transaction = TransactionType.new() + transaction.stage_dictionary_set(state, "gold", 60) + transaction.stage_custom( + "failing stock update", + func() -> bool: return true, + func() -> bool: + state["stock"] = 2 + return false, + func() -> void: state["stock"] = 3 + ) + var result = transaction.commit() + _expect( + result.code == CommandResultType.Code.TRANSACTION_APPLY_FAILED, + "apply failure reported" + ) + _expect(state == {"gold": 100, "stock": 3}, "applied changes roll back in reverse order") + + +func _command( + command_id: int, + execute_tick: int, + priority: int, + idempotency_key: StringName +): + return CommandType.new( + command_id, + 0, + execute_tick, + priority, + &"Test", + &"actor", + &"target", + {}, + idempotency_key + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s0/test_command_transaction.gd.uid b/tests/s0/test_command_transaction.gd.uid new file mode 100644 index 0000000..364572e --- /dev/null +++ b/tests/s0/test_command_transaction.gd.uid @@ -0,0 +1 @@ +uid://dulabdbgejdnn diff --git a/tests/s0/test_definition_registry.gd b/tests/s0/test_definition_registry.gd new file mode 100644 index 0000000..88cb2b2 --- /dev/null +++ b/tests/s0/test_definition_registry.gd @@ -0,0 +1,291 @@ +extends SceneTree + +const DefinitionRegistryScript := preload("res://scripts/core/definition_registry.gd") +const FacilityCatalogScript := preload("res://scripts/data/facility_catalog.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_catalog_references() + _test_expansion_catalog() + _test_default_registry() + _test_detailed_definition_schema() + _test_invalid_detailed_service_is_rejected() + _test_canonical_added_baselines() + _test_queries() + _test_duplicate_detection_is_deterministic() + _test_missing_path_is_structured() + if _failures == 0: + print("PASS: DefinitionRegistry (%d checks)" % _checks) + else: + push_error("FAIL: DefinitionRegistry (%d of %d checks failed)" % [_failures, _checks]) + quit(_failures) + + +func _test_default_registry() -> void: + var registry = DefinitionRegistryScript.new() + var report: Dictionary = registry.rebuild() + _expect(bool(report[&"valid"]), "all baseline definitions pass registry schema validation") + _expect(int(report[&"source_count"]) == 16, "all sixteen baseline source files are inspected") + _expect(int(report[&"definition_count"]) == 16, "all sixteen valid definitions are indexed") + _expect(int(report[&"error_count"]) == 0, "baseline definitions have no fatal schema errors") + _expect(int(report[&"warning_count"]) == 0, "baseline definitions have no schema warnings") + var expected_ids: Array[StringName] = [ + &"adult_private_room", + &"cafeteria", + &"guest_corridor", + &"guest_elevator", + &"kitchen", + &"massage_room", + &"public_bath", + &"reception", + &"security_station", + &"service_corridor", + &"service_elevator", + &"staff_door", + &"staff_waiting_room", + &"standard_room", + &"tavern", + &"warehouse", + ] + _expect( + registry.get_facility_ids() == expected_ids, + "all sixteen facility IDs are returned in deterministic lexical order" + ) + _expect(registry.has_facility(&"reception"), "has_facility finds a known ID") + _expect(registry.has_facility(&"adult_private_room"), "has_facility finds the underground baseline ID") + _expect(registry.get_facility(&"missing") == null, "unknown lookup returns null") + + +func _test_catalog_references() -> void: + _expect(FacilityCatalogScript.FACILITY_PATHS.size() == 16, "catalog exposes sixteen stable resource references") + _expect(FacilityCatalogScript.BASELINE_FACILITY_IDS.size() == 16, "catalog exposes sixteen stable baseline IDs") + var loaded: Array[FacilityDefinition] = FacilityCatalogScript.load_all() + _expect(loaded.size() == 16, "every catalog resource reference resolves") + var loaded_ids: Array[StringName] = [] + for index in FacilityCatalogScript.FACILITY_PATHS.size(): + var path: String = FacilityCatalogScript.FACILITY_PATHS[index] + var expected_id: StringName = FacilityCatalogScript.BASELINE_FACILITY_IDS[index] + _expect(FileAccess.file_exists(path), "catalog reference %s exists" % path) + var definition := load(path) as FacilityDefinition + _expect(definition != null, "catalog reference %s loads as FacilityDefinition" % path) + if definition != null: + loaded_ids.append(definition.id) + _expect(definition.id == expected_id, "catalog reference %s preserves its expected ID" % path) + _expect(loaded_ids == FacilityCatalogScript.BASELINE_FACILITY_IDS, "catalog loading preserves the design-table order") + _expect(FacilityCatalogScript.by_id(&"service_elevator") != null, "catalog by_id resolves an added baseline") + _expect(FacilityCatalogScript.by_id(&"not_a_facility") == null, "catalog by_id rejects an unknown reference") + + +func _test_detailed_definition_schema() -> void: + var registry = DefinitionRegistryScript.new() + registry.rebuild() + var reception: FacilityDefinition = registry.get_facility(&"reception") + var kitchen: FacilityDefinition = registry.get_facility(&"kitchen") + var restricted: FacilityDefinition = registry.get_facility(&"adult_private_room") + _expect(reception.has_detailed_profile(), "reception uses the detailed facility schema") + _expect(reception.default_service_type == &"check_in", "reception declares its default service") + _expect(reception.service_profiles.size() == 2, "reception defines check-in and check-out profiles") + _expect(reception.service_profiles[&"check_out"][&"duration_minutes"] == 6, "check-out has an independent duration") + _expect(kitchen.has_detailed_profile(), "kitchen uses the detailed facility schema") + _expect(kitchen.service_profiles[&"meal_batch"][&"capacity_units"] == 4, "kitchen service produces a four-unit batch") + _expect(restricted.has_detailed_profile(), "restricted room uses the detailed facility schema") + _expect(restricted.opening_schedule == {&"morning": false, &"day": false, &"night": true}, "restricted room is night-only") + _expect(restricted.service_profiles[&"private_adult_service"][&"need_effects"][&"intimacy"] == -70, "restricted service records need relief") + _expect(restricted.content_replacement_id == &"massage_room", "restricted service declares a safe replacement") + + +func _test_invalid_detailed_service_is_rejected() -> void: + var registry = DefinitionRegistryScript.new() + var paths: Array[String] = ["res://tests/fixtures/facilities/invalid_service_profile.tres"] + var report: Dictionary = registry.rebuild_from_paths(paths) + var errors: Array[Dictionary] = registry.get_fatal_errors() + _expect(not bool(report[&"valid"]), "an invalid detailed service profile makes the registry invalid") + _expect(registry.facility_count() == 0, "an invalid detailed facility is not exposed through lookup") + _expect(errors.size() == 1, "invalid service duration produces one focused issue") + _expect(errors[0][&"code"] == &"invalid_service_profile_value", "invalid service duration has a stable issue code") + _expect(errors[0][&"field"] == &"service_profiles", "invalid service issue identifies its owning field") + + +func _test_expansion_catalog() -> void: + _expect(FacilityCatalogScript.EXPANSION_FACILITY_IDS.size() == 16, "catalog exposes sixteen optional expansion IDs") + _expect(FacilityCatalogScript.EXPANSION_FACILITY_PATHS.size() == 16, "catalog exposes sixteen optional expansion resources") + var loaded: Array[FacilityDefinition] = FacilityCatalogScript.load_expansion() + _expect(loaded.size() == 16, "every optional expansion resource resolves") + var loaded_ids: Array[StringName] = [] + for index in FacilityCatalogScript.EXPANSION_FACILITY_PATHS.size(): + var path: String = FacilityCatalogScript.EXPANSION_FACILITY_PATHS[index] + var expected_id: StringName = FacilityCatalogScript.EXPANSION_FACILITY_IDS[index] + _expect(FileAccess.file_exists(path), "expansion catalog reference %s exists" % path) + var definition := load(path) as FacilityDefinition + _expect(definition != null, "expansion catalog reference %s loads" % path) + if definition != null: + loaded_ids.append(definition.id) + _expect(definition.id == expected_id, "expansion resource preserves %s" % expected_id) + _expect(definition.is_valid(), "expansion definition %s passes its resource schema" % expected_id) + _expect(loaded_ids == FacilityCatalogScript.EXPANSION_FACILITY_IDS, "expansion loading preserves design order") + _expect(FacilityCatalogScript.by_id(&"clinic") != null, "catalog by_id resolves an optional expansion") + _expect( + FacilityCatalogScript.by_id(&"vip_private_salon").zone == FacilityDefinition.MapZone.UNDERGROUND, + "restricted expansion is constrained to the underground zone" + ) + _expect( + FacilityCatalogScript.by_id(&"mana_control_room").layer == FacilityDefinition.ViewLayer.BACK, + "infrastructure expansion is constrained to the BACK layer" + ) + + +func _test_canonical_added_baselines() -> void: + var registry = DefinitionRegistryScript.new() + registry.rebuild() + var rows: Array = [ + [&"kitchen", 700, 10, Vector2i(4, 2), 1, 3, 20, 15, 0], + [&"public_bath", 1200, 16, Vector2i(6, 2), 1, 2, 12, 60, 20], + [&"staff_waiting_room", 400, 6, Vector2i(4, 2), 0, 0, 8, 1, 0], + [&"security_station", 900, 10, Vector2i(2, 2), 1, 3, 3, 1, 0], + [&"tavern", 1000, 12, Vector2i(4, 2), 2, 4, 14, 50, 40], + [&"massage_room", 800, 8, Vector2i(2, 1), 1, 2, 2, 60, 100], + [&"guest_elevator", 1500, 15, Vector2i(2, 2), 0, 0, 4, 10, 0], + [&"service_elevator", 2200, 20, Vector2i(3, 2), 0, 0, 8, 5, 0], + [&"staff_door", 100, 1, Vector2i(1, 1), 0, 0, 1, 1, 0], + [&"guest_corridor", 20, 0, Vector2i(1, 1), 0, 0, 3, 1, 0], + [&"service_corridor", 25, 0, Vector2i(1, 1), 0, 0, 2, 1, 0], + [&"adult_private_room", 1500, 14, Vector2i(2, 1), 1, 1, 2, 90, 250], + ] + for raw_row: Variant in rows: + var row := raw_row as Array + var facility_id: StringName = row[0] + var definition: FacilityDefinition = registry.get_facility(facility_id) + _expect(definition != null, "%s canonical baseline is registered" % facility_id) + if definition == null: + continue + _expect( + definition.construction_cost == {&"gold": row[1], &"materials": row[2]}, + "%s uses canonical Gold and Materials cost" % facility_id + ) + _expect(definition.footprint == row[3], "%s uses canonical footprint" % facility_id) + _expect( + definition.staff_min == row[4] and definition.staff_max == row[5], + "%s uses canonical Min/Max staffing" % facility_id + ) + _expect(definition.capacity == row[6], "%s uses canonical capacity" % facility_id) + _expect(definition.base_service_minutes == row[7], "%s uses canonical duration or one-minute proxy" % facility_id) + _expect(definition.base_price == row[8], "%s uses canonical direct price" % facility_id) + _expect(definition.is_valid(), "%s passes the FacilityDefinition schema" % facility_id) + + var kitchen: FacilityDefinition = registry.get_facility(&"kitchen") + _expect(kitchen.inventory_outputs.get(&"ingredient/basic_meal", 0) == 4, "kitchen models the canonical four-meal production batch") + var restricted: FacilityDefinition = registry.get_facility(&"adult_private_room") + _expect(restricted.zone == FacilityDefinition.MapZone.UNDERGROUND, "restricted private room is constrained to the underground zone") + _expect(&"adult_only" in restricted.tags, "restricted private room carries an explicit adult-only systems guard tag") + + +func _test_queries() -> void: + var registry = DefinitionRegistryScript.new() + registry.rebuild() + var service_facilities: Array[StringName] = [ + &"adult_private_room", &"massage_room", &"public_bath", &"reception", &"tavern", + ] + var any_zone: Array[StringName] = [ + &"cafeteria", + &"guest_corridor", + &"guest_elevator", + &"kitchen", + &"massage_room", + &"public_bath", + &"security_station", + &"service_corridor", + &"service_elevator", + &"staff_door", + &"staff_waiting_room", + &"tavern", + &"warehouse", + ] + var back_facilities: Array[StringName] = [ + &"kitchen", + &"security_station", + &"service_corridor", + &"service_elevator", + &"staff_door", + &"staff_waiting_room", + &"warehouse", + ] + _expect( + _ids_of(registry.query_facilities_by_tag(&"service")) == service_facilities, + "service tag query returns every matching definition in stable order" + ) + _expect( + _ids_of(registry.query_facilities_by_zone(FacilityDefinition.MapZone.ANY)) == any_zone, + "zone query preserves ID order" + ) + _expect( + _ids_of(registry.query_facilities_by_zone(FacilityDefinition.MapZone.UNDERGROUND)) == [&"adult_private_room"], + "underground query isolates its restricted baseline" + ) + _expect( + _ids_of(registry.query_facilities_by_layer(FacilityDefinition.ViewLayer.BACK)) == back_facilities, + "BACK layer query returns all logistics and staff definitions" + ) + _expect( + _ids_of(registry.query_facilities_by_tag(&"corridor")) == [&"guest_corridor", &"service_corridor"], + "corridor tag identifies both route-subslot proxies" + ) + _expect( + _ids_of(registry.query_facilities_by_tag(&"vertical_connector")) == [&"guest_elevator", &"service_elevator"], + "vertical connector tag identifies both elevators" + ) + _expect( + _ids_of(registry.query_facilities_by_tag(&"cross_layer_connector")) == [&"staff_door"], + "cross-layer connector query identifies the staff door proxy" + ) + + +func _test_duplicate_detection_is_deterministic() -> void: + var registry = DefinitionRegistryScript.new() + var duplicate_paths: Array[String] = [ + "res://data/facilities/reception.tres", + "res://data/facilities/reception.tres", + ] + var first_report: Dictionary = registry.rebuild_from_paths(duplicate_paths) + var first_errors: Array[Dictionary] = registry.get_fatal_errors() + var second_report: Dictionary = registry.rebuild_from_paths(duplicate_paths) + _expect(not bool(first_report[&"valid"]), "duplicate IDs make the registry invalid") + _expect(int(first_report[&"error_count"]) == 2, "every duplicate source receives a fatal issue") + _expect(registry.facility_count() == 0, "duplicate definitions are excluded from lookup") + _expect(first_errors == registry.get_fatal_errors(), "issue order is deterministic across rebuilds") + _expect(first_report[&"errors"][0][&"code"] == &"duplicate_id", "duplicate issue has a stable code") + _expect(int(second_report[&"warning_count"]) == 1, "duplicate source paths produce a warning") + + +func _test_missing_path_is_structured() -> void: + var registry = DefinitionRegistryScript.new() + var paths: Array[String] = ["res://data/facilities/does_not_exist.tres"] + registry.rebuild_from_paths(paths) + var errors: Array[Dictionary] = registry.get_fatal_errors() + _expect(errors.size() == 1, "missing path produces one fatal issue") + _expect(errors[0][&"severity"] == &"error", "issue carries severity") + _expect(errors[0][&"code"] == &"resource_not_found", "missing path has a stable code") + _expect(errors[0][&"field"] == &"resource_path", "issue identifies its field") + _expect(errors[0][&"resource_path"] == paths[0], "issue identifies its source") + + +func _ids_of(definitions: Array[FacilityDefinition]) -> Array[StringName]: + var result: Array[StringName] = [] + for definition in definitions: + result.append(definition.id) + return result + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s0/test_definition_registry.gd.uid b/tests/s0/test_definition_registry.gd.uid new file mode 100644 index 0000000..bc23a50 --- /dev/null +++ b/tests/s0/test_definition_registry.gd.uid @@ -0,0 +1 @@ +uid://cwok3f6pmjar5 diff --git a/tests/s0/test_determinism.gd b/tests/s0/test_determinism.gd new file mode 100644 index 0000000..c7fccf9 --- /dev/null +++ b/tests/s0/test_determinism.gd @@ -0,0 +1,131 @@ +extends SceneTree + +const DeterministicRngScript = preload("res://scripts/core/deterministic_rng.gd") +const SimulationDigestScript = preload("res://scripts/core/simulation_digest.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S0: deterministic RNG and simulation digest") + _test_stream_reproducibility() + _test_stream_isolation() + _test_snapshot_restore() + _test_unbiased_range_contract() + _test_digest_normalization() + _test_digest_sequence_sensitivity() + _test_digest_float_normalization() + + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_stream_reproducibility() -> void: + var first = DeterministicRngScript.from_root_seed(20260827, &"guest_ai") + var second = DeterministicRngScript.from_root_seed(20260827, &"guest_ai") + var first_values: Array[int] = [] + var second_values: Array[int] = [] + for draw_index in range(64): + first_values.append(first.next_u32()) + second_values.append(second.next_u32()) + _expect(first_values == second_values, "same root seed and stream ID reproduce the sequence") + _expect(first.get_counter() == 64, "raw draw counter advances exactly once per sample") + + +func _test_stream_isolation() -> void: + var guest = DeterministicRngScript.from_root_seed(20260827, &"guest_ai") + var events = DeterministicRngScript.from_root_seed(20260827, &"event_service") + var guest_values: Array[int] = [] + var event_values: Array[int] = [] + for draw_index in range(16): + guest_values.append(guest.next_u32()) + event_values.append(events.next_u32()) + _expect(guest_values != event_values, "different subsystem stream IDs derive isolated sequences") + + +func _test_snapshot_restore() -> void: + var original = DeterministicRngScript.from_root_seed(-17, &"authority_service") + for draw_index in range(11): + original.next_u32() + var saved_state: Dictionary = original.snapshot() + var expected_tail: Array[int] = [] + for draw_index in range(20): + expected_tail.append(original.next_u32()) + + var restored = DeterministicRngScript.from_root_seed(0, &"temporary") + _expect(restored.restore(saved_state), "valid RNG snapshot restores") + var actual_tail: Array[int] = [] + for draw_index in range(20): + actual_tail.append(restored.next_u32()) + _expect(actual_tail == expected_tail, "restored state continues at the exact next sample") + _expect(restored.get_counter() == 31, "snapshot restores and continues the draw counter") + + +func _test_unbiased_range_contract() -> void: + var rng = DeterministicRngScript.from_root_seed(99, &"range_test") + var all_samples_inside := true + for draw_index in range(4096): + var sample: int = rng.randi_range(-7, 13) + all_samples_inside = all_samples_inside and sample >= -7 and sample <= 13 + _expect(all_samples_inside, "inclusive integer range never produces an out-of-range value") + var counter_before_constant: int = rng.get_counter() + _expect(rng.randi_range(5, 5) == 5, "single-value range returns that value") + _expect(rng.get_counter() == counter_before_constant, "single-value range consumes no random sample") + + +func _test_digest_normalization() -> void: + var first := {} + first[&"resources"] = {&"gold": 4500, &"mana": 100} + first[&"minute"] = 721 + first[&"positions"] = [Vector2i(3, 4), Vector2i(-2, 8)] + + var second := {} + second[&"positions"] = [Vector2i(3, 4), Vector2i(-2, 8)] + second[&"minute"] = 721 + var reversed_resources := {} + reversed_resources[&"mana"] = 100 + reversed_resources[&"gold"] = 4500 + second[&"resources"] = reversed_resources + + var first_digest: String = SimulationDigestScript.compute(first) + var second_digest: String = SimulationDigestScript.compute(second) + _expect(first_digest.length() == 64, "simulation digest is a 256-bit lowercase hexadecimal value") + _expect(first_digest == second_digest, "dictionary insertion order does not affect the digest") + + +func _test_digest_sequence_sensitivity() -> void: + var forward: String = SimulationDigestScript.compute([1, 2, 3]) + var reversed: String = SimulationDigestScript.compute([3, 2, 1]) + _expect( + forward == "4e32bb2921e1b879d5b8778789d3bc7e46559d4448e95f9c2fed427d7e5c7b93", + "canonical digest format matches its versioned reference vector" + ) + _expect(forward != reversed, "array order remains significant") + _expect( + SimulationDigestScript.compute({"value": 1}) != SimulationDigestScript.compute({"value": "1"}), + "value types remain significant" + ) + + +func _test_digest_float_normalization() -> void: + _expect( + SimulationDigestScript.compute({"zero": 0.0}) == SimulationDigestScript.compute({"zero": -0.0}), + "positive and negative floating-point zero share one canonical representation" + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s0/test_determinism.gd.uid b/tests/s0/test_determinism.gd.uid new file mode 100644 index 0000000..ca0f08d --- /dev/null +++ b/tests/s0/test_determinism.gd.uid @@ -0,0 +1 @@ +uid://6ob7pdgspn68 diff --git a/tests/s0/test_domain_events.gd b/tests/s0/test_domain_events.gd new file mode 100644 index 0000000..76e0e04 --- /dev/null +++ b/tests/s0/test_domain_events.gd @@ -0,0 +1,121 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("S0: domain events") + _test_single_publish_and_snapshot() + _test_atomic_batch_and_reentrant_delivery() + _test_batch_rejection_is_atomic() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_single_publish_and_snapshot() -> void: + var stream := DomainEventStream.new() + var delivery_order: Array[StringName] = [] + stream.subscribe(&"SERVICE_COMPLETED", &"z_report", func(_event: SimulationDomainEvent) -> void: delivery_order.append(&"z_report")) + stream.subscribe(&"*", &"a_log", func(_event: SimulationDomainEvent) -> void: delivery_order.append(&"a_log")) + var event := SimulationDomainEvent.new( + &"domain_event.000000000001", + &"SERVICE_COMPLETED", + 360, + &"facility_service", + [&"facility.000000000001"], + {&"quality": 80}, + 12 + ) + _expect(event.is_valid(), "complete domain event is valid") + _expect(stream.publish(event), "new fact is published") + _expect(delivery_order == [&"a_log", &"z_report"], "subscribers run in stable ID order") + _expect(not stream.publish(event), "same event ID cannot be applied twice") + _expect(delivery_order.size() == 2, "duplicate event is not redelivered") + var snapshot := stream.create_snapshot() + var restored := DomainEventStream.new() + _expect(restored.restore_snapshot(snapshot), "published event state restores") + _expect(restored.has_published(event.event_id), "deduplication IDs survive restore") + var recent := restored.recent_events() + _expect(recent.size() == 1 and recent[0].payload[&"quality"] == 80, "recent fact data survives restore") + + +func _test_atomic_batch_and_reentrant_delivery() -> void: + var stream := DomainEventStream.new() + var deliveries: Array[StringName] = [] + var callback_probe := { + &"second_committed_before_delivery": false, + &"restore_rejected_during_delivery": false, + } + var event_a := _event(&"domain_event.batch.0001", &"A", 400) + var event_b := _event(&"domain_event.batch.0002", &"B", 400) + var event_c := _event(&"domain_event.batch.0003", &"C", 400) + var snapshot_before := stream.create_snapshot() + stream.subscribe(&"*", &"observer", func(event: SimulationDomainEvent) -> void: + deliveries.append(event.event_type) + if event.event_type == &"A": + callback_probe[&"second_committed_before_delivery"] = stream.has_published(event_b.event_id) + callback_probe[&"restore_rejected_during_delivery"] = not stream.restore_snapshot(snapshot_before) + _expect(stream.publish_batch([event_c]), "reentrant publication is accepted after the committed batch") + ) + _expect(stream.publish_batch([event_a, event_b]), "valid event batch commits") + _expect(bool(callback_probe[&"second_committed_before_delivery"]), "every batch ID is committed before the first callback") + _expect(bool(callback_probe[&"restore_rejected_during_delivery"]), "subscriber cannot replace stream state during delivery") + _expect(deliveries == [&"A", &"B", &"C"], "reentrant event delivery waits behind the complete batch") + _expect( + stream.has_published(event_a.event_id) \ + and stream.has_published(event_b.event_id) \ + and stream.has_published(event_c.event_id), + "batch and reentrant event IDs are all retained" + ) + stream.unsubscribe(&"*", &"observer") + + +func _test_batch_rejection_is_atomic() -> void: + var stream := DomainEventStream.new() + var deliveries: Array[StringName] = [] + stream.subscribe(&"*", &"observer", func(event: SimulationDomainEvent) -> void: + deliveries.append(event.event_type) + ) + var valid := _event(&"domain_event.reject.0001", &"VALID", 500) + var duplicate := _event(&"domain_event.reject.0001", &"DUPLICATE", 500) + var before := stream.create_snapshot() + _expect(not stream.publish_batch([valid, duplicate]), "duplicate ID inside a batch is rejected") + _expect(stream.create_snapshot() == before and deliveries.is_empty(), "duplicate batch rejection has no state or notification prefix") + var invalid := _event(&"domain_event.reject.0002", &"INVALID", 500) + invalid.event_type = &"" + _expect(not stream.publish_batch([valid, invalid]), "invalid member rejects the whole batch") + _expect(stream.create_snapshot() == before and deliveries.is_empty(), "invalid batch rejection remains atomic") + _expect(stream.publish(valid), "single publish still delegates to the batch boundary") + var after_valid := stream.create_snapshot() + _expect(not stream.publish_batch([_event(&"domain_event.reject.0003", &"NEW", 500), valid]), "collision with published history rejects the whole batch") + _expect(stream.create_snapshot() == after_valid and deliveries == [&"VALID"], "history collision cannot commit a new prefix") + var oversized: Array = [] + oversized.resize(DomainEventStream.MAX_BATCH_SIZE + 1) + _expect(not stream.publish_batch(oversized), "oversized batch is rejected before member access") + _expect(stream.create_snapshot() == after_valid, "oversized rejection preserves history") + + +func _event(event_id: StringName, event_type: StringName, minute: int) -> SimulationDomainEvent: + return SimulationDomainEvent.new( + event_id, + event_type, + minute, + &"test", + [], + {}, + 0 + ) + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s0/test_domain_events.gd.uid b/tests/s0/test_domain_events.gd.uid new file mode 100644 index 0000000..a8013dd --- /dev/null +++ b/tests/s0/test_domain_events.gd.uid @@ -0,0 +1 @@ +uid://3a3nnlur4gv3 diff --git a/tests/s0/test_kernel_and_save.gd b/tests/s0/test_kernel_and_save.gd new file mode 100644 index 0000000..e2ef943 --- /dev/null +++ b/tests/s0/test_kernel_and_save.gd @@ -0,0 +1,123 @@ +extends RefCounted + +static func run(expect: Callable) -> void: + _test_fixed_tick_order(expect) + _test_frame_budget_and_overload(expect) + _test_boundary_pause_stops_catch_up(expect) + _test_apply_gate_aborts_before_clock_increment(expect) + _test_snapshot_restore(expect) + _test_save_round_trip(expect) + +static func _test_fixed_tick_order(expect: Callable) -> void: + var clock = load("res://autoload/game_clock.gd").new() + clock.reset(1, 6, 0) + var phases: Array[int] = [] + clock.tick_phase_started.connect(func(phase: int, _minute: int) -> void: phases.append(phase)) + var handler_order: Array[StringName] = [] + clock.register_tick_handler(clock.TickPhase.MANA, &"z_last", func(_minute: int) -> void: handler_order.append(&"z_last")) + clock.register_tick_handler(clock.TickPhase.MANA, &"a_first", func(_minute: int) -> void: handler_order.append(&"a_first")) + clock.advance_one_minute() + expect.call(clock.absolute_minute == 361, "fixed tick advances exactly one game minute") + expect.call(phases == range(clock.TickPhase.size()), "tick phases run in the specified order") + expect.call(handler_order == [&"a_first", &"z_last"], "phase handlers use stable id order") + clock.free() + +static func _test_frame_budget_and_overload(expect: Callable) -> void: + var clock = load("res://autoload/game_clock.gd").new() + clock.reset(1, 6, 0) + var processed: int = clock.process_real_delta(5.0) + expect.call(processed == clock.MAX_TICKS_PER_FRAME, "one frame runs at most eight ticks") + expect.call(clock.absolute_minute == 368, "excess ticks remain queued instead of being skipped") + clock.reset(1, 6, 0) + clock.process_real_delta(100.0) + expect.call(clock.paused and clock.get_backlog_minutes() > 120, "large backlog pauses the simulation") + clock.free() + +static func _test_boundary_pause_stops_catch_up(expect: Callable) -> void: + var clock = load("res://autoload/game_clock.gd").new() + clock.reset(1, 6, 0) + clock.register_tick_handler( + clock.TickPhase.TIME_BOUNDARIES, + &"test.pause_boundary", + func(minute: int) -> void: + if minute == 361: + clock.set_paused(true) + ) + var processed: int = clock.process_real_delta(5.0) + expect.call(processed == 1, "a boundary pause stops the remaining frame tick budget") + expect.call(clock.absolute_minute == 361, "catch-up cannot advance beyond the pausing boundary") + clock.reset(1, 6, 0) + clock.advance_minutes(8) + expect.call(clock.absolute_minute == 361, "an initially-running manual batch stops when a handler pauses") + clock.unregister_tick_handler(clock.TickPhase.TIME_BOUNDARIES, &"test.pause_boundary") + clock.reset(1, 6, 0) + clock.set_paused(true) + clock.advance_minutes(2) + expect.call(clock.absolute_minute == 362, "an already-paused clock retains explicit manual stepping") + clock.free() + +static func _test_apply_gate_aborts_before_clock_increment(expect: Callable) -> void: + var clock = load("res://autoload/game_clock.gd").new() + clock.reset(1, 6, 0) + clock.register_tick_handler( + clock.TickPhase.APPLY_COMMANDS, + &"test.abort_tick", + func(_minute: int) -> void: + clock.set_paused(true) + clock.request_current_tick_abort() + ) + var starting_version: int = clock.snapshot_version + var processed: int = clock.process_real_delta(5.0) + expect.call(processed == 0, "an APPLY gate abort consumes no catch-up tick") + expect.call( + clock.absolute_minute == 360 and clock.snapshot_version == starting_version, + "an APPLY gate abort leaves Clock minute and snapshot version exact" + ) + clock.advance_minutes(1) + expect.call( + clock.absolute_minute == 360 and clock.snapshot_version == starting_version, + "manual stepping cannot bypass an APPLY gate abort" + ) + clock.free() + +static func _test_snapshot_restore(expect: Callable) -> void: + var clock = load("res://autoload/game_clock.gd").new() + var state = load("res://autoload/game_state.gd").new() + clock.reset(2, 5, 30) + state.reset() + var starting_revision: int = state.revision + expect.call(state.apply_resource_deltas({&"gold": -250, &"materials": -5}), "multi-resource transaction succeeds") + expect.call(state.revision == starting_revision + 1, "atomic resource transaction creates one revision") + var before_failed: Dictionary = state.create_snapshot() + expect.call(not state.apply_resource_deltas({&"gold": -999999, &"mana": 10}), "invalid transaction is rejected") + expect.call(state.create_snapshot() == before_failed, "rejected transaction changes no resource") + + var snapshot := SimulationSnapshot.capture(clock, state) + clock.advance_minutes(10) + state.add_resource(&"gold", 999) + expect.call(SimulationSnapshot.restore(snapshot, clock, state), "valid snapshot restores atomically") + expect.call(clock.absolute_minute == int(snapshot[&"clock"][&"absolute_minute"]), "clock snapshot restores exact minute") + expect.call(state.create_snapshot() == snapshot[&"game_state"], "game state snapshot restores exactly") + var minute_before_skip: int = clock.absolute_minute + expect.call(clock.process_real_delta(1.0) == 0 and clock.absolute_minute == minute_before_skip, "first frame after load does not advance") + clock.free() + state.free() + +static func _test_save_round_trip(expect: Callable) -> void: + var save_path := "user://saves/s0_round_trip" + SaveService.delete_save(save_path) + var clock = load("res://autoload/game_clock.gd").new() + var state = load("res://autoload/game_state.gd").new() + clock.reset(3, 18, 45) + state.reset() + state.apply_resource_deltas({&"gold": -700, &"mana": 25}) + var snapshot := SimulationSnapshot.capture(clock, state, {&"next_entity_id": 42}) + var save_result := SaveService.save_snapshot(save_path, snapshot, {&"display_name": "S0 Round Trip"}) + expect.call(save_result[&"ok"], "snapshot is written atomically") + var load_result := SaveService.load_snapshot(save_path) + expect.call(load_result[&"ok"], "saved snapshot passes checksum and schema validation") + if load_result[&"ok"]: + expect.call(load_result[&"snapshot"] == snapshot, "save and load preserve the complete snapshot") + SaveService.delete_save(save_path) + clock.free() + state.free() diff --git a/tests/s0/test_kernel_and_save.gd.uid b/tests/s0/test_kernel_and_save.gd.uid new file mode 100644 index 0000000..59bc4cd --- /dev/null +++ b/tests/s0/test_kernel_and_save.gd.uid @@ -0,0 +1 @@ +uid://w4p6kwpj1x46 diff --git a/tests/s0/test_runtime_integration.gd b/tests/s0/test_runtime_integration.gd new file mode 100644 index 0000000..e69d5f9 --- /dev/null +++ b/tests/s0/test_runtime_integration.gd @@ -0,0 +1,201 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("S0: runtime integration") + _test_tick_command_execution() + _test_starter_world_layers() + _test_paused_command_execution() + _test_runtime_snapshot_restore() + _test_runtime_save_round_trip() + _test_twenty_four_hour_determinism() + _cleanup_runtime() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + +func _reset_runtime(seed := 20260827) -> void: + _cleanup_runtime() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, seed) + +func _cleanup_runtime() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + +func _test_tick_command_execution() -> void: + _reset_runtime() + var initial_gold: int = _state.get_resource(&"gold") + var result = _runtime.submit_command( + &"spend_resources", + {&"cost": {&"gold": 100}}, + &"player", + &"", + 50, + _clock.absolute_minute + 5, + &"integration.spend.future" + ) + _expect(result.is_success(), "future resource command enters the queue") + _clock.advance_minutes(4) + _expect(_state.get_resource(&"gold") == initial_gold, "future command does not execute early") + _clock.advance_one_minute() + _expect(_state.get_resource(&"gold") == initial_gold - 100, "command executes on its scheduled minute") + var processed = _runtime.command_queue.get_result_by_idempotency_key(&"integration.spend.future") + _expect(processed != null and processed.is_success(), "processed result remains available by idempotency key") + +func _test_starter_world_layers() -> void: + _reset_runtime() + var front_start := Vector3i(-2, 0, WorldService.LAYER_FRONT) + var back_goal := Vector3i(2, 0, WorldService.LAYER_BACK) + var guest_path: Array[Vector3i] = _runtime.path_service.find_path( + front_start, back_goal, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK] + ) + _expect(guest_path.is_empty(), "guest access cannot cross the FRONT/BACK staff door") + var staff_path: Array[Vector3i] = _runtime.path_service.find_path( + front_start, + back_goal, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], + [&"staff"] + ) + _expect(not staff_path.is_empty(), "staff access crosses the explicit FRONT/BACK connector") + +func _test_paused_command_execution() -> void: + _reset_runtime() + _clock.set_paused(true) + var minute_before: int = _clock.absolute_minute + var result = _runtime.submit_command( + &"change_resources", + {&"deltas": {&"faith": 7}}, + &"player", + &"", + 0, + -1, + &"integration.paused.faith" + ) + _expect(result.is_success() and _state.get_resource(&"faith") == 7, "current-minute command can complete while paused") + _expect(_clock.absolute_minute == minute_before, "paused command does not advance simulation time") + var duplicate = _runtime.submit_command( + &"change_resources", + {&"deltas": {&"faith": 7}}, + &"player", + &"", + 0, + -1, + &"integration.paused.faith" + ) + _expect(duplicate.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, "duplicate idempotency key cannot apply twice") + +func _test_runtime_snapshot_restore() -> void: + _reset_runtime(77) + var authority_rng = _runtime.get_rng_stream(&"authority") + for _index in range(9): + authority_rng.next_u32() + _runtime.submit_command( + &"spend_resources", + {&"cost": {&"materials": 3}}, + &"player", + &"", + 0, + _clock.absolute_minute + 30, + &"integration.pending.materials" + ) + var snapshot: Dictionary = _runtime.create_snapshot() + var expected_digest := SimulationDigest.compute(snapshot) + var expected_rng_tail: int = authority_rng.next_u32() + _clock.advance_minutes(12) + _state.add_resource(&"gold", 500) + _expect(_runtime.restore_snapshot(snapshot), "runtime restores clock, state, queue and RNG streams") + _expect(_runtime.latest_digest == expected_digest, "restored runtime recreates the exact digest") + _expect(_runtime.get_rng_stream(&"authority").next_u32() == expected_rng_tail, "restored RNG resumes at the exact next draw") + _clock.advance_minutes(30) + _expect(_state.get_resource(&"materials") == 47, "restored pending command executes once") + +func _test_twenty_four_hour_determinism() -> void: + var first_snapshot := _run_day_scenario(99117) + var second_snapshot := _run_day_scenario(99117) + var first_digest := SimulationDigest.compute(first_snapshot) + var second_digest := SimulationDigest.compute(second_snapshot) + if first_digest != second_digest: + _report_digest_difference(first_snapshot, second_snapshot) + _expect(first_digest.length() == 64, "24-hour scenario produces a simulation digest") + _expect(first_digest == second_digest, "same seed and command sequence match after 24 game hours") + +func _test_runtime_save_round_trip() -> void: + var save_path := "user://saves/s0_runtime_round_trip" + SaveService.delete_save(save_path) + _reset_runtime(54321) + _runtime.get_rng_stream(&"event").next_u32() + _runtime.submit_command( + &"spend_resources", + {&"cost": {&"gold": 333}}, + &"player", &"", 0, _clock.absolute_minute + 20, &"runtime.save.pending" + ) + var expected_digest := SimulationDigest.compute(_runtime.create_snapshot()) + var save_result: Dictionary = _runtime.save_to_path(save_path, {&"display_name": "Runtime Round Trip"}) + _expect(save_result[&"ok"], "runtime snapshot writes command queue and RNG state to disk") + _clock.advance_minutes(25) + _state.add_resource(&"gold", 999) + var load_result: Dictionary = _runtime.load_from_path(save_path) + _expect(load_result[&"ok"], "runtime snapshot loads from verified world data") + _expect(_runtime.latest_digest == expected_digest, "disk round trip restores the exact runtime digest") + _clock.advance_minutes(20) + _expect(_state.get_resource(&"gold") == 4667, "disk-restored pending command executes exactly once") + SaveService.delete_save(save_path) + +func _run_day_scenario(seed: int) -> Dictionary: + _reset_runtime(seed) + var guest_rng = _runtime.get_rng_stream(&"guest_spawn") + for _index in range(16): + guest_rng.randi_range(1, 100) + _runtime.submit_command( + &"spend_resources", + {&"cost": {&"gold": 250, &"materials": 2}}, + &"player", &"", 20, 6 * 60 + 60, &"scenario.build" + ) + _runtime.submit_command( + &"change_resources", + {&"deltas": {&"mana": 15, &"faith": 3}}, + &"event", &"", 10, 6 * 60 + 12 * 60, &"scenario.reward" + ) + _clock.advance_minutes(24 * 60) + return _runtime.latest_snapshot.duplicate(true) + +func _report_digest_difference(first: Dictionary, second: Dictionary) -> void: + var first_runtime: Dictionary = first[&"systems"][&"runtime"] + var second_runtime: Dictionary = second[&"systems"][&"runtime"] + var keys: Array = first_runtime.keys() + keys.sort() + for key: Variant in keys: + var first_part := SimulationDigest.compute(first_runtime[key]) + var second_part := SimulationDigest.compute(second_runtime.get(key)) + if first_part != second_part: + print(" digest mismatch in runtime.%s: %s != %s" % [key, first_part, second_part]) + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s0/test_runtime_integration.gd.uid b/tests/s0/test_runtime_integration.gd.uid new file mode 100644 index 0000000..8daeec0 --- /dev/null +++ b/tests/s0/test_runtime_integration.gd.uid @@ -0,0 +1 @@ +uid://brilcgnxebg7u diff --git a/tests/s0/test_stable_id_generator.gd b/tests/s0/test_stable_id_generator.gd new file mode 100644 index 0000000..38ab4aa --- /dev/null +++ b/tests/s0/test_stable_id_generator.gd @@ -0,0 +1,35 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("S0: stable ID generator") + var generator := StableIdGenerator.new() + _expect(generator.next_id(&"guest") == &"guest.000000000001", "first ID uses a stable padded sequence") + _expect(generator.next_id(&"guest") == &"guest.000000000002", "same prefix increments monotonically") + _expect(generator.next_id(&"staff") == &"staff.000000000001", "prefixes have independent counters") + var snapshot := generator.create_snapshot() + var restored := StableIdGenerator.new() + _expect(restored.restore_snapshot(snapshot), "valid ID counters restore") + _expect(restored.next_id(&"guest") == &"guest.000000000003", "restored counter never reuses an ID") + _expect(restored.next_id(&"staff") == &"staff.000000000002", "all prefix counters restore") + var before_invalid := restored.create_snapshot() + _expect(not restored.restore_snapshot({"version": 1, "counters": {"Bad Prefix": 2}}), "invalid prefix snapshot is rejected") + _expect(restored.create_snapshot() == before_invalid, "rejected restore leaves counters unchanged") + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s0/test_stable_id_generator.gd.uid b/tests/s0/test_stable_id_generator.gd.uid new file mode 100644 index 0000000..4fce3a4 --- /dev/null +++ b/tests/s0/test_stable_id_generator.gd.uid @@ -0,0 +1 @@ +uid://cfgmvvsmbwg8t diff --git a/tests/s1/test_construction_service.gd b/tests/s1/test_construction_service.gd new file mode 100644 index 0000000..b034393 --- /dev/null +++ b/tests/s1/test_construction_service.gd @@ -0,0 +1,752 @@ +extends SceneTree + +const FacilityDefinitionType = preload("res://scripts/data/facility_definition.gd") +const PlacementReportType = preload("res://scripts/construction/placement_report.gd") +const ConstructionJobType = preload("res://scripts/construction/construction_job.gd") +const ConstructionServiceType = preload("res://scripts/construction/construction_service.gd") +const WorldServiceType = preload("res://scripts/world/world_service.gd") + +var _checks := 0 +var _failures := 0 + + +class FakeResourceState: + extends RefCounted + + var resources := {&"gold": 1_000, &"materials": 100} + var mutate_then_fail_spend := false + var fail_apply := false + var fail_restore := false + var reentrant_service: Variant + var reentrant_attempted := false + var reentrant_succeeded := false + + func get_resource(resource_name: StringName) -> int: + return int(resources.get(resource_name, 0)) + + func can_afford(cost: Dictionary) -> bool: + for resource_name: Variant in cost: + if get_resource(StringName(resource_name)) < int(cost[resource_name]): + return false + return true + + func spend(cost: Dictionary) -> bool: + if reentrant_service != null and not reentrant_attempted: + reentrant_attempted = true + var nested: Variant = reentrant_service.start_construction( + _valid_definition_for_reentry(), + Vector3i(2, 2, 0) + ) + reentrant_succeeded = nested.is_success() + if mutate_then_fail_spend: + resources[&"gold"] = int(resources[&"gold"]) - int(cost.get(&"gold", 0)) + return false + if not can_afford(cost): + return false + var deltas: Dictionary = {} + for resource_name: Variant in cost: + deltas[StringName(resource_name)] = -int(cost[resource_name]) + return apply_resource_deltas(deltas) + + func apply_resource_deltas(deltas: Dictionary) -> bool: + if fail_apply: + return false + var next := resources.duplicate(true) + for resource_name: Variant in deltas: + var key := StringName(resource_name) + next[key] = int(next.get(key, 0)) + int(deltas[resource_name]) + if int(next[key]) < 0: + return false + resources = next + return true + + func create_snapshot() -> Dictionary: + return {&"resources": resources.duplicate(true)} + + func restore_snapshot(snapshot: Dictionary) -> bool: + if fail_restore: + return false + if not snapshot.get(&"resources", null) is Dictionary: + return false + resources = snapshot[&"resources"].duplicate(true) + return true + + func _valid_definition_for_reentry() -> Dictionary: + return { + &"id": &"nested_room", + &"footprint": Vector2i.ONE, + &"zone": FacilityDefinitionType.MapZone.ANY, + &"layer": FacilityDefinitionType.ViewLayer.FRONT, + &"construction_cost": {&"gold": 100, &"materials": 10}, + &"build_minutes": 60, + &"weight_per_cell": 1, + } + + +class FakeWorld: + extends RefCounted + + var min_x := 0 + var max_x := 8 + var min_y := -2 + var max_y := 8 + var occupancy: Dictionary = {} + var unsupported_cells: Array[Vector3i] = [] + var fail_next_occupy := false + var fail_next_release := false + var fail_all_occupy := false + var excavation: Dictionary = {} + var forced_cells: Array[Vector3i] = [] + + func cells_for_footprint( + origin: Vector3i, + footprint: Vector2i, + rotation: int, + _layer: int + ) -> Array[Vector3i]: + if not forced_cells.is_empty(): + return forced_cells.duplicate() + var width := footprint.x + var height := footprint.y + if rotation % 180 != 0: + width = footprint.y + height = footprint.x + var cells: Array[Vector3i] = [] + for y_offset in height: + for x_offset in width: + cells.append(origin + Vector3i(x_offset, y_offset, 0)) + return cells + + func is_in_bounds(cell: Vector3i) -> bool: + return cell.x >= min_x and cell.x <= max_x and cell.y >= min_y and cell.y <= max_y + + func get_zone(cell: Vector3i) -> int: + if cell.y > 0: + return FacilityDefinitionType.MapZone.ABOVE_GROUND + if cell.y < 0: + return FacilityDefinitionType.MapZone.UNDERGROUND + return FacilityDefinitionType.MapZone.BOUNDARY + + func get_occupant(cell: Vector3i) -> Variant: + return occupancy.get(cell, &"") + + func is_excavated(cell: Vector3i) -> bool: + return bool(excavation.get(Vector2i(cell.x, cell.y), true)) + + func validate_support(_cells: Array[Vector3i], _weight_per_cell: int) -> Dictionary: + return { + &"ok": unsupported_cells.is_empty(), + &"unsupported_cells": unsupported_cells.duplicate(), + &"reason": "Support capacity is insufficient.", + } + + func occupy(occupant_id: StringName, cells: Array[Vector3i], _kind: StringName) -> bool: + if fail_all_occupy: + return false + if fail_next_occupy: + fail_next_occupy = false + return false + for cell: Vector3i in cells: + if not String(occupancy.get(cell, &"")).is_empty(): + return false + for cell: Vector3i in cells: + occupancy[cell] = occupant_id + return true + + func release(occupant_id: StringName) -> bool: + if fail_next_release: + fail_next_release = false + return false + var cells: Array[Vector3i] = [] + for cell: Vector3i in occupancy: + if occupancy[cell] == occupant_id: + cells.append(cell) + if cells.is_empty(): + return false + for cell: Vector3i in cells: + occupancy.erase(cell) + return true + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: Construction Service") + _test_report_collects_every_reason() + _test_start_is_atomic() + _test_cancellation_refund() + _test_completion_and_demolition() + _test_completion_rollback_and_retry() + _test_completion_rollback_failure_quarantines() + _test_progress_replay_is_idempotent() + _test_deferred_completion_commit_and_reentry() + _test_deferred_failure_discard_and_restore() + _test_ordinary_advance_notifications_unchanged() + _test_snapshot_round_trip() + _test_snapshot_strictness_and_v1_migration() + _test_definition_type_spoof_and_reentry() + _test_failed_rollback_quarantines_service() + _test_real_world_service_integration() + _test_underground_requires_excavation() + _test_every_footprint_cell_must_match_zone() + _test_boundary_facility_anchor_rule() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_report_collects_every_reason() -> void: + var world := FakeWorld.new() + world.max_x = 2 + var state := FakeResourceState.new() + state.resources = {&"gold": 0, &"materials": 0} + var definition := _definition( + &"invalid_location", + Vector2i(2, 1), + FacilityDefinitionType.MapZone.ABOVE_GROUND, + FacilityDefinitionType.ViewLayer.FRONT, + {&"gold": 100, &"materials": 10} + ) + var origin := Vector3i(2, -1, FacilityDefinitionType.ViewLayer.BACK) + world.occupancy[origin] = &"existing" + world.unsupported_cells = [origin] + var service := ConstructionServiceType.new(world, state) + var report = service.validate_placement(definition, origin) + + _expect(not report.can_build, "invalid placement is rejected") + _expect(report.has_error(PlacementReportType.ERROR_OUT_OF_BOUNDS), "bounds error is reported") + _expect(report.has_error(PlacementReportType.ERROR_ZONE_MISMATCH), "zone error is reported") + _expect(report.has_error(PlacementReportType.ERROR_LAYER_MISMATCH), "layer error is reported") + _expect(report.has_error(PlacementReportType.ERROR_OVERLAP), "overlap error is reported") + _expect(report.has_error(PlacementReportType.ERROR_UNSUPPORTED), "support error is reported") + _expect( + report.has_error(PlacementReportType.ERROR_INSUFFICIENT_RESOURCES), + "cost error is reported" + ) + _expect(report.reasons.size() == 6, "all independent placement failures are retained") + + +func _test_start_is_atomic() -> void: + var world := FakeWorld.new() + var state := FakeResourceState.new() + var service := ConstructionServiceType.new(world, state) + var definition := _valid_definition() + var resources_before := state.resources.duplicate(true) + world.fail_next_occupy = true + var failed = service.start_construction(definition, Vector3i(1, 1, 0)) + _expect(not failed.is_success(), "failed world reservation rejects construction") + _expect(state.resources == resources_before, "failed reservation rolls resource payment back") + _expect(world.occupancy.is_empty(), "failed reservation leaves no occupied cell") + _expect(service.get_jobs().is_empty(), "failed reservation creates no job") + + var started = service.start_construction(definition, Vector3i(1, 1, 0), 0, 4, 600) + _expect(started.is_success(), "valid construction starts") + var job = service.get_job(started.details[&"job_id"]) + _expect(job != null and job.state == ConstructionJobType.State.PLANNED, "new job starts PLANNED") + _expect(state.resources == {&"gold": 900, &"materials": 90}, "full construction cost is paid once") + _expect(world.get_occupant(Vector3i(1, 1, 0)) == job.job_id, "job atomically reserves its cells") + + +func _test_cancellation_refund() -> void: + var world := FakeWorld.new() + var state := FakeResourceState.new() + var service := ConstructionServiceType.new(world, state) + var first = service.start_construction(_valid_definition(), Vector3i(1, 1, 0), 0, 4) + var first_job_id: StringName = first.details[&"job_id"] + var cancelled = service.cancel_job(first_job_id) + _expect(cancelled.is_success(), "zero-progress job cancels") + _expect(state.resources == {&"gold": 1_000, &"materials": 100}, "zero-progress cancellation refunds all") + _expect(world.occupancy.is_empty(), "cancellation releases reserved cells") + + var second = service.start_construction(_valid_definition(), Vector3i(2, 2, 0), 0, 4) + var second_job_id: StringName = second.details[&"job_id"] + service.progress_job(second_job_id) + var job = service.get_job(second_job_id) + _expect(job.progress == 2_500 and job.state == ConstructionJobType.State.BUILDING, "one minute advances exact integer progress") + var partial = service.cancel_job(second_job_id) + _expect(partial.details[&"refund"] == {&"gold": 75, &"materials": 7}, "partial cancellation refunds the unbuilt ratio") + _expect(state.resources == {&"gold": 975, &"materials": 97}, "partial refund is applied once") + + +func _test_completion_and_demolition() -> void: + var world := FakeWorld.new() + var state := FakeResourceState.new() + var service := ConstructionServiceType.new(world, state) + var started = service.start_construction(_valid_definition(), Vector3i(3, 3, 0), 0, 2) + var job_id: StringName = started.details[&"job_id"] + var first_tick = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 601) + _expect(first_tick.is_success() and service.get_job(job_id).progress == 5_000, "first build minute reaches half progress") + var second_tick = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 602) + var job = service.get_job(job_id) + _expect(second_tick.is_success() and job.state == ConstructionJobType.State.COMPLETED, "final minute completes the job") + var instance_id: StringName = second_tick.details[&"instance_id"] + _expect(not service.get_facility(instance_id).is_empty(), "completion creates a facility instance") + _expect(world.get_occupant(Vector3i(3, 3, 0)) == instance_id, "completion replaces job reservation with facility occupancy") + + var demolished = service.demolish_facility(instance_id) + _expect(demolished.is_success(), "completed facility can be demolished") + _expect(world.occupancy.is_empty(), "demolition releases facility cells") + _expect(state.resources == {&"gold": 900, &"materials": 92}, "demolition returns 25 percent of materials") + + +func _test_completion_rollback_and_retry() -> void: + var world := FakeWorld.new() + var service := ConstructionServiceType.new(world, FakeResourceState.new()) + var started = service.start_construction(_valid_definition(), Vector3i(3, 3, 0), 0, 2) + var job_id: StringName = started.details[&"job_id"] + service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 10) + world.fail_next_occupy = true + var failed = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 11) + var job = service.get_job(job_id) + _expect(not failed.is_success() and failed.details[&"rolled_back"], "failed completion reports a verified world rollback") + _expect(job.state == ConstructionJobType.State.BLOCKED and job.progress == ConstructionJobType.MAX_PROGRESS, "interrupted completion retains all finished work") + _expect(world.get_occupant(Vector3i(3, 3, 0)) == job_id, "failed facility transfer restores the construction reservation") + var retry = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 12) + _expect(retry.is_success() and service.get_job(job_id).state == ConstructionJobType.State.COMPLETED, "a later minute deterministically retries the blocked completion") + + +func _test_completion_rollback_failure_quarantines() -> void: + var world := FakeWorld.new() + var service := ConstructionServiceType.new(world, FakeResourceState.new()) + var job_id: StringName = service.start_construction(_valid_definition(), Vector3i(3, 3, 0), 0, 2).details[&"job_id"] + service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 30) + world.fail_all_occupy = true + var failed = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 31) + _expect(not failed.is_success() and failed.details[&"rollback_failed"], "completion exposes failure to restore its reservation") + _expect(service.is_quarantined(), "unrecoverable completion rollback quarantines construction mutations") + _expect(not service.cancel_job(job_id).is_success(), "quarantined completion state cannot be mutated further") + + +func _test_progress_replay_is_idempotent() -> void: + var world := FakeWorld.new() + var service := ConstructionServiceType.new(world, FakeResourceState.new()) + var job_id: StringName = service.start_construction(_valid_definition(), Vector3i(1, 1, 0), 0, 4).details[&"job_id"] + service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 20) + var progress_once := service.get_job(job_id).progress + var replay = service.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 20) + _expect(replay.is_success() and replay.is_replay, "same-minute construction retry is an explicit replay") + _expect(service.get_job(job_id).progress == progress_once, "same-minute construction retry cannot add work twice") + + +func _test_deferred_completion_commit_and_reentry() -> void: + var world := FakeWorld.new() + var resources := FakeResourceState.new() + var service := ConstructionServiceType.new(world, resources) + var definition := _valid_definition() + var started = service.start_construction(definition, Vector3i(1, 1, 0), 0, 1) + var job_id: StringName = started.details[&"job_id"] + var before_advance: Dictionary = service.create_snapshot() + var order: Array[StringName] = [] + var delivered_jobs: Array[Dictionary] = [] + var delivered_facilities: Array[Dictionary] = [] + var reentry: Dictionary = {} + var job_changed_callback := func(job: ConstructionJob) -> void: + order.append(&"job_changed") + delivered_jobs.append(job.create_snapshot()) + job.assigned_builder_ids.append(&"listener_tamper") + var nested_advance: Array[CommandResult] = service.advance_one_minute(102) + reentry[&"advance_rejected"] = ( + nested_advance.size() == 1 and not nested_advance[0].is_success() + ) + reentry[&"commit_rejected"] = not service.commit_deferred_notifications() + reentry[&"discard_rejected"] = not service.discard_deferred_notifications() + reentry[&"snapshot_rejected"] = service.create_snapshot().is_empty() + reentry[&"restore_rejected"] = not service.restore_snapshot(before_advance) + reentry[&"mutation_rejected"] = not service.start_construction( + definition, Vector3i(5, 5, 0) + ).is_success() + var facility_completed_callback := func(instance: Dictionary) -> void: + order.append(&"facility_completed") + delivered_facilities.append(instance.duplicate(true)) + (instance[&"occupied_cells"] as Array).clear() + instance[&"definition_id"] = &"listener_tamper" + service.job_changed.connect(job_changed_callback) + service.facility_completed.connect(facility_completed_callback) + + var advanced: Array[CommandResult] = service.advance_one_minute(101, {}, true) + _expect( + advanced.size() == 1 and advanced[0].is_success(), + "deferred advance completes construction state" + ) + var instance_id := StringName(advanced[0].details.get(&"instance_id", &"")) + _expect( + order.is_empty() and delivered_jobs.is_empty() and delivered_facilities.is_empty(), + "deferred completion emits no raw signal before commit" + ) + _expect(service.has_deferred_notifications(), "deferred completion exposes a pending notification batch") + _expect(service.create_snapshot().is_empty(), "pending notifications fail closed for snapshot capture") + _expect( + not service.restore_snapshot(before_advance), + "pending notifications reject snapshot restore" + ) + var minute_before_rejected_advance := service.current_minute + var pending_advance: Array[CommandResult] = service.advance_one_minute(102) + _expect( + pending_advance.size() == 1 + and not pending_advance[0].is_success() + and pending_advance[0].details.get(&"code", &"") == &"DEFERRED_NOTIFICATIONS_PENDING" + and service.current_minute == minute_before_rejected_advance, + "a pending batch rejects another advance before changing its minute" + ) + _expect( + not service.start_construction(definition, Vector3i(5, 5, 0)).is_success(), + "a pending batch rejects unrelated mutation until its disposition is chosen" + ) + # get_job historically returns the live aggregate. Mutating it after staging + # proves the queued notification captured its own defensive point-in-time copy. + service.get_job(job_id).assigned_builder_ids.append(&"late_live_builder") + _expect(service.commit_deferred_notifications(), "deferred notifications commit explicitly") + _expect( + order == [&"job_changed", &"facility_completed"] + and delivered_jobs.size() == 1 + and delivered_facilities.size() == 1, + "commit emits completion signals exactly once in Construction order" + ) + _expect( + delivered_jobs[0][&"assigned_builder_ids"].is_empty() + and service.get_job(job_id).assigned_builder_ids == [&"late_live_builder"], + "queued and delivered ConstructionJob payloads are defensive copies" + ) + var live_facility: Dictionary = service.get_facility(instance_id) + _expect( + live_facility.get(&"definition_id", &"") == definition.id + and live_facility.get(&"occupied_cells", []) == [Vector3i(1, 1, 0)], + "listener mutation of a deferred facility payload cannot alter live state" + ) + _expect( + bool(reentry.get(&"advance_rejected", false)) + and bool(reentry.get(&"commit_rejected", false)) + and bool(reentry.get(&"discard_rejected", false)) + and bool(reentry.get(&"snapshot_rejected", false)) + and bool(reentry.get(&"restore_rejected", false)) + and bool(reentry.get(&"mutation_rejected", false)), + "signal callbacks cannot reenter notification dispatch, restore, snapshot, or mutation" + ) + var committed_order := order.duplicate() + _expect( + not service.has_deferred_notifications() + and service.commit_deferred_notifications() + and order == committed_order, + "an empty repeat commit succeeds without emitting twice" + ) + service.job_changed.disconnect(job_changed_callback) + service.facility_completed.disconnect(facility_completed_callback) + + +func _test_deferred_failure_discard_and_restore() -> void: + var world := FakeWorld.new() + var service := ConstructionServiceType.new(world, FakeResourceState.new()) + var started = service.start_construction(_valid_definition(), Vector3i(2, 2, 0), 0, 1) + var before_advance: Dictionary = service.create_snapshot() + var changed_count := [0] + var completed_count := [0] + var job_changed_callback := func(_job: ConstructionJob) -> void: + changed_count[0] += 1 + var facility_completed_callback := func(_instance: Dictionary) -> void: + completed_count[0] += 1 + service.job_changed.connect(job_changed_callback) + service.facility_completed.connect(facility_completed_callback) + world.fail_next_occupy = true + var advanced: Array[CommandResult] = service.advance_one_minute(201, {}, true) + _expect( + advanced.size() == 1 and not advanced[0].is_success(), + "a deferred completion retains its internal failure result" + ) + _expect( + service.has_deferred_notifications() + and changed_count[0] == 0 + and completed_count[0] == 0, + "notifications queued before an internal failure remain silent and pending" + ) + _expect( + service.create_snapshot().is_empty() + and not service.restore_snapshot(before_advance), + "failed deferred advances still block snapshot and restore while pending" + ) + _expect(service.discard_deferred_notifications(), "a failed deferred batch can be discarded") + _expect( + not service.has_deferred_notifications() + and service.discard_deferred_notifications() + and service.commit_deferred_notifications() + and changed_count[0] == 0 + and completed_count[0] == 0, + "discard is replay-safe and never emits the rejected batch" + ) + _expect( + service.restore_snapshot(before_advance) + and service.create_snapshot() == before_advance, + "discard permits the caller to restore Construction after failure" + ) + service.job_changed.disconnect(job_changed_callback) + service.facility_completed.disconnect(facility_completed_callback) + + +func _test_ordinary_advance_notifications_unchanged() -> void: + var service := ConstructionServiceType.new(FakeWorld.new(), FakeResourceState.new()) + var job_id: StringName = service.start_construction( + _valid_definition(), Vector3i(3, 3, 0), 0, 1 + ).details[&"job_id"] + var order: Array[StringName] = [] + var delivered_jobs: Array[ConstructionJob] = [] + var job_changed_callback := func(job: ConstructionJob) -> void: + order.append(&"job_changed") + delivered_jobs.append(job) + var facility_completed_callback := func(_instance: Dictionary) -> void: + order.append(&"facility_completed") + service.job_changed.connect(job_changed_callback) + service.facility_completed.connect(facility_completed_callback) + var advanced: Array[CommandResult] = service.advance_one_minute(301, {}) + _expect( + advanced.size() == 1 and advanced[0].is_success(), + "the existing two-argument advance call still completes normally" + ) + _expect( + order == [&"job_changed", &"facility_completed"] + and delivered_jobs.size() == 1, + "ordinary advance still emits both signals immediately in prior order" + ) + _expect( + delivered_jobs[0] == service.get_job(job_id), + "ordinary job_changed retains its historical live-job payload" + ) + _expect( + not service.has_deferred_notifications() and not service.create_snapshot().is_empty(), + "ordinary advance creates no ephemeral pending batch" + ) + service.job_changed.disconnect(job_changed_callback) + service.facility_completed.disconnect(facility_completed_callback) + + +func _test_snapshot_round_trip() -> void: + var world := FakeWorld.new() + var state := FakeResourceState.new() + var service := ConstructionServiceType.new(world, state) + var started = service.start_construction(_valid_definition(), Vector3i(4, 4, 0), 0, 8, 700) + var job_id: StringName = started.details[&"job_id"] + service.assign_builders(job_id, [&"builder_b", &"builder_a"]) + service.progress_job(job_id, 5_000, 701) + var snapshot := service.create_snapshot() + var restored := ConstructionServiceType.new(world, state) + _expect(restored.restore_snapshot(snapshot), "valid construction snapshot restores") + _expect(restored.create_snapshot() == snapshot, "snapshot restore preserves all construction state") + var before_invalid := restored.create_snapshot() + _expect(not restored.restore_snapshot({&"schema_version": 999}), "unsupported snapshot schema is rejected") + _expect(restored.create_snapshot() == before_invalid, "rejected restore does not mutate live state") + + +func _test_snapshot_strictness_and_v1_migration() -> void: + var world := FakeWorld.new() + var source := ConstructionServiceType.new(world, FakeResourceState.new()) + var job_id: StringName = source.start_construction(_valid_definition(), Vector3i(4, 4, 0), 0, 8, 700).details[&"job_id"] + source.progress_job(job_id, 5_000, 701) + var snapshot := source.create_snapshot() + var restored := ConstructionServiceType.new(world, FakeResourceState.new()) + var unknown := snapshot.duplicate(true) + unknown[&"unknown"] = 1 + _expect(not restored.restore_snapshot(unknown), "current construction schema rejects unknown top-level keys") + var type_spoof := snapshot.duplicate(true) + type_spoof[&"schema_version"] = "2" + _expect(not restored.restore_snapshot(type_spoof), "current construction schema rejects a string version") + var cost_spoof := snapshot.duplicate(true) + cost_spoof[&"jobs"][0][&"construction_cost"][&"gold"] = "100" + _expect(not restored.restore_snapshot(cost_spoof), "current construction schema rejects numeric-string costs") + var derived_tamper := snapshot.duplicate(true) + derived_tamper[&"jobs"][0][&"progress"] += 1 + _expect(not restored.restore_snapshot(derived_tamper), "current construction schema rejects derived progress tampering") + var cell_tamper := snapshot.duplicate(true) + cell_tamper[&"jobs"][0][&"reserved_cells"] = [[5, 4, 0]] + _expect(not restored.restore_snapshot(cell_tamper), "construction restore rejects world reservation cross-link tampering") + var high_water_tamper := snapshot.duplicate(true) + high_water_tamper[&"next_job_sequence"] = 1 + _expect(not restored.restore_snapshot(high_water_tamper), "construction restore rejects a sequence below the ID high-water mark") + var before := restored.create_snapshot() + _expect(before[&"jobs"].is_empty(), "rejected construction snapshots leave the target untouched") + + var legacy := snapshot.duplicate(true) + legacy[&"schema_version"] = source.LEGACY_SNAPSHOT_VERSION + legacy.erase(&"quarantined") + legacy.erase(&"quarantine_reason") + for saved_job: Dictionary in legacy[&"jobs"]: + saved_job.erase(&"last_progress_at") + _expect(restored.restore_snapshot(legacy), "legacy v1 construction snapshot migrates through its exact old shape") + _expect(restored.create_snapshot()[&"schema_version"] == source.SNAPSHOT_VERSION, "legacy construction state is re-emitted as strict v2") + _expect(restored.progress_job(job_id, ConstructionJobType.NORMAL_WORK_RATE, 701).is_replay, "v1 migration conservatively prevents replaying prior work") + + +func _test_definition_type_spoof_and_reentry() -> void: + var world := FakeWorld.new() + var resources := FakeResourceState.new() + var service := ConstructionServiceType.new(world, resources) + var spoofed := { + &"id": &"spoofed", + &"footprint": Vector2i.ONE, + &"zone": FacilityDefinitionType.MapZone.ANY, + &"layer": FacilityDefinitionType.ViewLayer.FRONT, + &"construction_cost": {&"gold": "100", &"materials": 10}, + &"build_minutes": 60, + } + _expect(not service.validate_placement(spoofed, Vector3i(1, 1, 0)).can_build, "numeric-string definition costs are rejected instead of normalized") + resources.reentrant_service = service + var started = service.start_construction(_valid_definition(), Vector3i(1, 1, 0)) + _expect(started.is_success() and resources.reentrant_attempted, "resource callback attempts a nested construction command") + _expect(not resources.reentrant_succeeded and service.get_jobs().size() == 1, "reentrant construction mutation cannot create or charge a second job") + resources.reentrant_service = null + + +func _test_failed_rollback_quarantines_service() -> void: + var world := FakeWorld.new() + var resources := FakeResourceState.new() + resources.mutate_then_fail_spend = true + resources.fail_restore = true + var service := ConstructionServiceType.new(world, resources) + var failed = service.start_construction(_valid_definition(), Vector3i(1, 1, 0)) + _expect(not failed.is_success() and failed.details[&"rollback_failed"], "failed construction payment reports an unrecoverable rollback") + _expect(service.is_quarantined(), "unrecoverable construction rollback quarantines the service") + _expect(not service.start_construction(_valid_definition(), Vector3i(2, 2, 0)).is_success(), "quarantined construction service refuses later mutations") + + +func _test_real_world_service_integration() -> void: + var world = WorldServiceType.new() + world.reset({&"min_x": 0, &"max_x": 5, &"min_y": 0, &"max_y": 5}) + world.set_support(Vector3i(1, 1, 0), 0, true) + var state := FakeResourceState.new() + var service := ConstructionServiceType.new(world, state) + var definition := _definition( + &"rotated_room", + Vector2i(2, 1), + FacilityDefinitionType.MapZone.ABOVE_GROUND, + FacilityDefinitionType.ViewLayer.FRONT, + {&"gold": 100, &"materials": 10} + ) + var expected_cells: Array[Vector3i] = [Vector3i(1, 1, 0), Vector3i(1, 2, 0)] + var report = service.validate_placement(definition, Vector3i(1, 1, 0), 90) + _expect(report.can_build, "real WorldService accepts enum-to-StringName zone matching") + _expect(report.cells == expected_cells, "90-degree placement swaps rectangular footprint axes") + var report_270 = service.validate_placement(definition, Vector3i(1, 1, 0), 270) + _expect(report_270.cells == expected_cells, "270-degree placement swaps rectangular footprint axes") + + var started = service.start_construction(definition, Vector3i(1, 1, 0), 90, 4) + _expect(started.is_success(), "real WorldService occupancy signature starts construction") + var job_id: StringName = started.details[&"job_id"] + _expect(world.get_entity_cells(job_id) == expected_cells, "real WorldService stores the complete rotated reservation") + _expect(service.cancel_job(job_id).is_success(), "real WorldService release signature cancels construction") + _expect(world.get_entity_cells(job_id).is_empty(), "real WorldService release removes the reservation") + + var restarted = service.start_construction(definition, Vector3i(1, 1, 0), 270, 1) + var restarted_job_id: StringName = restarted.details[&"job_id"] + var completed = service.progress_job(restarted_job_id, ConstructionJobType.NORMAL_WORK_RATE, 900) + _expect(completed.is_success(), "real WorldService completes a construction reservation") + var instance_id: StringName = completed.details[&"instance_id"] + _expect(world.get_entity_cells(instance_id) == expected_cells, "completion transfers real world occupancy to the facility") + _expect(service.demolish_facility(instance_id).is_success(), "real WorldService facility can be demolished") + _expect(world.get_entity_cells(instance_id).is_empty(), "real WorldService demolition releases facility occupancy") + + +func _test_underground_requires_excavation() -> void: + var world = WorldServiceType.new() + world.reset({&"min_x": 0, &"max_x": 3, &"min_y": -2, &"max_y": 0}) + world.set_support(Vector3i(0, -1, 0), 0, true) + var state := FakeResourceState.new() + var service := ConstructionServiceType.new(world, state) + var definition := _definition( + &"underground_room", + Vector2i(2, 1), + FacilityDefinitionType.MapZone.UNDERGROUND, + FacilityDefinitionType.ViewLayer.FRONT, + {&"gold": 100, &"materials": 10} + ) + var blocked = service.validate_placement(definition, Vector3i(0, -1, 0)) + _expect(not blocked.can_build, "underground facility cannot occupy solid earth") + _expect(blocked.has_error(PlacementReportType.ERROR_NOT_EXCAVATED), "placement report identifies unexcavated footprint cells") + _expect(blocked.unexcavated_cells == [Vector3i(0, -1, 0), Vector3i(1, -1, 0)], "every blocked underground footprint cell is reported") + world.set_excavated(Vector3i(0, -1, 0), true) + var half_open = service.validate_placement(definition, Vector3i(0, -1, 0)) + _expect(not half_open.can_build and half_open.unexcavated_cells == [Vector3i(1, -1, 0)], "partial excavation is still insufficient") + world.set_excavated(Vector3i(1, -1, 1), true) + var ready = service.validate_placement(definition, Vector3i(0, -1, 0)) + _expect(ready.can_build, "fully excavated spatial cells permit underground construction on either layer") + + +func _test_every_footprint_cell_must_match_zone() -> void: + var world = WorldServiceType.new() + world.reset({&"min_x": 0, &"max_x": 2, &"min_y": 0, &"max_y": 2}) + world.set_support(Vector3i(0, 0, 0), 0, true) + var service := ConstructionServiceType.new(world, FakeResourceState.new()) + var definition := _definition( + &"above_ground_tower", + Vector2i(1, 2), + FacilityDefinitionType.MapZone.ABOVE_GROUND, + FacilityDefinitionType.ViewLayer.FRONT, + {&"gold": 100, &"materials": 10} + ) + var crossing = service.validate_placement(definition, Vector3i(0, 0, 0)) + _expect(not crossing.can_build, "a footprint cannot straddle the boundary and above-ground zones") + _expect(crossing.zone_mismatch_cells == [Vector3i(0, 0, 0)], "zone validation reports the exact mismatching footprint cell") + var valid = service.validate_placement(definition, Vector3i(0, 1, 0)) + _expect(valid.can_build, "the same full footprint is valid when every cell matches its zone") + + +func _test_boundary_facility_anchor_rule() -> void: + var world := FakeWorld.new() + world.max_x = 8 + var service := ConstructionServiceType.new(world, FakeResourceState.new()) + var reception := _definition( + &"boundary_reception", + Vector2i(4, 2), + FacilityDefinitionType.MapZone.BOUNDARY, + FacilityDefinitionType.ViewLayer.FRONT, + {&"gold": 100, &"materials": 10} + ) + var anchored = service.validate_placement(reception, Vector3i(0, 0, 0)) + _expect(anchored.can_build, "boundary reception anchored at y=0 may extend into above-ground cells") + var above_anchor = service.validate_placement(reception, Vector3i(0, 1, 0)) + _expect(not above_anchor.can_build and above_anchor.has_error(PlacementReportType.ERROR_ZONE_MISMATCH), "boundary reception rejects an above-ground y=1 anchor") + var below_anchor = service.validate_placement(reception, Vector3i(0, -1, 0)) + _expect(not below_anchor.can_build and below_anchor.has_error(PlacementReportType.ERROR_ZONE_MISMATCH), "boundary reception rejects an underground y=-1 anchor") + world.forced_cells = [ + Vector3i(0, -1, 0), Vector3i(1, -1, 0), Vector3i(2, -1, 0), Vector3i(3, -1, 0), + Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(2, 0, 0), Vector3i(3, 0, 0), + ] + var underground_spill = service.validate_placement(reception, Vector3i(0, 0, 0)) + _expect(not underground_spill.can_build, "boundary footprint may not spill below y=0 even with a valid anchor") + _expect(underground_spill.zone_mismatch_cells.has(Vector3i(0, -1, 0)), "boundary spill reports the exact underground cells") + + +func _definition( + id: StringName, + footprint: Vector2i, + zone: int, + layer: int, + cost: Dictionary +) -> FacilityDefinition: + var definition := FacilityDefinitionType.new() + definition.id = id + definition.display_name = String(id) + definition.footprint = footprint + definition.zone = zone + definition.layer = layer + definition.construction_cost = cost.duplicate(true) + return definition + + +func _valid_definition() -> FacilityDefinition: + return _definition( + &"test_room", + Vector2i(1, 1), + FacilityDefinitionType.MapZone.ANY, + FacilityDefinitionType.ViewLayer.FRONT, + {&"gold": 100, &"materials": 10} + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_construction_service.gd.uid b/tests/s1/test_construction_service.gd.uid new file mode 100644 index 0000000..f78b01a --- /dev/null +++ b/tests/s1/test_construction_service.gd.uid @@ -0,0 +1 @@ +uid://d1wplc6ml6vei diff --git a/tests/s1/test_excavation_service.gd b/tests/s1/test_excavation_service.gd new file mode 100644 index 0000000..facb80b --- /dev/null +++ b/tests/s1/test_excavation_service.gd @@ -0,0 +1,285 @@ +extends SceneTree + +const ExcavationServiceType = preload("res://scripts/construction/excavation_service.gd") +const WorldServiceType = preload("res://scripts/world/world_service.gd") + +var _checks := 0 +var _failures := 0 + + +class FakeResourceState: + extends RefCounted + + var resources := {&"gold": 1_000, &"materials": 100} + var mutate_then_fail_spend := false + var fail_restore := false + var reentrant_service: Variant + var reentrant_attempted := false + var reentrant_succeeded := false + + func get_resource(resource_name: StringName) -> int: + return int(resources.get(resource_name, 0)) + + func spend(cost: Dictionary) -> bool: + if reentrant_service != null and not reentrant_attempted: + reentrant_attempted = true + var nested: Variant = reentrant_service.start_excavation(Vector3i(1, -1, 0), 1) + reentrant_succeeded = nested.is_success() + if mutate_then_fail_spend: + resources[&"gold"] = int(resources[&"gold"]) - int(cost.get(&"gold", 0)) + return false + for resource_name: Variant in cost: + if get_resource(StringName(resource_name)) < int(cost[resource_name]): + return false + for resource_name: Variant in cost: + var key := StringName(resource_name) + resources[key] = int(resources[key]) - int(cost[resource_name]) + return true + + func apply_resource_deltas(deltas: Dictionary) -> bool: + var candidate := resources.duplicate(true) + for resource_name: Variant in deltas: + var key := StringName(resource_name) + candidate[key] = int(candidate.get(key, 0)) + int(deltas[resource_name]) + if int(candidate[key]) < 0: + return false + resources = candidate + return true + + func create_snapshot() -> Dictionary: + return {&"resources": resources.duplicate(true)} + + func restore_snapshot(snapshot: Dictionary) -> bool: + if fail_restore: + return false + if not snapshot.get(&"resources", null) is Dictionary: + return false + resources = (snapshot[&"resources"] as Dictionary).duplicate(true) + return true + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: Excavation Service") + _test_rules_adjacency_and_two_layer_unlock() + _test_disconnected_island_is_rejected() + _test_pause_resume_preserves_progress() + _test_hard_width_requires_support() + _test_hard_cluster_is_not_a_four_cell_span() + _test_progress_replay_is_idempotent() + _test_snapshot_round_trip_and_atomic_rejection() + _test_snapshot_schema_strictness_and_v1_migration() + _test_reentry_and_failed_rollback_quarantine() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_rules_adjacency_and_two_layer_unlock() -> void: + var world := _world(0, 3, -3, 0) + var resources := FakeResourceState.new() + var service := ExcavationServiceType.new(world, resources) + _expect(service.get_rule(0) == {&"gold": 20, &"materials": 1, &"minutes": 30}, "hardness 0 uses the canonical cost and time") + _expect(service.get_rule(1) == {&"gold": 50, &"materials": 1, &"minutes": 60}, "hardness 1 uses the canonical cost and time") + _expect(service.get_rule(2) == {&"gold": 100, &"materials": 2, &"minutes": 120}, "hardness 2 uses the canonical cost and time") + _expect(service.get_rule(3) == {&"gold": 200, &"materials": 4, &"minutes": 240}, "hardness 3 uses the canonical cost and time") + _expect(service.set_hardness(Vector2i(0, -1), 1), "underground hardness can be assigned before work") + var report := service.validate_excavation(Vector3i(0, -1, 1)) + _expect(report[&"ok"] and report[&"entrance_connected"], "first underground cell connects to the boundary entrance") + _expect(report[&"cost"] == {&"gold": 50, &"materials": 1} and report[&"build_minutes"] == 60, "validation exposes exact hardness tuning") + var started := service.start_excavation(Vector3i(0, -1, 0), 360) + _expect(started.is_success(), "valid connected excavation starts") + _expect(resources.resources == {&"gold": 950, &"materials": 99}, "excavation payment is charged exactly once") + var job_id: StringName = started.details[&"job_id"] + for minute: int in range(360, 420): + service.progress_job(job_id, service.NORMAL_WORK_RATE, minute) + _expect(world.is_excavated(Vector3i(0, -1, 0)), "completed excavation unlocks FRONT") + _expect(world.is_excavated(Vector3i(0, -1, 1)), "the same completed excavation unlocks BACK") + _expect(service.get_job(job_id)[&"state"] == service.STATE_COMPLETED, "completed job becomes terminal") + _expect(not service.validate_excavation(Vector3i(0, -1, 0))[&"ok"], "an excavated cell cannot be charged twice") + _expect(service.validate_invariants()[&"ok"], "completed excavation retains valid invariants") + + +func _test_disconnected_island_is_rejected() -> void: + var world := _world(0, 4, -4, 0) + var resources := FakeResourceState.new() + var service := ExcavationServiceType.new(world, resources) + world.set_excavated(Vector3i(4, -4, 0), true) + var report := service.validate_excavation(Vector3i(3, -4, 0)) + _expect(not report[&"ok"], "an island next to an excavated cell is still rejected") + _expect(report[&"errors"].has(service.ERROR_NO_ENTRANCE_CONNECTION), "island rejection identifies the missing entrance connection") + _expect(resources.resources == {&"gold": 1_000, &"materials": 100}, "failed validation changes no resources") + var no_neighbor := service.validate_excavation(Vector3i(1, -3, 0)) + _expect(no_neighbor[&"errors"].has(service.ERROR_NO_EXCAVATED_NEIGHBOR), "a cell without any excavated neighbor has a distinct reason") + + +func _test_pause_resume_preserves_progress() -> void: + var world := _world(0, 2, -2, 0) + var resources := FakeResourceState.new() + var service := ExcavationServiceType.new(world, resources) + var started := service.start_excavation(Vector3i(0, -1, 0), 100) + var job_id: StringName = started.details[&"job_id"] + for minute: int in range(100, 110): + service.progress_job(job_id, service.NORMAL_WORK_RATE, minute) + var progress_before := int(service.get_job(job_id)[&"progress"]) + _expect(progress_before > 0 and progress_before < service.MAX_PROGRESS, "work accumulates as deterministic fixed-point progress") + _expect(service.pause_job(job_id).is_success(), "active excavation can be paused") + service.advance_one_minute(111) + _expect(service.get_job(job_id)[&"progress"] == progress_before, "paused time does not discard or advance progress") + _expect(service.resume_job(job_id).is_success(), "connected paused excavation can resume") + for minute: int in range(112, 132): + service.progress_job(job_id, service.NORMAL_WORK_RATE, minute) + _expect(service.get_job(job_id)[&"state"] == service.STATE_COMPLETED, "resumed work completes from retained progress") + _expect(service.pause_job(job_id).details[&"code"] == &"TERMINAL_STATE", "completed excavation cannot return to a mutable state") + + +func _test_hard_width_requires_support() -> void: + var world := _world(0, 4, -1, 0) + var resources := FakeResourceState.new() + var service := ExcavationServiceType.new(world, resources) + for x: int in range(4): + _expect(service.set_hardness(Vector2i(x, -1), 2), "hard cell %d is configured" % x) + for x: int in range(3): + world.set_excavated(Vector3i(x, -1, 0), true) + var unsupported := service.validate_excavation(Vector3i(3, -1, 0)) + _expect(unsupported[&"support_required"], "four connected hardness-2 cells trigger support") + _expect(unsupported[&"errors"].has(service.ERROR_SUPPORT_REQUIRED), "unsupported hard span cannot start") + world.set_support(Vector3i(2, -1, 1), 1) + var supported := service.validate_excavation(Vector3i(3, -1, 0)) + _expect(supported[&"ok"] and supported[&"support_required"], "nearby support makes the same hard span legal") + _expect(service.start_excavation(Vector3i(3, -1, 1)).is_success(), "supported hard excavation can reserve its spatial cell") + _expect(not service.start_excavation(Vector3i(3, -1, 0)).is_success(), "FRONT and BACK cannot reserve the same spatial excavation twice") + + +func _test_hard_cluster_is_not_a_four_cell_span() -> void: + var world := _world(0, 1, -2, 0) + var service := ExcavationServiceType.new(world, FakeResourceState.new()) + for position: Vector2i in [Vector2i(0, -1), Vector2i(1, -1), Vector2i(0, -2), Vector2i(1, -2)]: + service.set_hardness(position, 2) + world.set_excavated(Vector3i(0, -1, 0), true) + world.set_excavated(Vector3i(1, -1, 0), true) + world.set_excavated(Vector3i(0, -2, 0), true) + var report := service.validate_excavation(Vector3i(1, -2, 0)) + _expect(report[&"ok"], "a connected 2x2 hard cluster remains excavatable without support") + _expect(not report[&"support_required"], "support is based on four collinear cells, not component cardinality") + + +func _test_progress_replay_is_idempotent() -> void: + var world := _world(0, 2, -2, 0) + var service := ExcavationServiceType.new(world, FakeResourceState.new()) + var started := service.start_excavation(Vector3i(0, -1, 0), 5) + var job_id: StringName = started.details[&"job_id"] + var first := service.progress_job(job_id, service.NORMAL_WORK_RATE, 10) + var progress_once := int(service.get_job(job_id)[&"progress"]) + var duplicate := service.progress_job(job_id, service.NORMAL_WORK_RATE, 10) + _expect(first.is_success() and duplicate.is_success() and duplicate.is_replay, "same-minute excavation retry is an explicit replay") + _expect(service.get_job(job_id)[&"progress"] == progress_once, "same-minute retry cannot add work twice") + var snapshot := service.create_snapshot() + var restored := ExcavationServiceType.new(world, FakeResourceState.new()) + _expect(restored.restore_snapshot(snapshot), "last processed excavation minute survives snapshot restore") + var restored_retry := restored.progress_job(job_id, service.NORMAL_WORK_RATE, 10) + _expect(restored_retry.is_replay and restored.get_job(job_id)[&"progress"] == progress_once, "restored service also rejects the already-applied minute") + + +func _test_snapshot_round_trip_and_atomic_rejection() -> void: + var world := _world(0, 3, -2, 0) + var resources := FakeResourceState.new() + var source := ExcavationServiceType.new(world, resources) + source.set_hardness(Vector2i(0, -1), 1) + var completed_id: StringName = source.start_excavation(Vector3i(0, -1, 0), 10).details[&"job_id"] + for minute: int in range(10, 70): + source.progress_job(completed_id, source.NORMAL_WORK_RATE, minute) + source.set_hardness(Vector2i(0, -2), 3) + var active_id: StringName = source.start_excavation(Vector3i(0, -2, 0), 70).details[&"job_id"] + for minute: int in range(70, 75): + source.progress_job(active_id, source.NORMAL_WORK_RATE, minute) + var snapshot := source.create_snapshot() + var restored := ExcavationServiceType.new(world, resources) + _expect(restored.restore_snapshot(snapshot), "valid excavation snapshot restores against the same world") + _expect(restored.create_snapshot() == snapshot, "excavation snapshot round trip is exact") + _expect(restored.validate_invariants()[&"ok"], "restored active and completed jobs satisfy cross-links") + + var before := restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"jobs"][1][&"progress"] = 9_999 + _expect(not restored.restore_snapshot(corrupt), "derived progress tampering is rejected") + _expect(restored.create_snapshot() == before, "failed excavation restore preserves the live state atomically") + var duplicate := snapshot.duplicate(true) + duplicate[&"jobs"].append(duplicate[&"jobs"][1].duplicate(true)) + _expect(not restored.restore_snapshot(duplicate), "duplicate job identity is rejected") + _expect(restored.create_snapshot() == before, "all rejected snapshot shapes remain non-mutating") + + +func _test_snapshot_schema_strictness_and_v1_migration() -> void: + var world := _world(0, 2, -2, 0) + var source := ExcavationServiceType.new(world, FakeResourceState.new()) + var job_id: StringName = source.start_excavation(Vector3i(0, -1, 0), 10).details[&"job_id"] + source.progress_job(job_id, source.NORMAL_WORK_RATE, 11) + var snapshot := source.create_snapshot() + var restored := ExcavationServiceType.new(world, FakeResourceState.new()) + var unknown := snapshot.duplicate(true) + unknown[&"unknown"] = 1 + _expect(not restored.restore_snapshot(unknown), "current excavation schema rejects unknown top-level keys") + var type_spoof := snapshot.duplicate(true) + type_spoof[&"schema_version"] = "2" + _expect(not restored.restore_snapshot(type_spoof), "current excavation schema rejects a string version") + var cost_spoof := snapshot.duplicate(true) + cost_spoof[&"jobs"][0][&"cost"][&"gold"] = "20" + _expect(not restored.restore_snapshot(cost_spoof), "current excavation schema rejects numeric-string costs") + var derived_tamper := snapshot.duplicate(true) + derived_tamper[&"jobs"][0][&"build_minutes"] = 31 + _expect(not restored.restore_snapshot(derived_tamper), "current excavation schema rejects canonical-rule tampering") + var legacy := snapshot.duplicate(true) + legacy[&"schema_version"] = source.LEGACY_SNAPSHOT_VERSION + legacy.erase(&"quarantined") + legacy.erase(&"quarantine_reason") + for saved_job: Dictionary in legacy[&"jobs"]: + saved_job.erase(&"last_progress_at") + _expect(restored.restore_snapshot(legacy), "legacy v1 excavation snapshot migrates through its exact old shape") + _expect(restored.create_snapshot()[&"schema_version"] == source.SNAPSHOT_VERSION, "legacy excavation state is re-emitted as strict v2") + _expect(restored.progress_job(job_id, source.NORMAL_WORK_RATE, 11).is_replay, "v1 migration conservatively prevents replaying prior progress") + + +func _test_reentry_and_failed_rollback_quarantine() -> void: + var world := _world(0, 2, -2, 0) + var reentrant_resources := FakeResourceState.new() + var service := ExcavationServiceType.new(world, reentrant_resources) + reentrant_resources.reentrant_service = service + var started := service.start_excavation(Vector3i(0, -1, 0), 1) + _expect(started.is_success() and reentrant_resources.reentrant_attempted, "resource callback attempts a nested excavation command") + _expect(not reentrant_resources.reentrant_succeeded and service.get_jobs().size() == 1, "reentrant excavation mutation is rejected before a second charge or reservation") + reentrant_resources.reentrant_service = null + + var broken_resources := FakeResourceState.new() + broken_resources.mutate_then_fail_spend = true + broken_resources.fail_restore = true + var broken := ExcavationServiceType.new(_world(0, 1, -1, 0), broken_resources) + var failed := broken.start_excavation(Vector3i(0, -1, 0), 2) + _expect(not failed.is_success() and failed.details[&"rollback_failed"], "failed excavation payment reports an unrecoverable rollback") + _expect(broken.is_quarantined(), "unrecoverable excavation rollback quarantines the service") + _expect(not broken.start_excavation(Vector3i(1, -1, 0), 3).is_success(), "quarantined excavation service refuses later mutations") + + +func _world(min_x: int, max_x: int, min_y: int, max_y: int) -> WorldService: + var world := WorldServiceType.new() + world.reset({ + &"min_x": min_x, + &"max_x": max_x, + &"min_y": min_y, + &"max_y": max_y, + }) + return world + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_excavation_service.gd.uid b/tests/s1/test_excavation_service.gd.uid new file mode 100644 index 0000000..6cbbf97 --- /dev/null +++ b/tests/s1/test_excavation_service.gd.uid @@ -0,0 +1 @@ +uid://b5tbd0rocd1ir diff --git a/tests/s1/test_facility_infrastructure_projector.gd b/tests/s1/test_facility_infrastructure_projector.gd new file mode 100644 index 0000000..6ed72d8 --- /dev/null +++ b/tests/s1/test_facility_infrastructure_projector.gd @@ -0,0 +1,571 @@ +extends SceneTree + +const ProjectorType = preload("res://scripts/world/facility_infrastructure_projector.gd") +const AdapterType = preload("res://scripts/world/route_subslot_path_adapter.gd") +const SpatialType = preload("res://scripts/world/spatial_subslot_service.gd") +const PathType = preload("res://scripts/world/path_service.gd") +const WorldType = preload("res://scripts/world/world_service.gd") +const FacilityDefinitionType = preload("res://scripts/data/facility_definition.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +const GUEST_CORRIDOR = preload("res://data/facilities/guest_corridor.tres") +const SERVICE_CORRIDOR = preload("res://data/facilities/service_corridor.tres") +const STAFF_DOOR = preload("res://data/facilities/staff_door.tres") +const GUEST_ELEVATOR = preload("res://data/facilities/guest_elevator.tres") +const SERVICE_ELEVATOR = preload("res://data/facilities/service_elevator.tres") + + +class ControlledPathService: + extends PathService + + var fail_next_rebuild_after_mutation := false + var fail_next_connector_after_mutation := false + var fail_next_remove_after_mutation := false + var reject_restore := false + var reenter_next_connector := false + var tamper_projector_next_connector := false + var projector: Variant = null + var reentry_result: Variant = null + + func rebuild_graph(passable_cells: Variant) -> bool: + var should_fail := fail_next_rebuild_after_mutation + fail_next_rebuild_after_mutation = false + var result := super.rebuild_graph(passable_cells) + if should_fail: + last_error = "Injected graph rebuild failure after mutation." + return false + return result + + func configure_connector( + connector_id: StringName, + from_cell: Vector3i, + to_cell: Vector3i, + cost := DEFAULT_CONNECTOR_COST, + bidirectional := true, + access_id: StringName = &"", + enabled := true, + options: Dictionary = {} + ) -> bool: + var should_fail := fail_next_connector_after_mutation + fail_next_connector_after_mutation = false + var should_reenter := reenter_next_connector + reenter_next_connector = false + var should_tamper := tamper_projector_next_connector + tamper_projector_next_connector = false + var result := super.configure_connector( + connector_id, from_cell, to_cell, cost, bidirectional, access_id, enabled, options + ) + if should_reenter and projector != null: + reentry_result = projector.unproject_facility(&"nested_unproject", &"facility_nested") + if should_tamper and projector != null: + projector.projection_revision += 100 + if should_fail: + last_error = "Injected connector failure after mutation." + return false + return result + + func remove_connector(connector_id: StringName) -> bool: + var should_fail := fail_next_remove_after_mutation + fail_next_remove_after_mutation = false + var result := super.remove_connector(connector_id) + if should_fail: + last_error = "Injected connector removal failure after mutation." + return false + return result + + func restore_snapshot(snapshot: Dictionary) -> bool: + if reject_restore: + last_error = "Injected Path snapshot restore rejection." + return false + return super.restore_snapshot(snapshot) + + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: completed-facility infrastructure projection") + _test_corridors_and_primary_world_coexistence() + _test_staff_door_access_and_stable_ids() + _test_project_unproject_replay_and_defensive_queries() + _test_vertical_topology_rejects_unchanged() + _test_definition_and_instance_crosslink_validation() + _test_project_and_unproject_failures_roll_back_exactly() + _test_callback_reentry_tamper_and_quarantine_recovery() + _test_strict_snapshot_round_trip_and_atomic_rejection() + _test_configuration_and_stale_dependency_boundaries() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_corridors_and_primary_world_coexistence() -> void: + var front_start := Vector3i(0, 0, PathType.FRONT) + var front_mid := Vector3i(1, 0, PathType.FRONT) + var front_goal := Vector3i(2, 0, PathType.FRONT) + var back_start := Vector3i(0, 2, PathType.BACK) + var back_mid := Vector3i(1, 2, PathType.BACK) + var back_goal := Vector3i(2, 2, PathType.BACK) + var context := _context([front_start, front_goal, back_start, back_goal]) + var world := WorldType.new() + world.reset({&"min_x": -2, &"max_x": 4, &"min_y": -2, &"max_y": 4}) + _expect(world.occupy(&"facility_00000001", [front_mid], &"facility"), "completed facility occupies the primary World slot") + var guest_instance := _instance(GUEST_CORRIDOR, &"facility_00000001", front_mid, 10, &"construction_00000001") + var guest_result = context.projector.project_facility(&"project_guest", GUEST_CORRIDOR, guest_instance) + _expect(guest_result.is_success(), "guest corridor projects through the atomic boundary") + _expect(context.path.find_path(front_start, front_goal) == [front_start, front_mid, front_goal], "guest corridor makes the real FRONT Path reachable") + _expect(world.get_occupant(front_mid) == &"facility_00000001", "ROUTE subslot projection preserves primary World occupancy") + var guest_projection: Dictionary = context.projector.get_projection(&"facility_00000001") + _expect(guest_projection[&"layer"] == PathType.FRONT and not guest_projection[&"staff_only"], "guest corridor metadata preserves FRONT guest meaning") + + var service_instance := _instance(SERVICE_CORRIDOR, &"facility_00000002", back_mid, 11, &"construction_00000002") + var service_result = context.projector.project_facility(&"project_service", SERVICE_CORRIDOR, service_instance) + _expect(service_result.is_success(), "service corridor projects through the same boundary") + _expect(context.path.find_path(back_start, back_goal) == [back_start, back_mid, back_goal], "service corridor makes the real BACK Path reachable") + _expect(context.path.find_path(back_start, back_goal, [PathType.FRONT]).is_empty(), "FRONT-only guest profile cannot use the staff-only BACK corridor") + var service_projection: Dictionary = context.projector.get_projection(&"facility_00000002") + _expect(service_projection[&"layer"] == PathType.BACK and service_projection[&"staff_only"], "service corridor retains explicit staff-only semantics") + _expect(context.path.find_path(front_start, back_goal, [PathType.FRONT, PathType.BACK]).is_empty(), "FRONT and BACK remain separated without a staff door") + _expect(context.projector.projection_revision == 2, "corridor projections advance one deterministic revision each") + _expect(context.projector.validate_invariants()[&"ok"], "corridor route/entity/cell crosslinks are valid") + + +func _test_staff_door_access_and_stable_ids() -> void: + var front := Vector3i(5, 0, PathType.FRONT) + var back := Vector3i(5, 0, PathType.BACK) + var context := _context([front]) + var instance := _instance(STAFF_DOOR, &"facility_00000003", back, 20, &"construction_00000003") + var result = context.projector.project_facility(&"project_staff_door", STAFF_DOOR, instance) + _expect(result.is_success(), "staff door projects its BACK endpoint and connector atomically") + var projection: Dictionary = context.projector.get_projection(&"facility_00000003") + _expect(projection[&"route_entity_id"] == &"infra.route:facility_00000003", "staff door route entity ID is stable") + _expect(projection[&"connector_id"] == &"infra.connector:facility_00000003", "staff door connector ID is stable") + _expect(context.source.get_entity_cells(projection[&"route_entity_id"]) == [back], "staff door owns exactly its BACK ROUTE endpoint") + var connector: Dictionary = context.path.get_connector(projection[&"connector_id"]) + _expect(connector[&"from"] == front and connector[&"to"] == back, "connector joins the same coordinate across FRONT/BACK") + _expect(connector[&"cost"] == 120 and connector[&"bidirectional"] and connector[&"access_id"] == &"staff", "connector is bidirectional, cost 120, and staff-gated") + _expect(context.path.find_path(front, back, [PathType.FRONT, PathType.BACK], []).is_empty(), "guest access cannot traverse the staff door") + _expect(context.path.last_no_route_reason != context.path.ROUTE_OK, "guest denial reports a real no-route reason rather than fake reachability") + _expect(context.path.find_path(front, back, [PathType.FRONT, PathType.BACK], [&"staff"]) == [front, back], "staff connector access traverses the real door") + _expect(context.path.last_path_cost == 120, "staff door route report uses the exact specification cost") + _expect(context.path.calculate_path_cost([front, back], [&"staff"]) == 120, "explicit Path cost calculation preserves staff-door cost 120") + _expect(context.projector.validate_invariants()[&"ok"], "staff-door connector and route crosslinks are exact") + + +func _test_project_unproject_replay_and_defensive_queries() -> void: + var cell := Vector3i(1, 4, PathType.FRONT) + var context := _context([]) + var instance := _instance(GUEST_CORRIDOR, &"facility_00000004", cell, 30, &"construction_00000004") + var first = context.projector.project_facility(&"idem_project", GUEST_CORRIDOR, instance) + var after_projector: Dictionary = context.projector.create_snapshot() + var after_source: Dictionary = context.source.create_snapshot() + var after_adapter: Dictionary = context.adapter.create_snapshot() + var after_path: Dictionary = context.path.create_snapshot() + var replay = context.projector.project_facility(&"idem_project", GUEST_CORRIDOR, instance) + _expect(first.is_success() and replay.is_success() and replay.is_replay, "same project request and fingerprint replays") + _expect(replay.details == first.details, "project replay preserves exact result details") + _expect(context.projector.create_snapshot() == after_projector, "project replay does not advance projector state") + _expect(context.source.create_snapshot() == after_source and context.adapter.create_snapshot() == after_adapter and context.path.create_snapshot() == after_path, "project replay leaves all three dependencies exact") + var changed_instance := instance.duplicate(true) + changed_instance[&"completed_at"] = 31 + var conflict = context.projector.project_facility(&"idem_project", GUEST_CORRIDOR, changed_instance) + _expect(conflict.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, "same request ID with changed completed input conflicts") + var duplicate = context.projector.project_facility(&"another_project", GUEST_CORRIDOR, instance) + _expect(duplicate.code == CommandResultType.Code.DUPLICATE_COMMAND_ID, "another request cannot double-project one facility") + + var returned: Dictionary = context.projector.get_projection(&"facility_00000004") + returned[&"cells"].clear() + var ids: Array[StringName] = context.projector.get_projection_ids() + ids.clear() + var status: Dictionary = context.projector.get_status() + status[&"projection_count"] = 99 + _expect(context.projector.get_projection(&"facility_00000004")[&"cells"] == [cell], "projection query is a defensive deep copy") + _expect(context.projector.get_projection_ids() == [&"facility_00000004"], "projection ID query is defensive and lexical") + _expect(context.projector.get_status()[&"projection_count"] == 1, "status query is defensive") + + var removed = context.projector.unproject_facility(&"idem_remove", &"facility_00000004") + _expect(removed.is_success(), "facility infrastructure unprojects atomically") + _expect(not context.source.has_entity(&"infra.route:facility_00000004") and not context.path.has_cell(cell), "unproject removes the route from both source and real Path") + var removed_snapshot: Dictionary = context.projector.create_snapshot() + var remove_replay = context.projector.unproject_facility(&"idem_remove", &"facility_00000004") + _expect(remove_replay.is_success() and remove_replay.is_replay, "same unproject request replays after the entity is gone") + _expect(context.projector.create_snapshot() == removed_snapshot, "unproject replay is mutation-free") + var remove_conflict = context.projector.unproject_facility(&"idem_remove", &"facility_other") + _expect(remove_conflict.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, "unproject request conflict is rejected") + _expect(context.projector.projection_revision == 2, "project and unproject each advance exactly once") + + +func _test_vertical_topology_rejects_unchanged() -> void: + var context := _context([Vector3i(0, 0, PathType.FRONT)]) + var before_projector: Dictionary = context.projector.create_snapshot() + var before_source: Dictionary = context.source.create_snapshot() + var before_adapter: Dictionary = context.adapter.create_snapshot() + var before_path: Dictionary = context.path.create_snapshot() + var guest_instance := _instance(GUEST_ELEVATOR, &"facility_00000005", Vector3i(4, 4, PathType.FRONT), 40, &"construction_00000005") + var guest = context.projector.project_facility(&"guest_elevator", GUEST_ELEVATOR, guest_instance) + _expect(guest.code == CommandResultType.Code.VALIDATION_FAILED and guest.details[&"reason"] == context.projector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, "guest elevator explicitly reports unsupported vertical topology") + var service_instance := _instance(SERVICE_ELEVATOR, &"facility_00000006", Vector3i(8, 4, PathType.BACK), 41, &"construction_00000006") + var service = context.projector.project_facility(&"service_elevator", SERVICE_ELEVATOR, service_instance) + _expect(service.code == CommandResultType.Code.VALIDATION_FAILED and service.details[&"reason"] == context.projector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, "service elevator explicitly reports unsupported vertical topology") + _expect(context.projector.create_snapshot() == before_projector, "unsupported vertical facilities leave projector state unchanged") + _expect(context.source.create_snapshot() == before_source and context.adapter.create_snapshot() == before_adapter and context.path.create_snapshot() == before_path, "unsupported vertical facilities leave all authoritative dependencies unchanged") + _expect(context.projector.get_processed_request_ids().is_empty(), "unsupported vertical rejection does not consume request IDs") + + +func _test_definition_and_instance_crosslink_validation() -> void: + var context := _context([]) + var base_instance := _instance(GUEST_CORRIDOR, &"facility_00000007", Vector3i(0, 6, PathType.FRONT), 50, &"construction_00000007") + var before := _all_snapshots(context) + var wrong_layer: FacilityDefinition = GUEST_CORRIDOR.duplicate(true) + wrong_layer.layer = FacilityDefinitionType.ViewLayer.BACK + _expect(not context.projector.project_facility(&"bad_definition_layer", wrong_layer, base_instance).is_success(), "definition layer mismatch is rejected") + var wrong_tags: FacilityDefinition = GUEST_CORRIDOR.duplicate(true) + wrong_tags.tags = [&"corridor", &"movement", &"route_subslot", &"schema_facility_proxy"] + _expect(not context.projector.project_facility(&"bad_definition_tags", wrong_tags, base_instance).is_success(), "definition tag mismatch is rejected") + var wrong_pass: FacilityDefinition = GUEST_CORRIDOR.duplicate(true) + wrong_pass.pass_through = false + _expect(not context.projector.project_facility(&"bad_definition_pass", wrong_pass, base_instance).is_success(), "definition pass-through mismatch is rejected") + var unknown: FacilityDefinition = GUEST_CORRIDOR.duplicate(true) + unknown.id = &"unknown_proxy" + _expect(not context.projector.project_facility(&"unknown_definition", unknown, base_instance).is_success(), "unknown proxy definition is rejected") + + var extra := base_instance.duplicate(true) + extra[&"extra"] = true + _expect(not context.projector.project_facility(&"instance_extra", GUEST_CORRIDOR, extra).is_success(), "completed instance with an extra field is rejected") + var wrong_definition := base_instance.duplicate(true) + wrong_definition[&"definition_id"] = &"service_corridor" + _expect(not context.projector.project_facility(&"instance_definition", GUEST_CORRIDOR, wrong_definition).is_success(), "instance-to-definition ID mismatch is rejected") + var wrong_state := base_instance.duplicate(true) + wrong_state[&"state"] = &"PLANNED" + _expect(not context.projector.project_facility(&"instance_state", GUEST_CORRIDOR, wrong_state).is_success(), "non-ACTIVE completed instance is rejected") + var wrong_layer_instance := base_instance.duplicate(true) + wrong_layer_instance[&"layer"] = PathType.BACK + _expect(not context.projector.project_facility(&"instance_layer", GUEST_CORRIDOR, wrong_layer_instance).is_success(), "instance layer crosslink mismatch is rejected") + var wrong_cells := base_instance.duplicate(true) + wrong_cells[&"occupied_cells"] = [Vector3i(1, 6, PathType.FRONT)] + _expect(not context.projector.project_facility(&"instance_cells", GUEST_CORRIDOR, wrong_cells).is_success(), "instance cells outside its definition footprint are rejected") + var duplicate_cells := base_instance.duplicate(true) + duplicate_cells[&"occupied_cells"] = [base_instance[&"origin"], base_instance[&"origin"]] + _expect(not context.projector.project_facility(&"instance_duplicate_cells", GUEST_CORRIDOR, duplicate_cells).is_success(), "duplicate instance cells are rejected") + var float_cost := base_instance.duplicate(true) + float_cost[&"construction_cost"] = {&"gold": 20.0} + _expect(not context.projector.project_facility(&"instance_cost_type", GUEST_CORRIDOR, float_cost).is_success(), "non-integer construction cost is rejected") + var negative_cost := base_instance.duplicate(true) + negative_cost[&"construction_cost"] = {&"gold": -1} + _expect(not context.projector.project_facility(&"instance_cost_negative", GUEST_CORRIDOR, negative_cost).is_success(), "negative construction cost is rejected") + var bad_request = context.projector.project_facility(4, GUEST_CORRIDOR, base_instance) + _expect(bad_request.code == CommandResultType.Code.INVALID_PAYLOAD, "numeric request ID is rejected rather than coerced") + _expect(_all_snapshots(context) == before, "all definition/instance validation failures preserve every service exactly") + + +func _test_project_and_unproject_failures_roll_back_exactly() -> void: + var path := ControlledPathService.new() + var context := _context([Vector3i(0, 8, PathType.FRONT)], path) + path.projector = context.projector + var guest := _instance(GUEST_CORRIDOR, &"facility_00000008", Vector3i(1, 8, PathType.FRONT), 60, &"construction_00000008") + var before := _all_snapshots(context) + path.fail_next_rebuild_after_mutation = true + var route_failure = context.projector.project_facility(&"retry_route_failure", GUEST_CORRIDOR, guest) + _assert_exact_rollback(context, before, route_failure, "route adapter Path rebuild failure") + _expect(context.projector.project_facility(&"retry_route_failure", GUEST_CORRIDOR, guest).is_success(), "failed project request can retry after exact rollback") + + var door := _instance(STAFF_DOOR, &"facility_00000009", Vector3i(3, 8, PathType.BACK), 61, &"construction_00000009") + before = _all_snapshots(context) + path.fail_next_connector_after_mutation = true + var connector_failure = context.projector.project_facility(&"retry_connector_failure", STAFF_DOOR, door) + _assert_exact_rollback(context, before, connector_failure, "staff connector apply failure") + _expect(not context.source.has_entity(&"infra.route:facility_00000009") and not context.path.has_connector(&"infra.connector:facility_00000009"), "failed staff-door projection leaves neither route nor connector") + _expect(context.projector.project_facility(&"retry_connector_failure", STAFF_DOOR, door).is_success(), "failed staff-door request retries after exact rollback") + + before = _all_snapshots(context) + path.fail_next_remove_after_mutation = true + var remove_failure = context.projector.unproject_facility(&"retry_remove_failure", &"facility_00000009") + _assert_exact_rollback(context, before, remove_failure, "staff connector removal failure") + _expect(context.source.has_entity(&"infra.route:facility_00000009") and context.path.has_connector(&"infra.connector:facility_00000009"), "failed unproject restores route and connector") + _expect(context.projector.unproject_facility(&"retry_remove_failure", &"facility_00000009").is_success(), "failed unproject request retries after exact rollback") + path.projector = null + + +func _test_callback_reentry_tamper_and_quarantine_recovery() -> void: + var path := ControlledPathService.new() + var front := Vector3i(5, 10, PathType.FRONT) + var context := _context([front], path) + path.projector = context.projector + var first_door := _instance(STAFF_DOOR, &"facility_00000010", Vector3i(5, 10, PathType.BACK), 70, &"construction_00000010") + path.reenter_next_connector = true + var outer = context.projector.project_facility(&"outer_reentry", STAFF_DOOR, first_door) + _expect(outer.is_success(), "outer staff-door transaction commits when callback only attempts reentry") + _expect(path.reentry_result != null and path.reentry_result.code == CommandResultType.Code.TRANSACTION_CLOSED, "Path callback reentry is rejected") + _expect(context.projector.projection_revision == 1, "reentry cannot advance a nested projector revision") + + var second_door := _instance(STAFF_DOOR, &"facility_00000011", Vector3i(7, 10, PathType.BACK), 71, &"construction_00000011") + var before := _all_snapshots(context) + path.tamper_projector_next_connector = true + var tampered = context.projector.project_facility(&"callback_tamper", STAFF_DOOR, second_door) + _assert_exact_rollback(context, before, tampered, "Path callback projector tamper") + _expect(context.projector.projection_revision == 1 and not context.projector.has_projection(&"facility_00000011"), "callback tamper cannot leak owned metadata") + + var recovery := _all_snapshots(context) + path.fail_next_connector_after_mutation = true + path.reject_restore = true + var uncertain_door := _instance(STAFF_DOOR, &"facility_00000012", Vector3i(9, 10, PathType.BACK), 72, &"construction_00000012") + var uncertain = context.projector.project_facility(&"uncertain_project", STAFF_DOOR, uncertain_door) + _expect(not uncertain.is_success() and not uncertain.details[&"rollback_ok"], "uncertain dependency rollback reports its uncertainty") + _expect(context.projector.is_quarantined() and context.projector.get_quarantine_reason() == context.projector.QUARANTINE_TRANSACTION_ROLLBACK, "uncertain rollback quarantines the projector") + _expect(context.projector.project_facility(&"blocked_quarantine", GUEST_CORRIDOR, _instance(GUEST_CORRIDOR, &"facility_00000013", Vector3i(11, 10, 0), 73, &"construction_00000013")).code == CommandResultType.Code.TRANSACTION_CLOSED, "quarantine blocks further projection") + + path.reject_restore = false + _expect(context.source.restore_snapshot(recovery.source), "quarantine recovery restores the Spatial snapshot") + _restore_path_for_test(context.path, recovery.path_cells, recovery.path) + _expect(context.adapter.restore_snapshot(recovery.adapter), "quarantine recovery restores the Route adapter snapshot") + _expect(context.projector.restore_snapshot(recovery.projector), "matching projector snapshot clears quarantine") + _expect(not context.projector.is_quarantined() and context.projector.validate_invariants()[&"ok"], "recovered projector returns to a valid available state") + path.projector = null + + +func _test_strict_snapshot_round_trip_and_atomic_rejection() -> void: + var base := [ + Vector3i(0, 12, PathType.FRONT), + Vector3i(4, 12, PathType.FRONT), + ] + var context := _context(base) + var guest := _instance(GUEST_CORRIDOR, &"facility_00000020", Vector3i(1, 12, 0), 80, &"construction_00000020") + var door := _instance(STAFF_DOOR, &"facility_00000021", Vector3i(3, 12, 1), 81, &"construction_00000021") + context.projector.project_facility(&"z_project", GUEST_CORRIDOR, guest) + context.projector.project_facility(&"a_project", STAFF_DOOR, door) + context.projector.unproject_facility(&"m_unproject", &"facility_00000020") + var snapshot: Dictionary = context.projector.create_snapshot() + _expect(snapshot[&"projections"].map(func(row: Dictionary) -> String: return row[&"instance_id"]) == ["facility_00000021"], "snapshot stores only current projections in lexical order") + _expect(snapshot[&"processed_requests"].map(func(row: Dictionary) -> String: return row[&"request_id"]) == ["a_project", "m_unproject", "z_project"], "snapshot history is lexical independently of revision order") + _expect(snapshot[&"projection_revision"] == 3, "snapshot preserves contiguous successful revision history") + + var restored_source := SpatialType.new() + var restored_path := PathType.new() + var restored_adapter := AdapterType.new() + _expect(restored_adapter.configure(restored_source, restored_path, base), "round-trip target adapter configures empty dependencies") + var restored_projector := ProjectorType.new() + _expect(restored_projector.configure(restored_adapter, restored_source, restored_path), "round-trip target projector configures before dependency restore") + _expect(restored_source.restore_snapshot(context.source.create_snapshot()), "round-trip target restores Spatial state") + _restore_path_for_test(restored_path, _path_cells(context.path), context.path.create_snapshot()) + _expect(restored_adapter.restore_snapshot(context.adapter.create_snapshot()), "round-trip target restores Route adapter state") + _expect(restored_projector.restore_snapshot(snapshot), "strict current projector snapshot restores against matching dependencies") + _expect(restored_projector.create_snapshot() == snapshot, "projector snapshot round trip is exact") + _expect(restored_projector.validate_invariants()[&"ok"], "restored history/projection/dependency crosslinks validate") + var replay_source: Dictionary = restored_source.create_snapshot() + var replay_adapter: Dictionary = restored_adapter.create_snapshot() + var replay_path: Dictionary = restored_path.create_snapshot() + var restored_replay = restored_projector.project_facility(&"a_project", STAFF_DOOR, door) + _expect(restored_replay.is_success() and restored_replay.is_replay, "restored project history replays with the same canonical input") + _expect(restored_projector.create_snapshot() == snapshot and restored_source.create_snapshot() == replay_source and restored_adapter.create_snapshot() == replay_adapter and restored_path.create_snapshot() == replay_path, "restored replay leaves metadata and dependencies unchanged") + var restored_context := { + &"source": restored_source, + &"path": restored_path, + &"adapter": restored_adapter, + &"projector": restored_projector, + } + var before := _all_snapshots(restored_context) + + var wrong_version: Dictionary = snapshot.duplicate(true) + wrong_version[&"schema_version"] = 2 + _atomic_snapshot_reject(restored_context, wrong_version, before, "unknown snapshot version") + var version_type: Dictionary = snapshot.duplicate(true) + version_type[&"schema_version"] = "1" + _atomic_snapshot_reject(restored_context, version_type, before, "string snapshot version") + var unknown_field: Dictionary = snapshot.duplicate(true) + unknown_field[&"extra"] = true + _atomic_snapshot_reject(restored_context, unknown_field, before, "unknown root field") + var revision_type: Dictionary = snapshot.duplicate(true) + revision_type[&"projection_revision"] = 3.0 + _atomic_snapshot_reject(restored_context, revision_type, before, "floating projection revision") + var projection_order: Dictionary = snapshot.duplicate(true) + projection_order[&"projections"].append(projection_order[&"projections"][0].duplicate(true)) + _atomic_snapshot_reject(restored_context, projection_order, before, "duplicate projection order") + var request_order: Dictionary = snapshot.duplicate(true) + request_order[&"processed_requests"].reverse() + _atomic_snapshot_reject(restored_context, request_order, before, "unordered request history") + var fingerprint: Dictionary = snapshot.duplicate(true) + fingerprint[&"processed_requests"][0][&"fingerprint"] = "0".repeat(64) + _atomic_snapshot_reject(restored_context, fingerprint, before, "tampered request fingerprint") + var result_projection: Dictionary = snapshot.duplicate(true) + result_projection[&"processed_requests"][0][&"details"][&"projection"][&"staff_only"] = false + _atomic_snapshot_reject(restored_context, result_projection, before, "tampered result projection semantics") + var history_revision: Dictionary = snapshot.duplicate(true) + history_revision[&"processed_requests"][0][&"details"][&"projection_revision"] = 3 + _atomic_snapshot_reject(restored_context, history_revision, before, "duplicate history revision") + var final_crosslink: Dictionary = snapshot.duplicate(true) + final_crosslink[&"projections"].clear() + _atomic_snapshot_reject(restored_context, final_crosslink, before, "history-to-current projection mismatch") + var record_fingerprint: Dictionary = snapshot.duplicate(true) + record_fingerprint[&"projections"][0][&"record_fingerprint"] = "bad" + _atomic_snapshot_reject(restored_context, record_fingerprint, before, "tampered projection record fingerprint") + var extra_projection_field: Dictionary = snapshot.duplicate(true) + extra_projection_field[&"projections"][0][&"extra"] = true + _atomic_snapshot_reject(restored_context, extra_projection_field, before, "unknown projection field") + var deep: Dictionary = snapshot.duplicate(true) + var deep_value: Variant = [] + for _index: int in range(restored_projector.MAX_SNAPSHOT_DEPTH + 2): + deep_value = [deep_value] + deep[&"processed_requests"] = deep_value + _atomic_snapshot_reject(restored_context, deep, before, "excessive snapshot depth") + + var connector_id := &"infra.connector:facility_00000021" + var connector_before := restored_path.get_connector(connector_id) + _expect(restored_path.configure_connector(connector_id, connector_before[&"from"], connector_before[&"to"], 2, true, &"staff"), "test can introduce a live connector crosslink mismatch") + var stale_owned_before := restored_projector.create_snapshot() + var dependencies_before_restore := { + &"source": restored_source.create_snapshot(), + &"adapter": restored_adapter.create_snapshot(), + &"path": restored_path.create_snapshot(), + } + _expect(not restored_projector.restore_snapshot(snapshot), "valid metadata snapshot rejects a mismatched live staff connector") + _expect(restored_projector.create_snapshot() == stale_owned_before, "live-crosslink rejection is projector-atomic") + _expect(restored_source.create_snapshot() == dependencies_before_restore.source and restored_adapter.create_snapshot() == dependencies_before_restore.adapter and restored_path.create_snapshot() == dependencies_before_restore.path, "live-crosslink rejection preserves all dependency snapshots") + + +func _test_configuration_and_stale_dependency_boundaries() -> void: + var source_a := SpatialType.new() + var source_b := SpatialType.new() + var path_a := PathType.new() + var path_b := PathType.new() + var adapter := AdapterType.new() + adapter.configure(source_a, path_a, []) + var projector := ProjectorType.new() + _expect(not projector.configure(adapter, source_b, path_a), "configuration rejects a different Spatial instance with equal state") + _expect(not projector.configure(adapter, source_a, path_b), "configuration rejects a different Path instance with equal state") + _expect(projector.configure(adapter, source_a, path_a), "configuration accepts the exact adapter binding") + _expect(not projector.configure(adapter, source_a, path_a), "configured projector rejects reconfiguration") + + var context := _context([]) + var instance := _instance(GUEST_CORRIDOR, &"facility_00000030", Vector3i(1, 14, 0), 90, &"construction_00000030") + context.projector.project_facility(&"stale_project", GUEST_CORRIDOR, instance) + var before := _all_snapshots(context) + _expect(context.source.place(&"external_route", context.source.CATEGORY_ROUTE, [Vector3i(2, 14, 0)]).is_success(), "external ROUTE mutation can stale the lower adapter") + var source_after_external: Dictionary = context.source.create_snapshot() + var blocked = context.projector.unproject_facility(&"blocked_stale", &"facility_00000030") + _expect(blocked.code == CommandResultType.Code.TRANSACTION_CLOSED, "stale dependencies block projector mutation") + _expect(context.projector.create_snapshot() == before.projector and context.adapter.create_snapshot() == before.adapter and context.path.create_snapshot() == before.path, "stale rejection preserves projector, adapter, and Path state") + _expect(context.source.create_snapshot() == source_after_external, "stale rejection does not undo an external source mutation") + _expect(context.projector.is_projection_stale(), "projector exposes stale dependency status") + + var collision_source := SpatialType.new() + var collision_path := PathType.new() + var collision_adapter := AdapterType.new() + collision_adapter.configure(collision_source, collision_path, []) + collision_adapter.place_route(&"preexisting_managed", &"infra.route:foreign", [Vector3i(4, 14, 0)]) + var collision_projector := ProjectorType.new() + _expect(not collision_projector.configure(collision_adapter, collision_source, collision_path), "configuration rejects an untracked managed-ID namespace collision") + + +func _context(base_cells: Array, custom_path: Variant = null) -> Dictionary: + var source := SpatialType.new() + var path: PathService = custom_path if custom_path != null else PathType.new() + var adapter := AdapterType.new() + _expect(adapter.configure(source, path, base_cells), "test context configures Route adapter") + var projector := ProjectorType.new() + _expect(projector.configure(adapter, source, path), "test context configures Facility projector") + return { + &"source": source, + &"path": path, + &"adapter": adapter, + &"projector": projector, + } + + +func _instance( + definition: FacilityDefinition, + instance_id: StringName, + origin: Vector3i, + completed_at: int, + source_job_id: StringName, + rotation: int = 0 +) -> Dictionary: + var dimensions := definition.footprint + if rotation in [90, 270]: + dimensions = Vector2i(definition.footprint.y, definition.footprint.x) + var cells: Array[Vector3i] = [] + for y_offset: int in range(dimensions.y): + for x_offset: int in range(dimensions.x): + cells.append(origin + Vector3i(x_offset, y_offset, 0)) + return { + &"instance_id": instance_id, + &"definition_id": definition.id, + &"origin": origin, + &"rotation": rotation, + &"layer": definition.layer, + &"occupied_cells": cells, + &"construction_cost": definition.construction_cost.duplicate(true), + &"source_job_id": source_job_id, + &"state": &"ACTIVE", + &"completed_at": completed_at, + } + + +func _all_snapshots(context: Dictionary) -> Dictionary: + return { + &"projector": context.projector.create_snapshot(), + &"source": context.source.create_snapshot(), + &"adapter": context.adapter.create_snapshot(), + &"path": context.path.create_snapshot(), + &"path_cells": _path_cells(context.path), + } + + +func _path_cells(path: PathService) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + for row: Variant in path.create_snapshot().get(&"cell_traffic", []): + if row is Dictionary and row.get(&"cell", null) is Vector3i: + result.append(row[&"cell"]) + result.sort_custom(func(left: Vector3i, right: Vector3i) -> bool: + if left.y != right.y: + return left.y < right.y + if left.x != right.x: + return left.x < right.x + return left.z < right.z + ) + return result + + +func _restore_path_for_test(path: PathService, cells: Array, snapshot: Dictionary) -> void: + path.rebuild_graph(cells) + path.restore_snapshot(snapshot) + path.rebuild_graph(cells) + + +func _assert_exact_rollback( + context: Dictionary, + before: Dictionary, + result: CommandResult, + label: String +) -> void: + _expect(not result.is_success() and bool(result.details.get(&"rollback_ok", false)), "%s reports exact rollback" % label) + _expect(context.projector.create_snapshot() == before.projector, "%s restores exact projector snapshot" % label) + _expect(context.source.create_snapshot() == before.source, "%s restores exact Spatial snapshot" % label) + _expect(context.adapter.create_snapshot() == before.adapter, "%s restores exact Route adapter snapshot" % label) + _expect(context.path.create_snapshot() == before.path, "%s restores exact Path snapshot" % label) + + +func _atomic_snapshot_reject( + context: Dictionary, + candidate: Dictionary, + before: Dictionary, + label: String +) -> void: + _expect(not context.projector.restore_snapshot(candidate), "%s is rejected" % label) + _expect(context.projector.create_snapshot() == before.projector, "%s rejection is projector-atomic" % label) + _expect(context.source.create_snapshot() == before.source and context.adapter.create_snapshot() == before.adapter and context.path.create_snapshot() == before.path, "%s rejection leaves every dependency untouched" % label) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_facility_infrastructure_projector.gd.uid b/tests/s1/test_facility_infrastructure_projector.gd.uid new file mode 100644 index 0000000..e713f08 --- /dev/null +++ b/tests/s1/test_facility_infrastructure_projector.gd.uid @@ -0,0 +1 @@ +uid://dabxeosrueu1a diff --git a/tests/s1/test_path_congestion.gd b/tests/s1/test_path_congestion.gd new file mode 100644 index 0000000..3795d00 --- /dev/null +++ b/tests/s1/test_path_congestion.gd @@ -0,0 +1,517 @@ +extends SceneTree + +const PathServiceScript = preload("res://scripts/world/path_service.gd") + +var _checks := 0 +var _failures := 0 +var _timing_samples: Array[int] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: congestion, elevator, and stuck recovery") + _test_cell_traffic_and_dynamic_route() + _test_cache_decrease_and_unblock_invalidation() + _test_query_timing_boundary() + _test_two_hundred_actor_chokepoint() + _test_stopped_elevator_fallback_and_no_route_reason() + _test_normal_connector_outage_snapshot_stability() + _test_bounded_elevator_queue_and_order() + _test_elevator_request_global_uniqueness() + _test_parallel_connector_access_cost() + _test_stuck_detection_and_explicit_recovery() + _test_actor_progress_cleanup_contract() + _test_relevant_no_route_diagnosis() + _test_snapshot_roundtrip_and_atomic_rejection() + _test_input_order_determinism() + + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_cell_traffic_and_dynamic_route() -> void: + var service = PathServiceScript.new() + var cells := _diamond_cells() + _expect(service.rebuild_graph(cells), "traffic test graph rebuilds") + var start := Vector3i(0, 1, 0) + var goal := Vector3i(2, 1, 0) + var top_middle := Vector3i(1, 0, 0) + var bottom_middle := Vector3i(1, 2, 0) + var baseline: Array[Vector3i] = service.find_path(start, goal, service.FRONT) + _expect(baseline.has(top_middle), "equal-cost baseline uses the deterministic upper route") + _expect( + service.configure_cell_traffic(top_middle, 1, 25), + "per-cell capacity and dynamic cost are configurable" + ) + for index: int in range(4): + _expect( + service.actor_enter_cell(StringName("crowd_%03d" % index), top_middle), + "soft collision admits crowd member %d" % index + ) + var traffic := service.get_cell_traffic(top_middle) + _expect(int(traffic[&"base_capacity"]) == 1, "cell reports its base capacity") + _expect(int(traffic[&"current_density"]) == 4, "cell reports projected current density") + _expect(int(traffic[&"dynamic_cost"]) == 25, "cell reports configured dynamic cost") + _expect(service.get_congestion_cost(top_middle) == 225, "density excess costs 75 units per actor") + _expect(is_equal_approx(service.get_speed_multiplier(top_middle), 0.25), "soft collision speed clamps at 0.25") + var diverted: Array[Vector3i] = service.find_path(start, goal, service.FRONT) + _expect(diverted.has(bottom_middle), "A* avoids the dynamically congested route") + _expect(not diverted.has(top_middle), "locally invalidated cache never returns the stale congested route") + _expect(service.last_path_cost == 400, "reported route cost includes four orthogonal steps") + _expect(service.traffic_revision == 5, "one configuration and four entries advance traffic revision deterministically") + + +func _test_query_timing_boundary() -> void: + var service = PathServiceScript.new() + service.rebuild_graph([Vector3i(0, 0, 0), Vector3i(1, 0, 0)]) + _timing_samples.clear() + service.path_query_measured.connect(Callable(self, "_capture_path_sample")) + service.find_path(Vector3i(0, 0, 0), Vector3i(1, 0, 0)) + _expect(_timing_samples.size() == 1, "one public path query emits exactly one timing sample") + _expect(_timing_samples[0] >= 0, "path timing sample is non-negative") + service.find_path_report(Vector3i(0, 0, 0), Vector3i(1, 0, 0)) + _expect(_timing_samples.size() == 2, "route report also emits exactly one sample through its single search") + + +func _test_cache_decrease_and_unblock_invalidation() -> void: + var service = PathServiceScript.new() + service.rebuild_graph(_diamond_cells()) + var start := Vector3i(0, 1, 0) + var goal := Vector3i(2, 1, 0) + var top_middle := Vector3i(1, 0, 0) + var bottom_middle := Vector3i(1, 2, 0) + service.configure_cell_traffic(top_middle, 4, 500) + var expensive_route: Array[Vector3i] = service.find_path(start, goal) + _expect(expensive_route.has(bottom_middle), "high-cost upper alternative leaves lower route cached") + _expect(service.cached_path_count() == 1, "selected lower route is present in cache") + service.set_cell_dynamic_cost(top_middle, 0) + _expect(service.cached_path_count() == 0, "cost decrease broadly invalidates paths that did not touch the cheaper cell") + _expect(service.find_path(start, goal).has(top_middle), "query selects newly-cheaper non-cached alternative") + service.set_cell_temporary_block(top_middle, true) + _expect(service.find_path(start, goal).has(bottom_middle), "temporary block diverts route to lower path") + service.set_cell_temporary_block(top_middle, false) + _expect(service.cached_path_count() == 0, "block removal broadly invalidates the previously selected alternative") + _expect(service.find_path(start, goal).has(top_middle), "unblocked deterministic upper route becomes selectable immediately") + + +func _test_two_hundred_actor_chokepoint() -> void: + var service = PathServiceScript.new() + var entry := Vector3i(0, 0, 0) + var choke := Vector3i(1, 0, 0) + var exit_cell := Vector3i(2, 0, 0) + service.rebuild_graph([entry, choke, exit_cell]) + service.configure_cell_traffic(choke, 4) + var started_usec := Time.get_ticks_usec() + var all_entered := true + for index: int in range(200): + all_entered = all_entered and service.actor_enter_cell( + StringName("guest_%03d" % index), + choke + ) + var entry_elapsed_usec := Time.get_ticks_usec() - started_usec + _expect(all_entered, "all 200 actors can share the chokepoint without a hard-cap deadlock") + _expect(service.get_current_density(choke) == 200, "chokepoint density reaches 200 exactly") + _expect(service.get_congestion_cost(choke) == 14_700, "200-actor chokepoint congestion cost is exact") + _expect(is_equal_approx(service.get_speed_multiplier(choke), 0.25), "extreme density remains bounded by minimum speed") + var all_exited := true + for index: int in range(200): + var actor_id := StringName("guest_%03d" % index) + all_exited = all_exited and service.project_actor_position(actor_id, exit_cell) + all_exited = all_exited and service.actor_leave_cell(actor_id) + _expect(all_exited, "all 200 actors deterministically traverse and leave the chokepoint") + _expect(service.get_current_density(choke) == 0, "chokepoint density returns to zero") + _expect(service.get_current_density(exit_cell) == 0, "exit density returns to zero") + _expect(not service.actor_leave_cell(&"guest_000"), "duplicate leave is rejected") + _expect(service.get_current_density(choke) == 0, "rejected duplicate leave never creates negative density") + _expect(entry_elapsed_usec < 2_000_000, "200-actor admission completes within a two-second safety budget") + print(" i 200-actor admission: %d usec" % entry_elapsed_usec) + + +func _test_stopped_elevator_fallback_and_no_route_reason() -> void: + var service = PathServiceScript.new() + var front_start := Vector3i(0, 0, 0) + var front_fallback := Vector3i(1, 0, 0) + var back_goal := Vector3i(0, 0, 1) + var back_fallback := Vector3i(1, 0, 1) + service.rebuild_graph([front_start, front_fallback, back_goal, back_fallback]) + _expect( + service.configure_elevator(&"main_lift", front_start, back_goal, { + &"cost": 100, + &"cabin_capacity": 4, + &"queue_capacity": 8, + }), + "main elevator configuration is accepted" + ) + _expect( + service.configure_connector( + &"staff_stairs", + front_fallback, + back_fallback, + 300, + true + ), + "fallback stair connector is accepted" + ) + var main_route: Array[Vector3i] = service.find_path(front_start, back_goal) + _expect(main_route == [front_start, back_goal], "operational elevator is selected while cheapest") + var revision_before_stop: int = service.graph_revision + _expect( + service.set_connector_operational_state(&"main_lift", service.CONNECTOR_STOPPED), + "elevator can enter stopped state" + ) + _expect(service.graph_revision == revision_before_stop + 1, "connector state change advances graph revision") + var fallback_route: Array[Vector3i] = service.find_path(front_start, back_goal) + _expect(fallback_route.has(front_fallback), "stopped elevator triggers deterministic fallback routing") + _expect(fallback_route.has(back_fallback), "fallback route crosses the operational stairs") + _expect(not fallback_route.is_empty(), "stopped elevator does not make a reachable goal fail") + service.set_connector_operational_state(&"staff_stairs", service.CONNECTOR_OUTAGE) + var report := service.find_path_report(front_start, back_goal) + _expect(not bool(report[&"reachable"]), "route report explicitly marks unreachable goal") + _expect(report[&"reason"] == service.ROUTE_CONNECTOR_UNAVAILABLE, "no-route report names connector unavailability") + _expect(not service.last_error.is_empty(), "legacy last_error also carries an explicit no-route reason") + + +func _test_bounded_elevator_queue_and_order() -> void: + var service = PathServiceScript.new() + var from_cell := Vector3i(0, 0, 0) + var to_cell := Vector3i(0, 0, 1) + service.rebuild_graph([from_cell, to_cell]) + service.configure_elevator(&"lift", from_cell, to_cell, { + &"cabin_capacity": 2, + &"queue_capacity": 3, + }) + var normal_b := _request(&"r_normal_b", &"unit_b", 0, 1, &"NORMAL", 10) + var vip := _request(&"r_vip", &"unit_z", 0, 1, &"VIP", 10) + var normal_a := _request(&"r_normal_a", &"unit_a", 0, 1, &"NORMAL", 10) + _expect(bool(service.enqueue_elevator_request(&"lift", normal_b)[&"ok"]), "first request enters bounded queue") + _expect(bool(service.enqueue_elevator_request(&"lift", vip)[&"ok"]), "second request enters bounded queue") + _expect(bool(service.enqueue_elevator_request(&"lift", normal_a)[&"ok"]), "third request enters bounded queue") + var overflow := service.enqueue_elevator_request( + &"lift", + _request(&"r_overflow", &"unit_x", 0, 1, &"RESCUE", 10) + ) + _expect(not bool(overflow[&"ok"]) and overflow[&"reason"] == &"QUEUE_FULL", "fourth request is rejected at queue capacity") + var ordered := service.get_elevator_queue(&"lift", 10) + _expect(ordered.size() == 3, "queue remains bounded after overflow rejection") + _expect(ordered[0][&"request_id"] == &"r_vip", "VIP precedes fresh normal requests") + _expect(ordered[1][&"request_id"] == &"r_normal_a", "same-priority tie breaks by unit id") + _expect(ordered[2][&"request_id"] == &"r_normal_b", "same-minute ordering is deterministic") + var starved_order := service.get_elevator_queue(&"lift", 70) + _expect(starved_order[0][&"request_id"] == &"r_normal_a", "60-minute normal request is promoted ahead of VIP") + var boarded := service.dequeue_elevator_boarding(&"lift", 0, 70) + _expect(boarded.size() == 2, "boarding never exceeds cabin capacity") + _expect(boarded[0][&"request_id"] == &"r_normal_a", "starvation promotion applies to boarding order") + _expect(boarded[1][&"request_id"] == &"r_normal_b", "second starved normal boards deterministically") + _expect(service.elevator_queue_size(&"lift") == 1, "unboarded VIP remains queued without loss") + service.set_elevator_operational(&"lift", false) + _expect(service.dequeue_elevator_boarding(&"lift", 0, 71).is_empty(), "outage elevator boards nobody") + _expect(service.elevator_queue_size(&"lift") == 1, "outage preserves queued requests") + + +func _test_normal_connector_outage_snapshot_stability() -> void: + var from_cell := Vector3i(0, 0, 0) + var to_cell := Vector3i(0, 0, 1) + var original = PathServiceScript.new() + original.rebuild_graph([from_cell, to_cell]) + original.configure_connector(&"ordinary_door", from_cell, to_cell) + original.set_connector_operational_state(&"ordinary_door", original.CONNECTOR_OUTAGE) + var before := original.create_snapshot() + _expect(original.get_connector(&"ordinary_door")[&"cabin_state"] == original.CABIN_IDLE, "ordinary connector keeps canonical IDLE cabin placeholder during outage") + var restored = PathServiceScript.new() + _expect(restored.restore_snapshot(before), "outage connector snapshot restores") + _expect(restored.rebuild_graph([from_cell, to_cell]), "outage connector rebuilds against authoritative cells") + _expect(restored.get_connector(&"ordinary_door")[&"cabin_state"] == restored.CABIN_IDLE, "restore does not mutate ordinary connector cabin placeholder") + _expect(restored.create_snapshot() == before, "ordinary connector outage snapshot roundtrip preserves exact digest state") + + +func _test_elevator_request_global_uniqueness() -> void: + var service = PathServiceScript.new() + var a_from := Vector3i(0, 0, 0) + var a_to := Vector3i(0, 0, 1) + var b_from := Vector3i(2, 0, 0) + var b_to := Vector3i(2, 0, 1) + service.rebuild_graph([a_from, a_to, b_from, b_to]) + service.configure_elevator(&"lift_a", a_from, a_to, {&"queue_capacity": 4}) + service.configure_elevator(&"lift_b", b_from, b_to, {&"queue_capacity": 4}) + var original_request := _request(&"global_request", &"shared_unit", 0, 1, &"NORMAL", 5) + _expect(bool(service.enqueue_elevator_request(&"lift_a", original_request)[&"ok"]), "first global request is accepted") + var cross_connector := service.enqueue_elevator_request( + &"lift_b", + _request(&"global_request", &"other_unit", 0, 1, &"NORMAL", 5) + ) + _expect(not bool(cross_connector[&"ok"]) and cross_connector[&"reason"] == &"DUPLICATE_REQUEST", "request id cannot be reused across connectors") + var same_unit := service.enqueue_elevator_request( + &"lift_b", + _request(&"other_request", &"shared_unit", 0, 1, &"NORMAL", 5) + ) + _expect(not bool(same_unit[&"ok"]) and same_unit[&"reason"] == &"UNIT_ALREADY_ACTIVE", "one unit cannot own multiple active elevator requests") + var boarded := service.dequeue_elevator_boarding(&"lift_a", 0, 5) + _expect(boarded.size() == 1, "original request boards exactly once") + _expect(service.get_active_elevator_request(&"shared_unit")[&"state"] == service.ELEVATOR_REQUEST_BOARDED, "boarding persists an active BOARDED unit index") + var boarded_reentry := service.enqueue_elevator_request( + &"lift_b", + _request(&"while_boarded", &"shared_unit", 0, 1, &"NORMAL", 6) + ) + _expect(not bool(boarded_reentry[&"ok"]) and boarded_reentry[&"reason"] == &"UNIT_ALREADY_ACTIVE", "boarded unit cannot enqueue a second ride") + var mid_trip_snapshot := service.create_snapshot() + var restored = PathServiceScript.new() + _expect(restored.restore_snapshot(mid_trip_snapshot), "boarded request indexes restore") + _expect(restored.rebuild_graph([a_from, a_to, b_from, b_to]), "boarded request snapshot rebuilds") + _expect(restored.create_snapshot() == mid_trip_snapshot, "known and active request indexes roundtrip exactly") + _expect(restored.complete_elevator_trip(&"lift_a", &"global_request"), "explicit trip completion releases the unit index") + _expect(bool(restored.enqueue_elevator_request(&"lift_b", _request(&"new_trip", &"shared_unit", 0, 1, &"NORMAL", 7))[&"ok"]), "completed unit may enqueue a new request id") + var reused_retired := restored.enqueue_elevator_request( + &"lift_b", + _request(&"global_request", &"third_unit", 0, 1, &"NORMAL", 7) + ) + _expect(not bool(reused_retired[&"ok"]), "boarded and completed request id remains permanently tombstoned") + + +func _test_parallel_connector_access_cost() -> void: + var service = PathServiceScript.new() + var from_cell := Vector3i(0, 0, 0) + var to_cell := Vector3i(0, 0, 1) + service.rebuild_graph([from_cell, to_cell]) + service.configure_connector(&"a_staff_shortcut", from_cell, to_cell, 100, true, &"staff") + service.configure_connector(&"z_public_passage", from_cell, to_cell, 400) + var public_report := service.find_path_report(from_cell, to_cell) + _expect(bool(public_report[&"reachable"]), "public actor can use unrestricted parallel connector") + _expect(int(public_report[&"cost"]) == 400, "public route report excludes cheaper staff-only parallel edge") + var staff_report := service.find_path_report(from_cell, to_cell, null, [&"staff"]) + _expect(int(staff_report[&"cost"]) == 100, "staff route report uses accessible restricted shortcut") + _expect(service.calculate_path_cost(public_report[&"path"]) == 400, "public path cost API honors default connector access") + _expect(service.calculate_path_cost(staff_report[&"path"], [&"staff"]) == 100, "profiled path cost API selects the exact accessible edge") + + +func _test_stuck_detection_and_explicit_recovery() -> void: + var service = PathServiceScript.new() + var left := Vector3i(0, 0, 0) + var occupied := Vector3i(2, 0, 0) + service.rebuild_graph([left, occupied]) + service.actor_enter_cell(&"guest_stuck", occupied) + _expect( + bool(service.record_actor_progress(&"guest_stuck", Vector3i(1, 0, 0), 100)[&"ok"]), + "progress tracker accepts an actor observation" + ) + var early := service.get_actor_stuck_report(&"guest_stuck", 129) + _expect(not bool(early[&"should_recover"]), "29 minutes without progress is not yet stuck") + var stuck := service.get_actor_stuck_report(&"guest_stuck", 130) + _expect(bool(stuck[&"should_recover"]), "30 minutes without progress triggers stuck recovery") + _expect(stuck[&"reason"] == &"STUCK", "stuck report carries an explicit reason") + _expect(bool(stuck[&"requires_explicit_move"]), "recovery requires an explicit caller action") + _expect(bool(stuck[&"recovery"][&"found"]), "stuck report provides a safe recovery candidate") + _expect(stuck[&"recovery"][&"cell"] == left, "nearest-cell tie uses deterministic coordinate order") + _expect(not bool(stuck[&"recovery"][&"implicit_move_applied"]), "PathService never teleports during stuck evaluation") + _expect(service.get_actor_projected_cell(&"guest_stuck") == occupied, "actor occupancy is unchanged by recovery proposal") + service.record_actor_progress(&"guest_stuck", occupied, 131) + var reset := service.get_actor_stuck_report(&"guest_stuck", 160) + _expect(not bool(reset[&"should_recover"]), "actual cell progress resets the 30-minute timer") + + +func _test_actor_progress_cleanup_contract() -> void: + var service = PathServiceScript.new() + var left := Vector3i(0, 0, 0) + var right := Vector3i(1, 0, 0) + service.rebuild_graph([left, right]) + service.actor_enter_cell(&"despawned", left, 10) + _expect(service.actor_leave_cell(&"despawned"), "default leave removes occupancy") + _expect(not bool(service.get_actor_stuck_report(&"despawned", 40)[&"ok"]), "default leave also removes actor progress") + service.actor_enter_cell(&"preserved", left, 10) + _expect(service.actor_leave_cell(&"preserved", false), "leave can explicitly retain progress for a transfer") + _expect(bool(service.get_actor_stuck_report(&"preserved", 40)[&"ok"]), "opt-out cleanup retains transfer progress") + service.project_occupancy([ + {&"actor_id": &"kept", &"cell": left}, + {&"actor_id": &"removed", &"cell": right}, + ]) + service.record_actor_progress(&"kept", left, 50) + service.record_actor_progress(&"removed", right, 50) + service.project_occupancy([{&"actor_id": &"kept", &"cell": left}]) + _expect(bool(service.get_actor_stuck_report(&"kept", 50)[&"ok"]), "authoritative occupancy replacement preserves retained actor progress") + _expect(not bool(service.get_actor_stuck_report(&"removed", 50)[&"ok"]), "authoritative occupancy replacement cleans unprojected progress") + service.record_actor_progress(&"progress_only", right, 60) + _expect(service.despawn_actor(&"progress_only"), "despawn API cleans a progress-only actor") + _expect(not bool(service.get_actor_stuck_report(&"progress_only", 60)[&"ok"]), "despawn leaves no stale progress record") + + +func _test_relevant_no_route_diagnosis() -> void: + var service = PathServiceScript.new() + var start := Vector3i(0, 0, 0) + var goal := Vector3i(10, 0, 0) + var unrelated_a := Vector3i(20, 0, 0) + var unrelated_b := Vector3i(20, 0, 1) + service.rebuild_graph([start, goal, unrelated_a, unrelated_b]) + service.set_cell_temporary_block(unrelated_a, true) + service.configure_connector(&"unrelated_lift", unrelated_a, unrelated_b) + service.set_connector_operational_state(&"unrelated_lift", service.CONNECTOR_OUTAGE) + var unrelated_report := service.find_path_report(start, goal) + _expect(unrelated_report[&"reason"] == service.ROUTE_GOAL_UNREACHABLE, "unrelated block and outage do not misclassify a disconnected route") + + var blocked = PathServiceScript.new() + blocked.rebuild_graph([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(2, 0, 0)]) + blocked.set_cell_temporary_block(Vector3i(1, 0, 0), true) + _expect(blocked.find_path_report(Vector3i(0, 0, 0), Vector3i(2, 0, 0))[&"reason"] == blocked.ROUTE_TEMPORARILY_BLOCKED, "relevant temporary block is diagnosed by auxiliary reachability") + + var outage = PathServiceScript.new() + var outage_from := Vector3i(0, 0, 0) + var outage_to := Vector3i(0, 0, 1) + outage.rebuild_graph([outage_from, outage_to]) + outage.configure_connector(&"relevant_door", outage_from, outage_to) + outage.set_connector_operational_state(&"relevant_door", outage.CONNECTOR_OUTAGE) + _expect(outage.find_path_report(outage_from, outage_to)[&"reason"] == outage.ROUTE_CONNECTOR_UNAVAILABLE, "relevant stopped connector is diagnosed precisely") + + +func _test_snapshot_roundtrip_and_atomic_rejection() -> void: + var original = PathServiceScript.new() + var from_cell := Vector3i(0, 0, 0) + var to_cell := Vector3i(0, 0, 1) + original.rebuild_graph([from_cell, to_cell]) + original.configure_cell_traffic(from_cell, 2, 7) + original.actor_enter_cell(&"guest_a", from_cell, 40) + original.configure_elevator(&"lift_save", from_cell, to_cell, { + &"cabin_capacity": 3, + &"queue_capacity": 4, + &"travel_minutes_per_floor": 2, + }) + original.enqueue_elevator_request( + &"lift_save", + _request(&"saved_request", &"guest_a", 0, 1, &"NORMAL", 40) + ) + original.record_actor_progress(&"off_graph_actor", Vector3i(4, 4, 0), 12) + var snapshot := original.create_snapshot() + _expect(int(snapshot[&"schema_version"]) == 3, "request-safe traffic snapshot uses schema version 3") + _expect(snapshot.has(&"cell_traffic") and snapshot.has(&"actor_cells"), "snapshot persists traffic and occupancy projection") + _expect(not snapshot.has(&"graph") and not snapshot.has(&"cache"), "snapshot still excludes derived graph and cache") + var restored = PathServiceScript.new() + _expect(restored.restore_snapshot(snapshot), "v3 snapshot restores before authoritative world graph") + _expect(restored.rebuild_graph([from_cell, to_cell]), "restored traffic validates against authoritative cells") + _expect(restored.create_snapshot() == snapshot, "v3 snapshot roundtrip is exact") + _expect(restored.get_current_density(from_cell) == 1, "restored density is derived consistently from actors") + _expect(restored.elevator_queue_size(&"lift_save") == 1, "restored elevator retains its request queue") + + var before_corrupt := restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"cell_traffic"][0][&"current_density"] = 99 + _expect(not restored.restore_snapshot(corrupt), "corrupt density snapshot is rejected") + _expect(restored.create_snapshot() == before_corrupt, "failed restore is atomic and preserves live state") + var corrupt_index := snapshot.duplicate(true) + corrupt_index[&"active_elevator_units"][0][&"state"] = &"INVALID" + _expect(not restored.restore_snapshot(corrupt_index), "corrupt elevator active index is rejected") + _expect(restored.create_snapshot() == before_corrupt, "active-index rejection is also fully atomic") + var v2_snapshot := snapshot.duplicate(true) + v2_snapshot[&"schema_version"] = 2 + v2_snapshot.erase(&"known_elevator_request_ids") + v2_snapshot.erase(&"active_elevator_units") + var v2_restored = PathServiceScript.new() + _expect(v2_restored.restore_snapshot(v2_snapshot), "schema v2 snapshot remains readable") + _expect(v2_restored.rebuild_graph([from_cell, to_cell]), "schema v2 traffic validates against authoritative cells") + _expect(v2_restored.get_active_elevator_request(&"guest_a")[&"state"] == v2_restored.ELEVATOR_REQUEST_QUEUED, "v2 queued requests derive new active indexes deterministically") + + var legacy_connector := { + &"id": &"legacy_door", + &"from": from_cell, + &"to": to_cell, + &"cost": 150, + &"bidirectional": true, + &"access_id": &"", + &"enabled": true, + } + var legacy = PathServiceScript.new() + _expect( + legacy.restore_snapshot({&"schema_version": 1, &"connectors": [legacy_connector]}), + "legacy connector-only snapshot remains readable" + ) + _expect(legacy.rebuild_graph([from_cell, to_cell]), "legacy restore rebuilds against current world cells") + _expect(legacy.find_path(from_cell, to_cell).size() == 2, "legacy connector remains operational") + + +func _test_input_order_determinism() -> void: + var first = _build_ordered_service(false) + var reversed = _build_ordered_service(true) + _expect(first.create_snapshot() == reversed.create_snapshot(), "actor and request insertion order does not change canonical snapshot") + var first_path: Array[Vector3i] = first.find_path(Vector3i(0, 0, 0), Vector3i(0, 0, 1)) + var reversed_path: Array[Vector3i] = reversed.find_path(Vector3i(0, 0, 0), Vector3i(0, 0, 1)) + _expect(first_path == reversed_path, "input order does not change route selection") + _expect( + first.get_elevator_queue(&"deterministic_lift", 20) == reversed.get_elevator_queue(&"deterministic_lift", 20), + "input order does not change elevator queue order" + ) + _expect(first.get_current_density(Vector3i(1, 0, 0)) == 3, "canonical projection retains all actors") + + +func _build_ordered_service(reverse_input: bool): + var service = PathServiceScript.new() + var cells := [Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(0, 0, 1)] + if reverse_input: + cells.reverse() + service.rebuild_graph(cells) + service.configure_elevator(&"deterministic_lift", Vector3i(0, 0, 0), Vector3i(0, 0, 1), { + &"queue_capacity": 5, + }) + var occupancy := [ + {&"actor_id": &"actor_c", &"cell": Vector3i(1, 0, 0)}, + {&"actor_id": &"actor_a", &"cell": Vector3i(1, 0, 0)}, + {&"actor_id": &"actor_b", &"cell": Vector3i(1, 0, 0)}, + ] + var requests := [ + _request(&"req_c", &"unit_c", 0, 1, &"NORMAL", 20), + _request(&"req_a", &"unit_a", 0, 1, &"NORMAL", 20), + _request(&"req_b", &"unit_b", 0, 1, &"NORMAL", 20), + ] + if reverse_input: + occupancy.reverse() + requests.reverse() + service.project_occupancy(occupancy) + for request: Dictionary in requests: + service.enqueue_elevator_request(&"deterministic_lift", request) + return service + + +func _diamond_cells() -> Array[Vector3i]: + return [ + Vector3i(0, 1, 0), + Vector3i(0, 0, 0), + Vector3i(1, 0, 0), + Vector3i(2, 0, 0), + Vector3i(2, 1, 0), + Vector3i(0, 2, 0), + Vector3i(1, 2, 0), + Vector3i(2, 2, 0), + ] + + +func _request( + request_id: StringName, + unit_id: StringName, + floor_from: int, + floor_to: int, + priority: StringName, + enqueued_at: int +) -> Dictionary: + return { + &"request_id": request_id, + &"unit_id": unit_id, + &"floor_from": floor_from, + &"floor_to": floor_to, + &"direction": &"UP" if floor_to > floor_from else &"DOWN", + &"priority": priority, + &"enqueued_at": enqueued_at, + } + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) + + +func _capture_path_sample(duration_us: int) -> void: + _timing_samples.append(duration_us) diff --git a/tests/s1/test_path_congestion.gd.uid b/tests/s1/test_path_congestion.gd.uid new file mode 100644 index 0000000..f6a9f99 --- /dev/null +++ b/tests/s1/test_path_congestion.gd.uid @@ -0,0 +1 @@ +uid://qbem45x6fu4h diff --git a/tests/s1/test_path_service.gd b/tests/s1/test_path_service.gd new file mode 100644 index 0000000..794b867 --- /dev/null +++ b/tests/s1/test_path_service.gd @@ -0,0 +1,182 @@ +extends SceneTree + +const PathServiceScript = preload("res://scripts/world/path_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: deterministic PathService") + _test_deterministic_tie_break() + _test_layer_separation_and_connector() + _test_connector_access_and_direction() + _test_cache_invalidation_and_revision() + _test_connector_only_snapshot() + + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_deterministic_tie_break() -> void: + var service = PathServiceScript.new() + var passable := [ + Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(2, 0, 0), + Vector3i(0, 1, 0), Vector3i(2, 1, 0), + Vector3i(0, 2, 0), Vector3i(1, 2, 0), Vector3i(2, 2, 0), + ] + _expect(service.rebuild_graph(passable), "array of Vector3i cells rebuilds the path graph") + var expected: Array[Vector3i] = [ + Vector3i(0, 1, 0), + Vector3i(0, 0, 0), + Vector3i(1, 0, 0), + Vector3i(2, 0, 0), + Vector3i(2, 1, 0), + ] + var first: Array[Vector3i] = service.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0), service.FRONT) + var second: Array[Vector3i] = service.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0), service.FRONT) + _expect(first == expected, "equal-cost routes use coordinate dictionary order as the tie break") + _expect(second == first, "the cached route exactly reproduces the calculated route") + _expect(service.cache_hits == 1 and service.cache_misses == 1, "path cache records one miss followed by one hit") + + +func _test_layer_separation_and_connector() -> void: + var service = PathServiceScript.new() + var passable_set := {} + for layer in [service.FRONT, service.BACK]: + for x in range(3): + passable_set[Vector3i(x, 0, layer)] = true + _expect(service.rebuild_graph(passable_set), "Dictionary set can supply passable cells without a WorldService dependency") + _expect( + service.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 1)).is_empty(), + "FRONT and BACK never connect through ordinary cardinal edges" + ) + _expect( + service.configure_connector( + &"service_door", + Vector3i(2, 0, 0), + Vector3i(0, 0, 1), + 120, + true + ), + "an explicit layer connector is accepted" + ) + var connected_path: Array[Vector3i] = service.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 1)) + _expect( + connected_path.has(Vector3i(2, 0, 0)) and connected_path.has(Vector3i(0, 0, 1)), + "an explicit connector joins the two isolated layers" + ) + _expect( + service.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 1), service.FRONT).is_empty(), + "an access-layer restriction rejects a route that enters BACK" + ) + + +func _test_connector_access_and_direction() -> void: + var service = PathServiceScript.new() + var cells := [Vector3i(0, 0, 0), Vector3i(0, 2, 0)] + service.rebuild_graph(cells) + service.configure_connector( + &"staff_stairs", + Vector3i(0, 0, 0), + Vector3i(0, 2, 0), + 150, + false, + &"staff" + ) + _expect( + service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty(), + "a restricted connector requires its access id" + ) + var staff_profile := { + &"allowed_layers": [service.FRONT], + &"connector_access_ids": [&"staff"], + } + _expect( + service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0), staff_profile).size() == 2, + "an access profile enables its matching connector" + ) + _expect( + service.find_path(Vector3i(0, 2, 0), Vector3i(0, 0, 0), staff_profile).is_empty(), + "a one-way connector cannot be traversed in reverse" + ) + + +func _test_cache_invalidation_and_revision() -> void: + var service = PathServiceScript.new() + var passable := [ + Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(2, 0, 0), + Vector3i(0, 1, 0), Vector3i(2, 1, 0), + Vector3i(0, 2, 0), Vector3i(1, 2, 0), Vector3i(2, 2, 0), + ] + service.rebuild_graph(passable) + var initial_revision: int = service.graph_revision + service.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0)) + _expect(service.cached_path_count() == 1, "a calculated path is cached") + _expect( + service.invalidate_cache_for_cells([Vector3i(1, 0, 0)]) == 1, + "local invalidation removes a cached path that crosses an affected cell" + ) + _expect(service.cached_path_count() == 0, "local invalidation leaves no matching stale cache entry") + service.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0)) + _expect(service.invalidate_all_cache() == 1, "full invalidation reports and removes every cache entry") + + passable.erase(Vector3i(1, 0, 0)) + service.rebuild_graph(passable) + _expect(service.graph_revision == initial_revision + 1, "a changed topology advances graph_revision exactly once") + var rebuilt_path: Array[Vector3i] = service.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0)) + _expect(rebuilt_path.has(Vector3i(1, 2, 0)), "rebuild discards the old route and uses the remaining corridor") + + +func _test_connector_only_snapshot() -> void: + var cells := [Vector3i(0, 0, 0), Vector3i(0, 0, 1)] + var original = PathServiceScript.new() + original.rebuild_graph(cells) + original.configure_connector( + &"lift_a", + Vector3i(0, 0, 0), + Vector3i(0, 0, 1), + 250, + true, + &"lift_pass" + ) + original.find_path( + Vector3i(0, 0, 0), + Vector3i(0, 0, 1), + null, + [&"lift_pass"] + ) + var snapshot: Dictionary = original.create_snapshot() + _expect( + snapshot.has(&"connectors") and not snapshot.has(&"graph") and not snapshot.has(&"cache") and not snapshot.has(&"passable_cells"), + "snapshot persists connector configuration but no derived graph or cache" + ) + + var restored = PathServiceScript.new() + _expect(restored.restore_snapshot(snapshot), "connector configuration restores independently of world cells") + _expect(restored.rebuild_graph(cells), "restored service rebuilds its graph from authoritative passable cells") + _expect( + restored.find_path( + Vector3i(0, 0, 0), + Vector3i(0, 0, 1), + null, + [&"lift_pass"] + ).size() == 2, + "restored connector produces the same cross-layer route after rebuild" + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_path_service.gd.uid b/tests/s1/test_path_service.gd.uid new file mode 100644 index 0000000..8228e87 --- /dev/null +++ b/tests/s1/test_path_service.gd.uid @@ -0,0 +1 @@ +uid://bbqw1dome2hdp diff --git a/tests/s1/test_route_subslot_path_adapter.gd b/tests/s1/test_route_subslot_path_adapter.gd new file mode 100644 index 0000000..aa390ff --- /dev/null +++ b/tests/s1/test_route_subslot_path_adapter.gd @@ -0,0 +1,496 @@ +extends SceneTree + +const AdapterType = preload("res://scripts/world/route_subslot_path_adapter.gd") +const SpatialType = preload("res://scripts/world/spatial_subslot_service.gd") +const PathType = preload("res://scripts/world/path_service.gd") +const WorldType = preload("res://scripts/world/world_service.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + + +class ControlledPathService: + extends PathService + + var fail_next_rebuild_after_mutation := false + var reject_restore := false + var reenter_next_rebuild := false + var tamper_adapter_next_rebuild := false + var adapter: Variant = null + var reentry_result: Variant = null + + func rebuild_graph(passable_cells: Variant) -> bool: + var should_fail := fail_next_rebuild_after_mutation + fail_next_rebuild_after_mutation = false + var should_reenter := reenter_next_rebuild + reenter_next_rebuild = false + var should_tamper := tamper_adapter_next_rebuild + tamper_adapter_next_rebuild = false + var rebuilt := super.rebuild_graph(passable_cells) + if should_reenter and adapter != null: + reentry_result = adapter.place_route( + &"nested_path_request", + &"nested_path_route", + [Vector3i(40, 0, 0)] + ) + if should_tamper and adapter != null: + adapter.projection_revision += 99 + if should_fail: + last_error = "Injected Path rebuild failure after mutation." + return false + return rebuilt + + func restore_snapshot(snapshot: Dictionary) -> bool: + if reject_restore: + last_error = "Injected Path restore failure." + return false + return super.restore_snapshot(snapshot) + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: atomic ROUTE subslot Path projection") + _test_basic_projection_and_world_coexistence() + _test_replace_and_layer_separation() + _test_idempotency_and_defensive_queries() + _test_stale_detection_and_synchronization() + _test_path_failure_rolls_both_services_back() + _test_callback_reentry_and_tamper_defense() + _test_uncertain_rollback_quarantines_and_recovers() + _test_strict_snapshot_round_trip_and_atomic_rejection() + _test_strict_input_and_route_only_boundary() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_basic_projection_and_world_coexistence() -> void: + var source := SpatialType.new() + var path := PathType.new() + var world := WorldType.new() + world.reset({&"min_x": -2, &"max_x": 4, &"min_y": -2, &"max_y": 2}) + var start := Vector3i(0, 0, 0) + var bridge := Vector3i(1, 0, 0) + var goal := Vector3i(2, 0, 0) + _expect(world.occupy(&"major_facility", [bridge], &"facility_large"), "major World occupancy exists independently") + var adapter := AdapterType.new() + _expect(adapter.configure(source, path, [goal, start]), "base walkable cells configure the initial Path projection") + _expect(path.find_path(start, goal).is_empty(), "disconnected base cells are initially unreachable") + var placed = adapter.place_route(&"route_place_1", &"route_bridge", [bridge]) + _expect(placed.is_success(), "ROUTE placement and Path rebuild commit together") + _expect(path.find_path(start, goal) == [start, bridge, goal], "ROUTE subslot makes the real Path graph reachable") + _expect(world.get_occupant(bridge) == &"major_facility", "ROUTE projection preserves primary World occupancy") + _expect(adapter.source_revision == 1 and adapter.projection_revision == 2, "source and projection revisions advance deterministically") + _expect(not adapter.is_projection_stale(), "committed projection is fresh") + _expect(adapter.remove_route(&"route_remove_1", &"route_bridge").is_success(), "ROUTE removal and Path rebuild commit together") + _expect(path.find_path(start, goal).is_empty(), "removing the ROUTE makes the real Path graph unreachable again") + _expect(adapter.validate_invariants()[&"ok"], "basic projection crosslinks remain valid") + + +func _test_replace_and_layer_separation() -> void: + var source := SpatialType.new() + var path := PathType.new() + var front_start := Vector3i(0, 2, 0) + var front_mid := Vector3i(1, 2, 0) + var front_goal := Vector3i(2, 2, 0) + var back_start := Vector3i(0, 2, 1) + var back_mid := Vector3i(1, 2, 1) + var back_goal := Vector3i(2, 2, 1) + var adapter := AdapterType.new() + _expect( + adapter.configure(source, path, [back_goal, front_goal, back_start, front_start]), + "two-layer base projection configures in canonical order" + ) + _expect( + adapter.place_route(&"layers_place", &"dual_layer_route", [back_mid, front_mid]).is_success(), + "one ROUTE entity can project cells on both layers" + ) + _expect(path.find_path(front_start, front_goal) == [front_start, front_mid, front_goal], "FRONT route is reachable") + _expect(path.find_path(back_start, back_goal) == [back_start, back_mid, back_goal], "BACK route is reachable") + _expect(path.find_path(front_start, back_goal).is_empty(), "FRONT and BACK remain disconnected without a connector") + var replaced = adapter.replace_route(&"layers_replace", &"dual_layer_route", [front_mid]) + _expect(replaced.is_success() and replaced.details[&"changed"], "ROUTE replacement commits atomically") + _expect(path.find_path(front_start, front_goal).size() == 3, "replacement retains the FRONT route") + _expect(path.find_path(back_start, back_goal).is_empty(), "replacement removes the BACK projection") + + var base_route := adapter.place_route(&"base_overlap_place", &"base_overlap_route", [front_start]) + _expect(base_route.is_success(), "ROUTE may overlap a base walkable cell") + _expect(adapter.remove_route(&"base_overlap_remove", &"base_overlap_route").is_success(), "base-overlap ROUTE removes cleanly") + _expect(path.has_cell(front_start), "removing a ROUTE never removes the overlapping base cell") + _expect(adapter.validate_invariants()[&"ok"], "replacement and layer projection crosslinks remain valid") + + +func _test_idempotency_and_defensive_queries() -> void: + var source := SpatialType.new() + var path := PathType.new() + var adapter := AdapterType.new() + var start := Vector3i(0, 0, 0) + var middle := Vector3i(1, 0, 0) + var goal := Vector3i(2, 0, 0) + adapter.configure(source, path, [goal, start]) + var first = adapter.place_route(&"z_request", &"route_idem", [middle]) + var after_first_adapter := adapter.create_snapshot() + var after_first_source := source.create_snapshot() + var after_first_path := path.create_snapshot() + var replay = adapter.place_route(&"z_request", &"route_idem", [middle]) + _expect(first.is_success() and replay.is_success() and replay.is_replay, "same request ID and fingerprint replays") + _expect(replay.details == first.details, "idempotent replay preserves exact result details") + _expect(adapter.create_snapshot() == after_first_adapter, "replay does not advance adapter state") + _expect(source.create_snapshot() == after_first_source and path.create_snapshot() == after_first_path, "replay does not touch either authoritative service") + var conflict = adapter.place_route(&"z_request", &"route_idem", [Vector3i(1, 1, 0)]) + _expect(conflict.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, "same request ID with another fingerprint conflicts") + var before_noop_revision := adapter.projection_revision + var noop = adapter.replace_route(&"a_request", &"route_idem", [middle]) + _expect(noop.is_success() and not noop.details[&"changed"], "canonical-equivalent replace is a successful no-op") + _expect(adapter.projection_revision == before_noop_revision, "no-op request does not advance projection revision") + _expect(adapter.get_processed_request_ids() == [&"a_request", &"z_request"], "processed request IDs are lexical") + + var returned_base := adapter.get_base_walkable_cells() + var returned_route := adapter.get_projected_route_cells() + var returned_walkable := adapter.get_projected_walkable_cells() + var returned_status := adapter.get_projection_status() + returned_base.clear() + returned_route.clear() + returned_walkable.clear() + returned_status[&"source_changed"] = true + _expect(adapter.get_base_walkable_cells().size() == 2, "base-cell query is defensive") + _expect(adapter.get_projected_route_cells() == [middle], "route projection query is defensive") + _expect(adapter.get_projected_walkable_cells().size() == 3, "walkable projection query is defensive") + _expect(not adapter.get_projection_status()[&"source_changed"], "projection status query is defensive") + + +func _test_stale_detection_and_synchronization() -> void: + var source := SpatialType.new() + var path := PathType.new() + var adapter := AdapterType.new() + var start := Vector3i(0, 0, 0) + var middle := Vector3i(1, 0, 0) + var goal := Vector3i(2, 0, 0) + var base := [start, goal] + adapter.configure(source, path, base) + _expect(source.place(&"external_route", source.CATEGORY_ROUTE, [middle]).is_success(), "direct source mutation can occur outside the adapter boundary") + var stale_status := adapter.get_projection_status() + _expect(stale_status[&"stale"] and stale_status[&"source_changed"], "direct ROUTE mutation is detected as stale") + var blocked = adapter.place_route(&"blocked_while_stale", &"blocked_route", [Vector3i(3, 0, 0)]) + _expect(blocked.code == CommandResultType.Code.TRANSACTION_CLOSED, "route mutation is blocked while projection is stale") + var synchronized = adapter.synchronize_projection(&"sync_source_1") + _expect(synchronized.is_success() and synchronized.details[&"changed"], "explicit synchronization projects the external ROUTE change") + _expect(path.find_path(start, goal) == [start, middle, goal], "source synchronization repairs real Path reachability") + _expect(adapter.source_revision == source.get_revision_for_category(source.CATEGORY_ROUTE), "synchronization records the exact source revision") + var sync_replay = adapter.synchronize_projection(&"sync_source_1") + _expect(sync_replay.is_success() and sync_replay.is_replay, "synchronization request is idempotently replayed") + + path.rebuild_graph(base) + _expect(adapter.get_projection_status()[&"path_changed"], "direct Path topology drift is detected") + var repaired = adapter.synchronize_projection(&"sync_path_2") + _expect(repaired.is_success() and repaired.details[&"changed"], "synchronization repairs direct Path drift") + _expect(path.find_path(start, goal).size() == 3 and not adapter.is_projection_stale(), "Path drift repair restores a fresh route") + var projection_before_decoration := adapter.projection_revision + _expect(source.place(&"external_decor", source.CATEGORY_DECORATION, [middle]).is_success(), "non-ROUTE source category changes independently") + _expect(not adapter.is_projection_stale(), "DECORATION revision does not stale the ROUTE projection") + _expect(adapter.projection_revision == projection_before_decoration, "unrelated source mutation does not advance projection revision") + + var base_source := SpatialType.new() + var base_path := PathType.new() + var base_adapter := AdapterType.new() + base_adapter.configure(base_source, base_path, base) + var base_added = base_adapter.synchronize_projection(&"base_add", [goal, middle, start]) + _expect(base_added.is_success() and base_added.details[&"changed"], "synchronization atomically replaces the base walkable set") + _expect(base_path.find_path(start, goal).size() == 3, "new base walkability participates in the rebuilt graph") + _expect(base_adapter.get_base_walkable_cells() == [start, middle, goal], "updated base cells are canonical and queryable") + var base_removed = base_adapter.synchronize_projection(&"base_remove", base) + _expect(base_removed.is_success() and base_removed.details[&"changed"], "base walkability can be removed atomically") + _expect(base_path.find_path(start, goal).is_empty(), "removed base walkability disappears from the graph") + _expect(base_adapter.validate_invariants()[&"ok"], "dynamic base synchronization preserves crosslinks") + + +func _test_path_failure_rolls_both_services_back() -> void: + var source := SpatialType.new() + var path := ControlledPathService.new() + var adapter := AdapterType.new() + path.adapter = adapter + var start := Vector3i(0, 0, 0) + var middle := Vector3i(1, 0, 0) + var alternate := Vector3i(1, 1, 0) + var goal := Vector3i(2, 0, 0) + adapter.configure(source, path, [start, goal]) + + var before_adapter := adapter.create_snapshot() + var before_source := source.create_snapshot() + var before_path := path.create_snapshot() + path.fail_next_rebuild_after_mutation = true + var failed_place = adapter.place_route(&"retryable_place", &"route_atomic", [middle]) + _assert_exact_rollback(adapter, source, path, before_adapter, before_source, before_path, failed_place, "failed place") + _expect(not adapter.is_quarantined(), "exact place rollback does not quarantine") + _expect(not source.has_entity(&"route_atomic") and path.find_path(start, goal).is_empty(), "failed place leaves neither side applied") + _expect(adapter.place_route(&"retryable_place", &"route_atomic", [middle]).is_success(), "failed request ID can be retried after exact rollback") + + before_adapter = adapter.create_snapshot() + before_source = source.create_snapshot() + before_path = path.create_snapshot() + path.fail_next_rebuild_after_mutation = true + var failed_replace = adapter.replace_route(&"retryable_replace", &"route_atomic", [alternate]) + _assert_exact_rollback(adapter, source, path, before_adapter, before_source, before_path, failed_replace, "failed replace") + _expect(source.get_entity_cells(&"route_atomic") == [middle], "failed replacement restores the original ROUTE cells") + _expect(adapter.replace_route(&"retryable_replace", &"route_atomic", [alternate]).is_success(), "replacement retries after exact rollback") + + before_adapter = adapter.create_snapshot() + before_source = source.create_snapshot() + before_path = path.create_snapshot() + path.fail_next_rebuild_after_mutation = true + var failed_remove = adapter.remove_route(&"retryable_remove", &"route_atomic") + _assert_exact_rollback(adapter, source, path, before_adapter, before_source, before_path, failed_remove, "failed remove") + _expect(source.has_entity(&"route_atomic"), "failed removal restores the ROUTE entity") + _expect(adapter.remove_route(&"retryable_remove", &"route_atomic").is_success(), "removal retries after exact rollback") + path.adapter = null + + +func _test_callback_reentry_and_tamper_defense() -> void: + var source := SpatialType.new() + var path := ControlledPathService.new() + var adapter := AdapterType.new() + path.adapter = adapter + adapter.configure(source, path, [Vector3i(0, 0, 0), Vector3i(2, 0, 0)]) + path.reenter_next_rebuild = true + var outer = adapter.place_route(&"outer_path_callback", &"outer_route", [Vector3i(1, 0, 0)]) + _expect(outer.is_success(), "outer transaction commits when Path callback only attempts reentry") + _expect(path.reentry_result != null and not path.reentry_result.is_success(), "Path callback reentry is rejected") + _expect(not source.has_entity(&"nested_path_route"), "reentrant Path callback cannot create a nested ROUTE") + + var nested_source_result: Array = [null] + source.configure_compatibility_validator( + func(_payload: Dictionary) -> Dictionary: + nested_source_result[0] = adapter.place_route( + &"nested_source_request", + &"nested_source_route", + [Vector3i(5, 0, 0)] + ) + return {&"ok": true} + ) + var source_outer = adapter.place_route(&"outer_source_callback", &"second_route", [Vector3i(3, 0, 0)]) + _expect(source_outer.is_success(), "outer transaction commits when Spatial validator only attempts reentry") + _expect(nested_source_result[0] != null and not nested_source_result[0].is_success(), "Spatial callback reentry is rejected") + _expect(not source.has_entity(&"nested_source_route"), "reentrant Spatial callback cannot create a nested ROUTE") + source.configure_compatibility_validator(Callable()) + + var before_adapter := adapter.create_snapshot() + var before_source := source.create_snapshot() + var before_path := path.create_snapshot() + path.tamper_adapter_next_rebuild = true + var tampered = adapter.replace_route(&"path_tamper_request", &"second_route", [Vector3i(4, 0, 0)]) + _assert_exact_rollback(adapter, source, path, before_adapter, before_source, before_path, tampered, "Path callback adapter tamper") + _expect(not adapter.is_quarantined(), "detected callback tamper with exact rollback stays available") + + source.configure_compatibility_validator( + func(_payload: Dictionary) -> Dictionary: + return {&"ok": false, &"reason": &"WORLD_POLICY_REJECTED"} + ) + before_adapter = adapter.create_snapshot() + before_source = source.create_snapshot() + before_path = path.create_snapshot() + var rejected = adapter.place_route(&"source_rejection", &"rejected_route", [Vector3i(6, 0, 0)]) + _assert_exact_rollback(adapter, source, path, before_adapter, before_source, before_path, rejected, "Spatial validator rejection") + source.configure_compatibility_validator(Callable()) + path.adapter = null + + +func _test_uncertain_rollback_quarantines_and_recovers() -> void: + var source := SpatialType.new() + var path := ControlledPathService.new() + var adapter := AdapterType.new() + path.adapter = adapter + var start := Vector3i(0, 0, 0) + var middle := Vector3i(1, 0, 0) + var goal := Vector3i(2, 0, 0) + var base := [start, goal] + adapter.configure(source, path, base) + var recovery_adapter := adapter.create_snapshot() + var recovery_source := source.create_snapshot() + var recovery_path := path.create_snapshot() + path.fail_next_rebuild_after_mutation = true + path.reject_restore = true + var failed = adapter.place_route(&"uncertain_request", &"uncertain_route", [middle]) + _expect(not failed.is_success() and not failed.details[&"rollback_ok"], "uncertain rollback reports failure explicitly") + _expect(adapter.is_quarantined() and adapter.get_quarantine_reason() == adapter.QUARANTINE_TRANSACTION_ROLLBACK, "uncertain rollback quarantines the adapter") + _expect(adapter.is_projection_stale(), "quarantined projection reports stale") + var blocked = adapter.place_route(&"blocked_in_quarantine", &"blocked_route", [Vector3i(3, 0, 0)]) + _expect(blocked.code == CommandResultType.Code.TRANSACTION_CLOSED, "quarantine blocks further mutations") + + path.reject_restore = false + _expect(source.restore_snapshot(recovery_source), "recovery restores the Spatial source snapshot") + _restore_path_for_test(path, base, recovery_path) + _expect(path.create_snapshot() == recovery_path, "recovery restores the exact Path snapshot") + _expect(adapter.restore_snapshot(recovery_adapter), "exact adapter snapshot clears quarantine after dependencies recover") + _expect(not adapter.is_quarantined() and not adapter.is_projection_stale(), "snapshot recovery returns the adapter to service") + _expect(adapter.place_route(&"post_recovery", &"recovered_route", [middle]).is_success(), "route transaction succeeds after quarantine recovery") + path.adapter = null + + +func _test_strict_snapshot_round_trip_and_atomic_rejection() -> void: + var source := SpatialType.new() + var path := PathType.new() + var adapter := AdapterType.new() + var base := [Vector3i(0, 0, 0), Vector3i(3, 0, 0)] + adapter.configure(source, path, base) + adapter.place_route(&"z_place", &"route_z", [Vector3i(1, 0, 0)]) + adapter.place_route(&"a_place", &"route_a", [Vector3i(2, 0, 0)]) + adapter.synchronize_projection(&"b_sync_base", base) + adapter.replace_route(&"m_noop", &"route_a", [Vector3i(2, 0, 0)]) + var snapshot := adapter.create_snapshot() + _expect( + snapshot[&"processed_requests"].map(func(row: Dictionary) -> String: return row[&"request_id"]) == ["a_place", "b_sync_base", "m_noop", "z_place"], + "snapshot request history is lexical" + ) + var restored_source := SpatialType.new() + _expect(restored_source.restore_snapshot(source.create_snapshot()), "snapshot test restores a matching Spatial source") + var restored_path := PathType.new() + var restored := AdapterType.new() + _expect(restored.configure(restored_source, restored_path, base), "snapshot test configures matching dependencies") + _expect(restored.restore_snapshot(snapshot), "strict current adapter snapshot restores") + _expect(restored.create_snapshot() == snapshot, "adapter snapshot round trip is exact") + _expect(restored.validate_invariants()[&"ok"], "restored adapter snapshot crosslinks are valid") + var replay = restored.place_route(&"z_place", &"route_z", [Vector3i(1, 0, 0)]) + _expect(replay.is_success() and replay.is_replay, "restored idempotency history replays exactly") + var before := restored.create_snapshot() + var source_before := restored_source.create_snapshot() + var path_before := restored_path.create_snapshot() + + var version_type := snapshot.duplicate(true) + version_type[&"schema_version"] = "1" + _atomic_snapshot_reject(restored, restored_source, restored_path, version_type, before, source_before, path_before, "string schema version") + var unknown := snapshot.duplicate(true) + unknown[&"unknown"] = true + _atomic_snapshot_reject(restored, restored_source, restored_path, unknown, before, source_before, path_before, "unknown root field") + var source_revision_type := snapshot.duplicate(true) + source_revision_type[&"source_revision"] = float(source_revision_type[&"source_revision"]) + _atomic_snapshot_reject(restored, restored_source, restored_path, source_revision_type, before, source_before, path_before, "floating source revision") + var projection_bound := snapshot.duplicate(true) + projection_bound[&"projection_revision"] = 0 + _atomic_snapshot_reject(restored, restored_source, restored_path, projection_bound, before, source_before, path_before, "zero projection revision") + var projection_history_gap := snapshot.duplicate(true) + projection_history_gap[&"projection_revision"] = int(projection_history_gap[&"projection_revision"]) + 1 + _atomic_snapshot_reject(restored, restored_source, restored_path, projection_history_gap, before, source_before, path_before, "projection revision without request evidence") + var unordered_base := snapshot.duplicate(true) + unordered_base[&"base_walkable_cells"].reverse() + _atomic_snapshot_reject(restored, restored_source, restored_path, unordered_base, before, source_before, path_before, "unordered base cells") + var duplicate_route := snapshot.duplicate(true) + duplicate_route[&"projected_route_cells"].append(duplicate_route[&"projected_route_cells"][0]) + _atomic_snapshot_reject(restored, restored_source, restored_path, duplicate_route, before, source_before, path_before, "duplicate projected route cell") + var broken_union := snapshot.duplicate(true) + broken_union[&"projected_walkable_cells"].remove_at(1) + _atomic_snapshot_reject(restored, restored_source, restored_path, broken_union, before, source_before, path_before, "broken projected union") + var quarantine_reason := snapshot.duplicate(true) + quarantine_reason[&"quarantine_reason"] = "UNKNOWN_REASON" + _atomic_snapshot_reject(restored, restored_source, restored_path, quarantine_reason, before, source_before, path_before, "orphan quarantine reason") + var request_order := snapshot.duplicate(true) + request_order[&"processed_requests"].reverse() + _atomic_snapshot_reject(restored, restored_source, restored_path, request_order, before, source_before, path_before, "unordered request history") + var request_extra := snapshot.duplicate(true) + request_extra[&"processed_requests"][0][&"extra"] = true + _atomic_snapshot_reject(restored, restored_source, restored_path, request_extra, before, source_before, path_before, "unknown request field") + var fingerprint := snapshot.duplicate(true) + fingerprint[&"processed_requests"][0][&"fingerprint"] = "tampered" + _atomic_snapshot_reject(restored, restored_source, restored_path, fingerprint, before, source_before, path_before, "request fingerprint tamper") + var sync_input := snapshot.duplicate(true) + sync_input[&"processed_requests"][1][&"input_cells"] = [Vector3i(9, 0, 0)] + _atomic_snapshot_reject(restored, restored_source, restored_path, sync_input, before, source_before, path_before, "synchronization base fingerprint tamper") + var detail_revision := snapshot.duplicate(true) + detail_revision[&"processed_requests"][0][&"details"][&"projection_revision"] = int(snapshot[&"projection_revision"]) + 1 + _atomic_snapshot_reject(restored, restored_source, restored_path, detail_revision, before, source_before, path_before, "request result revision tamper") + var detail_cells := snapshot.duplicate(true) + detail_cells[&"processed_requests"][0][&"details"][&"cells"] = [Vector3i(8, 0, 0)] + _atomic_snapshot_reject(restored, restored_source, restored_path, detail_cells, before, source_before, path_before, "request result cell tamper") + var deep := snapshot.duplicate(true) + var deep_value: Variant = [] + for _index: int in range(adapter.MAX_SNAPSHOT_DEPTH + 2): + deep_value = [deep_value] + deep[&"processed_requests"] = deep_value + _atomic_snapshot_reject(restored, restored_source, restored_path, deep, before, source_before, path_before, "excessive snapshot depth") + + restored_path.rebuild_graph(base) + var stale_before := restored.create_snapshot() + _expect(not restored.restore_snapshot(snapshot), "valid metadata snapshot rejects mismatched live Path topology") + _expect(restored.create_snapshot() == stale_before, "live-crosslink rejection is adapter-atomic") + _expect(restored.synchronize_projection(&"repair_after_restore_reject").is_success(), "synchronization repairs live mismatch after restore rejection") + + +func _test_strict_input_and_route_only_boundary() -> void: + var invalid_adapter := AdapterType.new() + _expect(not invalid_adapter.configure(SpatialType.new(), PathType.new(), Vector3i.ZERO), "non-Array base cells are rejected") + _expect(not invalid_adapter.configure(SpatialType.new(), PathType.new(), [Vector3i.ZERO, Vector3i.ZERO]), "duplicate base cells are rejected") + _expect(not invalid_adapter.configure(SpatialType.new(), PathType.new(), [Vector3i(0, 0, 2)]), "invalid base layer is rejected") + + var source := SpatialType.new() + var path := PathType.new() + var adapter := AdapterType.new() + adapter.configure(source, path, [Vector3i.ZERO]) + _expect(adapter.is_bound_to(source, path), "adapter reports its exact authoritative service binding") + _expect(not adapter.is_bound_to(SpatialType.new(), path), "binding check rejects another equal Spatial instance") + _expect(not adapter.is_bound_to(source, PathType.new()), "binding check rejects another equal Path instance") + var before := adapter.create_snapshot() + _expect(not adapter.place_route(1, &"route", [Vector3i(1, 0, 0)]).is_success(), "numeric request ID is rejected") + _expect(not adapter.place_route(&"bad_entity", 1, [Vector3i(1, 0, 0)]).is_success(), "numeric entity ID is rejected") + _expect(not adapter.place_route(&"bad_cells", &"route", Vector3i(1, 0, 0)).is_success(), "non-Array route cells are rejected") + _expect(not adapter.place_route(&"empty_cells", &"route", []).is_success(), "empty route placement is rejected") + _expect(not adapter.place_route(&"duplicate_cells", &"route", [Vector3i(1, 0, 0), Vector3i(1, 0, 0)]).is_success(), "duplicate route cells are rejected") + _expect(not adapter.place_route(&"invalid_layer", &"route", [Vector3i(1, 0, 2)]).is_success(), "invalid route layer is rejected") + _expect(adapter.create_snapshot() == before, "malformed route requests preserve adapter state") + _expect(source.place(&"decor_only", source.CATEGORY_DECORATION, [Vector3i.ZERO]).is_success(), "DECORATION entity exists beside adapter boundary") + var source_before := source.create_snapshot() + var path_before := path.create_snapshot() + var wrong_category = adapter.remove_route(&"remove_decor", &"decor_only") + _expect(not wrong_category.is_success(), "adapter refuses to remove a non-ROUTE entity") + _expect(source.create_snapshot() == source_before and path.create_snapshot() == path_before, "route-only rejection leaves both services untouched") + _expect(not adapter.configure(source, path, [Vector3i.ZERO]), "configured adapter rejects reconfiguration") + + +func _assert_exact_rollback( + adapter: RouteSubslotPathAdapter, + source: SpatialSubslotService, + path: PathService, + adapter_before: Dictionary, + source_before: Dictionary, + path_before: Dictionary, + result: CommandResult, + label: String +) -> void: + _expect(not result.is_success() and bool(result.details.get(&"rollback_ok", false)), "%s reports exact rollback" % label) + _expect(adapter.create_snapshot() == adapter_before, "%s restores exact adapter snapshot" % label) + _expect(source.create_snapshot() == source_before, "%s restores exact Spatial snapshot" % label) + _expect(path.create_snapshot() == path_before, "%s restores exact Path snapshot" % label) + + +func _atomic_snapshot_reject( + adapter: RouteSubslotPathAdapter, + source: SpatialSubslotService, + path: PathService, + candidate: Dictionary, + adapter_before: Dictionary, + source_before: Dictionary, + path_before: Dictionary, + label: String +) -> void: + _expect(not adapter.restore_snapshot(candidate), "%s is rejected" % label) + _expect(adapter.create_snapshot() == adapter_before, "%s rejection is adapter-atomic" % label) + _expect(source.create_snapshot() == source_before and path.create_snapshot() == path_before, "%s rejection leaves dependencies untouched" % label) + + +func _restore_path_for_test(path: PathService, cells: Array, snapshot: Dictionary) -> void: + path.rebuild_graph(cells) + path.restore_snapshot(snapshot) + path.rebuild_graph(cells) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_route_subslot_path_adapter.gd.uid b/tests/s1/test_route_subslot_path_adapter.gd.uid new file mode 100644 index 0000000..2f1333a --- /dev/null +++ b/tests/s1/test_route_subslot_path_adapter.gd.uid @@ -0,0 +1 @@ +uid://bigl6r8i1b5cl diff --git a/tests/s1/test_runtime_infrastructure_integration.gd b/tests/s1/test_runtime_infrastructure_integration.gd new file mode 100644 index 0000000..d8b1f37 --- /dev/null +++ b/tests/s1/test_runtime_infrastructure_integration.gd @@ -0,0 +1,1233 @@ +extends SceneTree + +const RUNTIME_SNAPSHOT_VERSION := 6 +const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ + &"excavation", + &"spatial_subslots", + &"route_projection", + &"facility_infrastructure", +] +const INFRASTRUCTURE_MEMBER_NAMES: Array[StringName] = [ + &"excavation_service", + &"spatial_subslot_service", + &"route_subslot_path_adapter", + &"facility_infrastructure_projector", +] + +const PathType = preload("res://scripts/world/path_service.gd") + + +class DenyingOperationsCoordinator: + extends OperationsCoordinator + + func can_start_construction_or_investment() -> Dictionary: + return { + &"allowed": false, + &"reason": &"ARREARS_REMAIN", + &"arrears_total": 1, + &"consecutive_insolvency_count": 1, + } + + +class SnapshotCountingDomainEventStream: + extends DomainEventStream + + var snapshot_calls := 0 + + func create_snapshot() -> Dictionary: + snapshot_calls += 1 + return super.create_snapshot() + + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: Runtime infrastructure integration") + var probe := _new_fixture(81_001) + var surface_ready := _test_runtime_surface(probe) + _free_fixture(probe) + if surface_ready: + _test_completion_capture_fast_path() + _test_rank_and_operations_gates() + _test_excavation_tick_pause_resume_and_completion() + _test_completed_proxy_projection() + _test_proxy_completion_raw_signal_commit_order() + _test_proxy_completion_projection_failure_is_atomic() + _test_vertical_preflight_rejection() + _test_demolition_projection_atomicity() + _test_v6_snapshot_strictness() + _test_v5_proxy_migration_determinism() + _test_rank_expansion_preserves_route_union() + _finish() + + +func _finish() -> void: + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _new_fixture(seed: int) -> Dictionary: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + var initialized: Dictionary = runtime.initialize(clock, state, seed) + clock.set_paused(true) + _expect(bool(initialized.get(&"valid", false)), "fixture Runtime initializes") + return {&"clock": clock, &"state": state, &"runtime": runtime} + + +func _free_fixture(fixture: Dictionary) -> void: + var runtime: Variant = fixture.get(&"runtime") + var clock: Variant = fixture.get(&"clock") + var state: Variant = fixture.get(&"state") + if runtime != null: + runtime.free() + if clock != null: + clock.free() + if state != null: + state.free() + + +func _test_runtime_surface(fixture: Dictionary) -> bool: + var runtime: Variant = fixture[&"runtime"] + var ready := true + for member_name: StringName in INFRASTRUCTURE_MEMBER_NAMES: + var present := _object_has_property(runtime, member_name) + _expect(present, "Runtime publishes %s" % member_name) + ready = ready and present + if present: + var service: Variant = runtime.get(member_name) + _expect(service != null, "%s is initialized" % member_name) + ready = ready and service != null + var snapshot: Dictionary = runtime.create_snapshot() + var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] + var v6 := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION + _expect(v6, "Runtime emits infrastructure snapshot v6") + ready = ready and v6 + for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: + var valid_dictionary := runtime_data.get(key, null) is Dictionary + _expect(valid_dictionary, "v6 stores strict %s state" % key) + ready = ready and valid_dictionary + if not ready: + print(" PENDING: Runtime v6 infrastructure surface is not connected yet; behavioral cases skipped.") + return ready + + +func _test_completion_capture_fast_path() -> void: + var fixture := _new_fixture(81_005) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var counting_events := SnapshotCountingDomainEventStream.new() + _expect( + counting_events.restore_snapshot(runtime.domain_events.create_snapshot()), + "fast-path probe preserves the authoritative DomainEvent stream" + ) + runtime.domain_events = counting_events + counting_events.snapshot_calls = 0 + clock.advance_minutes(1) + _expect( + counting_events.snapshot_calls == 1, + "idle Construction tick performs only the normal publish snapshot capture" + ) + _expect( + runtime.construction_service.current_minute == clock.absolute_minute + and runtime.excavation_service.current_minute == clock.absolute_minute, + "idle fast path keeps Construction and Excavation on the exact Runtime minute" + ) + + var started = _submit( + runtime, + &"build_facility", + { + &"definition_id": &"guest_corridor", + &"origin": Vector3i(0, 1, PathType.FRONT), + &"rotation": 0, + }, + &"infra.fast_path.progress" + ) + _expect(started.is_success(), "fast-path probe starts a non-completing Construction job") + if not started.is_success(): + _free_fixture(fixture) + return + var job_id := StringName(started.details.get(&"job_id", &"")) + var raw_job_changed_count := [0] + runtime.construction_service.job_changed.connect( + func(changed_job: ConstructionJob) -> void: + if changed_job != null and changed_job.job_id == job_id: + raw_job_changed_count[0] += 1 + ) + counting_events.snapshot_calls = 0 + clock.advance_minutes(1) + var job: ConstructionJob = runtime.construction_service.get_job(job_id) + _expect( + job != null and job.state == ConstructionJob.State.BUILDING + and job.progress > 0 and job.progress < ConstructionJob.MAX_PROGRESS, + "ordinary Construction progress remains on the non-completion fast path" + ) + _expect( + counting_events.snapshot_calls == 1, + "non-completing Construction progress avoids the full completion token" + ) + _expect( + raw_job_changed_count[0] == 1, + "non-completion fast path preserves the raw job_changed notification" + ) + _expect( + runtime.construction_service.current_minute == clock.absolute_minute + and runtime.excavation_service.current_minute == clock.absolute_minute, + "progress fast path keeps both minute-driven infrastructure services synchronized" + ) + _free_fixture(fixture) + + +func _test_rank_and_operations_gates() -> void: + var fixture := _new_fixture(81_010) + var runtime: Variant = fixture[&"runtime"] + var target := Vector3i(0, -1, PathType.FRONT) + var before_rank_gate := _infrastructure_state(fixture) + var rank_rejected = _submit( + runtime, &"start_excavation", {&"cell": target}, &"infra.rank.reject" + ) + _expect(not rank_rejected.is_success(), "Rank 1 cannot start excavation") + _expect( + _infrastructure_state(fixture) == before_rank_gate, + "Rank rejection changes no infrastructure or resource state" + ) + _expect(_promote_rank_two(fixture, &"infra.rank"), "fixture reaches Rank 2 through public commands") + _free_fixture(fixture) + + fixture = _new_fixture(81_011) + runtime = fixture[&"runtime"] + _expect(_promote_rank_two(fixture, &"infra.operations.rank"), "Operations-gate fixture reaches Rank 2") + var enabled: Dictionary = runtime.enable_production_operations() + _expect(bool(enabled.get(&"ok", false)), "Operations enable before gate probe") + # A deterministic coordinator test double exercises the public gate result + # without advancing a full payroll day or mutating Runtime private helpers. + runtime.operations_coordinator = DenyingOperationsCoordinator.new() + var before_operations_gate := _infrastructure_state(fixture) + var gate_rejected = _submit( + runtime, &"start_excavation", {&"cell": target}, &"infra.operations.reject" + ) + _expect( + not gate_rejected.is_success() + and gate_rejected.details.get(&"reason", &"") == &"ARREARS_REMAIN", + "excavation uses the Operations construction/investment gate" + ) + _expect( + _infrastructure_state(fixture) == before_operations_gate, + "Operations gate rejection is infrastructure-atomic" + ) + _free_fixture(fixture) + + +func _test_excavation_tick_pause_resume_and_completion() -> void: + var fixture := _new_fixture(81_020) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + _expect(_promote_rank_two(fixture, &"infra.excavation.rank"), "excavation fixture reaches Rank 2") + var target := Vector3i(0, -1, PathType.FRONT) + var started = _submit( + runtime, &"start_excavation", {&"cell": target}, &"infra.excavation.start" + ) + _expect(started.is_success(), "start_excavation accepts a cell payload") + if not started.is_success(): + _free_fixture(fixture) + return + var excavation_job_id := StringName(started.details.get(&"job_id", &"")) + var excavation_events_before := _domain_event_count(runtime, &"EXCAVATION_COMPLETED") + var build = _submit( + runtime, + &"build_facility", + { + &"definition_id": &"guest_corridor", + &"origin": Vector3i(-4, 1, PathType.FRONT), + &"rotation": 0, + }, + &"infra.excavation.tick.build" + ) + _expect(build.is_success(), "construction comparison job starts in the same Runtime minute") + var construction_job_id := StringName(build.details.get(&"job_id", &"")) + clock.advance_minutes(5) + var excavation_job: Dictionary = runtime.excavation_service.get_job(excavation_job_id) + var construction_job := _construction_job_snapshot(runtime, construction_job_id) + _expect( + int(excavation_job.get(&"progress", 0)) > 0 + and int(construction_job.get(&"progress", 0)) > 0, + "excavation and construction both progress through Runtime ticks" + ) + _expect( + int(excavation_job.get(&"last_progress_at", -1)) == clock.absolute_minute + and int(construction_job.get(&"last_progress_at", -2)) == clock.absolute_minute + and runtime.excavation_service.current_minute == runtime.construction_service.current_minute, + "excavation advances in the exact Construction phase minute" + ) + + var pause = _submit( + runtime, + &"pause_excavation", + {&"job_id": excavation_job_id}, + &"infra.excavation.pause" + ) + var paused_progress := int(runtime.excavation_service.get_job(excavation_job_id)[&"progress"]) + _expect(pause.is_success(), "pause_excavation accepts a job_id payload") + clock.advance_minutes(4) + _expect( + int(runtime.excavation_service.get_job(excavation_job_id)[&"progress"]) == paused_progress, + "paused Runtime ticks retain excavation progress exactly" + ) + var paused_snapshot: Dictionary = runtime.create_snapshot() + _expect( + runtime.restore_snapshot(paused_snapshot) + and runtime.create_snapshot() == paused_snapshot, + "paused excavation survives an exact Runtime v6 save/restore" + ) + _expect( + runtime.excavation_service.get_job(excavation_job_id).get(&"state", &"") + == runtime.excavation_service.STATE_PAUSED + and int(runtime.excavation_service.get_job(excavation_job_id).get(&"progress", -1)) + == paused_progress, + "restored excavation retains its paused state and accumulated work" + ) + var resume = _submit( + runtime, + &"resume_excavation", + {&"job_id": excavation_job_id}, + &"infra.excavation.resume" + ) + _expect(resume.is_success(), "resume_excavation accepts the same job_id") + clock.advance_minutes(24) + excavation_job = runtime.excavation_service.get_job(excavation_job_id) + _expect( + excavation_job.get(&"state", &"") == runtime.excavation_service.STATE_ACTIVE + and int(excavation_job.get(&"work_units_done", -1)) + == 29 * runtime.excavation_service.NORMAL_WORK_RATE + and int(excavation_job.get(&"progress", -1)) < runtime.excavation_service.MAX_PROGRESS + and not runtime.world_service.is_excavated(target) + and _domain_event_count(runtime, &"EXCAVATION_COMPLETED") == excavation_events_before, + "hardness-0 excavation remains incomplete at the exact 29-minute boundary" + ) + clock.advance_minutes(1) + excavation_job = runtime.excavation_service.get_job(excavation_job_id) + _expect( + excavation_job.get(&"state", &"") == runtime.excavation_service.STATE_COMPLETED + and runtime.world_service.is_excavated(target) + and runtime.world_service.is_excavated(Vector3i(target.x, target.y, PathType.BACK)), + "the 30th minute completes FRONT and BACK excavation together" + ) + _expect( + _domain_event_count(runtime, &"EXCAVATION_COMPLETED") == excavation_events_before + 1 + and _saw_domain_event(runtime, &"EXCAVATION_COMPLETED", excavation_job_id), + "the 30-minute boundary publishes exactly one excavation completion event" + ) + clock.advance_minutes(3) + _expect( + _domain_event_count(runtime, &"EXCAVATION_COMPLETED") == excavation_events_before + 1, + "later Runtime ticks cannot duplicate excavation completion" + ) + _free_fixture(fixture) + + +func _test_completed_proxy_projection() -> void: + var fixture := _new_fixture(81_030) + var runtime: Variant = fixture[&"runtime"] + _expect(_promote_rank_two(fixture, &"infra.proxy.rank"), "proxy fixture reaches Rank 2") + var front_a := Vector3i(0, -1, PathType.FRONT) + var front_b := Vector3i(1, -1, PathType.FRONT) + _expect(not _complete_excavation(fixture, front_a, &"infra.proxy.dig.a").is_empty(), "first proxy cell is excavated") + _expect(not _complete_excavation(fixture, front_b, &"infra.proxy.dig.b").is_empty(), "adjacent proxy cell is excavated") + runtime.world_service.set_support(front_a, 10, true) + runtime.world_service.set_support(Vector3i(0, -1, PathType.BACK), 10, true) + runtime.world_service.set_support(Vector3i(1, -1, PathType.BACK), 10, true) + + var guest := _complete_proxy( + fixture, &"guest_corridor", front_a, &"infra.proxy.guest" + ) + var service := _complete_proxy( + fixture, + &"service_corridor", + Vector3i(1, -1, PathType.BACK), + &"infra.proxy.service" + ) + var door := _complete_proxy( + fixture, + &"staff_door", + Vector3i(0, -1, PathType.BACK), + &"infra.proxy.door" + ) + _expect(guest[&"ok"] and service[&"ok"] and door[&"ok"], "all three supported proxy kinds complete") + if not guest[&"ok"] or not service[&"ok"] or not door[&"ok"]: + _free_fixture(fixture) + return + var guest_id: StringName = guest[&"instance_id"] + var service_id: StringName = service[&"instance_id"] + var door_id: StringName = door[&"instance_id"] + _expect( + runtime.facility_infrastructure_projector.has_projection(guest_id) + and runtime.facility_infrastructure_projector.has_projection(service_id) + and runtime.facility_infrastructure_projector.has_projection(door_id), + "completed proxy facilities project immediately" + ) + _expect( + not runtime.facility_service.has_instance(guest_id) + and not runtime.facility_service.has_instance(service_id) + and not runtime.facility_service.has_instance(door_id), + "schema proxy facilities never enter operational FacilityService" + ) + var guest_projection: Dictionary = runtime.facility_infrastructure_projector.get_projection(guest_id) + var service_projection: Dictionary = runtime.facility_infrastructure_projector.get_projection(service_id) + var door_projection: Dictionary = runtime.facility_infrastructure_projector.get_projection(door_id) + _expect( + guest_projection[&"route_entity_id"] == StringName("infra.route:%s" % guest_id) + and service_projection[&"route_entity_id"] == StringName("infra.route:%s" % service_id) + and door_projection[&"connector_id"] == StringName("infra.connector:%s" % door_id), + "Runtime projection retains stable managed IDs" + ) + var entrance := Vector3i(0, 0, PathType.FRONT) + var door_back := Vector3i(0, -1, PathType.BACK) + var service_back := Vector3i(1, -1, PathType.BACK) + _expect( + runtime.path_service.find_path(entrance, front_a) == [entrance, front_a], + "guest corridor becomes a real FRONT Path edge" + ) + _expect( + runtime.path_service.find_path( + front_a, + service_back, + [PathType.FRONT, PathType.BACK], + [&"staff"] + ) == [front_a, door_back, service_back], + "staff door and service corridor form a real staff-only Path" + ) + _expect( + runtime.path_service.find_path( + front_a, + service_back, + [PathType.FRONT, PathType.BACK], + [] + ).is_empty(), + "guest access cannot traverse the staff-only projection" + ) + _expect( + runtime.world_service.get_occupant(front_a) == guest_id + and runtime.world_service.get_occupant(service_back) == service_id + and runtime.world_service.get_occupant(door_back) == door_id, + "proxy ROUTEs coexist with authoritative primary World occupancy" + ) + _expect( + runtime.spatial_subslot_service.validate_invariants()[&"ok"] + and runtime.route_subslot_path_adapter.validate_invariants()[&"ok"] + and runtime.facility_infrastructure_projector.validate_invariants()[&"ok"], + "completed proxy projection satisfies every infrastructure invariant" + ) + _free_fixture(fixture) + + +func _test_proxy_completion_raw_signal_commit_order() -> void: + var fixture := _new_fixture(81_034) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var started = _submit( + runtime, + &"build_facility", + { + &"definition_id": &"guest_corridor", + &"origin": Vector3i(0, 1, PathType.FRONT), + &"rotation": 0, + }, + &"infra.proxy.signals.start" + ) + _expect(started.is_success(), "signal-order fixture starts a proxy job") + if not started.is_success(): + _free_fixture(fixture) + return + var job_id := StringName(started.details.get(&"job_id", &"")) + var job: ConstructionJob = runtime.construction_service.get_job(job_id) + if job == null: + _expect(false, "signal-order fixture exposes its Construction job") + _free_fixture(fixture) + return + clock.advance_minutes(maxi(job.build_minutes - 1, 0)) + var raw_signal_order: Array[StringName] = [] + runtime.construction_service.job_changed.connect( + func(changed_job: ConstructionJob) -> void: + if changed_job != null and changed_job.job_id == job_id: + raw_signal_order.append(&"job_changed") + ) + runtime.construction_service.facility_completed.connect( + func(instance: Dictionary) -> void: + if StringName(instance.get(&"source_job_id", &"")) == job_id: + raw_signal_order.append(&"facility_completed") + ) + var completed_events_before := _domain_event_count(runtime, &"FACILITY_COMPLETED") + var projected_events_before := _domain_event_count(runtime, &"FACILITY_INFRASTRUCTURE_PROJECTED") + clock.advance_minutes(1) + job = runtime.construction_service.get_job(job_id) + _expect( + job != null + and job.state == ConstructionJob.State.COMPLETED + and runtime.facility_infrastructure_projector.has_projection(job.facility_instance_id), + "successful proxy completion commits Construction and projection together" + ) + _expect( + raw_signal_order == [&"job_changed", &"facility_completed"], + "successful completion publishes raw job_changed then facility_completed exactly once each" + ) + _expect( + _domain_event_count(runtime, &"FACILITY_COMPLETED") == completed_events_before + 1 + and _domain_event_count(runtime, &"FACILITY_INFRASTRUCTURE_PROJECTED") + == projected_events_before + 1, + "successful proxy completion publishes each committed DomainEvent exactly once" + ) + _free_fixture(fixture) + + +func _test_proxy_completion_projection_failure_is_atomic() -> void: + var fixture := _new_fixture(81_035) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + var started = _submit( + runtime, + &"build_facility", + { + &"definition_id": &"guest_corridor", + &"origin": Vector3i(0, 1, PathType.FRONT), + &"rotation": 0, + }, + &"infra.proxy.failure.start" + ) + _expect(started.is_success(), "projection-failure fixture starts a proxy job") + if not started.is_success(): + _free_fixture(fixture) + return + var job_id := StringName(started.details.get(&"job_id", &"")) + var job: Variant = runtime.construction_service.get_job(job_id) + if job == null: + _expect(false, "projection-failure fixture exposes its Construction job") + _free_fixture(fixture) + return + clock.advance_minutes(maxi(int(job.build_minutes) - 1, 0)) + job = runtime.construction_service.get_job(job_id) + _expect( + job != null + and job.state != ConstructionJob.State.COMPLETED + and job.progress > 0 + and job.progress < ConstructionJob.MAX_PROGRESS, + "proxy job reaches the exact minute before completion" + ) + var external_route_id := &"external_route_before_proxy_completion" + var made_stale = runtime.spatial_subslot_service.place( + external_route_id, + runtime.spatial_subslot_service.CATEGORY_ROUTE, + [Vector3i(5, 2, PathType.FRONT)] + ) + _expect( + made_stale.is_success() + and runtime.route_subslot_path_adapter.is_projection_stale(), + "public Spatial mutation makes the completion projector dependency stale" + ) + var before_failure := _completion_boundary_state(fixture) + var completed_events_before := _domain_event_count(runtime, &"FACILITY_COMPLETED") + var projected_events_before := _domain_event_count(runtime, &"FACILITY_INFRASTRUCTURE_PROJECTED") + var raw_completion_signals: Array[StringName] = [] + runtime.construction_service.job_changed.connect( + func(changed_job: ConstructionJob) -> void: + if changed_job != null and changed_job.job_id == job_id: + raw_completion_signals.append(&"job_changed") + ) + runtime.construction_service.facility_completed.connect( + func(instance: Dictionary) -> void: + if StringName(instance.get(&"source_job_id", &"")) == job_id: + raw_completion_signals.append(&"facility_completed") + ) + # A newly raised runtime failure must stop an initially-running batch. The + # test otherwise uses paused manual stepping so no wall-clock frame can race it. + clock.set_paused(false) + clock.advance_minutes(1) + var after_job: Variant = runtime.construction_service.get_job(job_id) + _expect( + _completion_boundary_state(fixture) == before_failure, + "failed completion restores Construction, World, Path, infrastructure, and resources exactly" + ) + _expect( + after_job != null + and after_job.state != ConstructionJob.State.COMPLETED + and after_job.progress < ConstructionJob.MAX_PROGRESS + and runtime.construction_service.get_facilities().is_empty() + and runtime.facility_infrastructure_projector.get_projection_ids().is_empty(), + "projection rejection leaves no completed facility or one-sided projection" + ) + _expect( + runtime.spatial_subslot_service.get_entity_ids( + runtime.spatial_subslot_service.CATEGORY_ROUTE + ) == [external_route_id] + and runtime.route_subslot_path_adapter.get_projected_route_cells().is_empty(), + "projection rejection retains only the pre-existing stale mutation and no managed ROUTE" + ) + _expect( + _domain_event_count(runtime, &"FACILITY_COMPLETED") == completed_events_before + and _domain_event_count(runtime, &"FACILITY_INFRASTRUCTURE_PROJECTED") == projected_events_before, + "projection rejection publishes no ghost completion or projection DomainEvent" + ) + _expect( + raw_completion_signals.is_empty(), + "projection rejection exposes no raw job_changed or facility_completed signal" + ) + var diagnostic := _runtime_failure_diagnostic(runtime) + _expect( + clock.paused + and bool(diagnostic.get(&"latched", false)) + and not String(diagnostic.get(&"reason", "")).is_empty(), + "projection rejection pauses Runtime with a public reason-bearing failure latch" + ) + _free_fixture(fixture) + + +func _test_vertical_preflight_rejection() -> void: + var fixture := _new_fixture(81_040) + var runtime: Variant = fixture[&"runtime"] + _expect(_promote_rank_two(fixture, &"infra.vertical.rank"), "vertical fixture reaches Rank 2") + var before := _infrastructure_state(fixture) + var guest = _submit( + runtime, + &"build_facility", + { + &"definition_id": &"guest_elevator", + &"origin": Vector3i(-6, 1, PathType.FRONT), + &"rotation": 0, + }, + &"infra.vertical.guest" + ) + var service = _submit( + runtime, + &"build_facility", + { + &"definition_id": &"service_elevator", + &"origin": Vector3i(5, 1, PathType.BACK), + &"rotation": 0, + }, + &"infra.vertical.service" + ) + _expect( + not guest.is_success() + and guest.details.get(&"reason", &"") == &"UNSUPPORTED_VERTICAL_TOPOLOGY", + "guest elevator is rejected at build-command preflight" + ) + _expect( + not service.is_success() + and service.details.get(&"reason", &"") == &"UNSUPPORTED_VERTICAL_TOPOLOGY", + "service elevator is rejected with the same stable reason" + ) + _expect( + _infrastructure_state(fixture) == before, + "unsupported vertical preflight spends nothing and mutates no service" + ) + _free_fixture(fixture) + + +func _test_demolition_projection_atomicity() -> void: + var fixture := _new_fixture(81_050) + var runtime: Variant = fixture[&"runtime"] + _expect(_promote_rank_two(fixture, &"infra.demolish.rank"), "demolition fixture reaches Rank 2") + var built := _complete_proxy( + fixture, + &"guest_corridor", + Vector3i(0, 1, PathType.FRONT), + &"infra.demolish.proxy" + ) + _expect(built[&"ok"], "demolition target proxy completes") + if not built[&"ok"]: + _free_fixture(fixture) + return + var instance_id: StringName = built[&"instance_id"] + var projection: Dictionary = runtime.facility_infrastructure_projector.get_projection(instance_id) + _expect( + projection.get(&"route_entity_id", null) is StringName, + "demolition fixture exposes the completed proxy projection" + ) + if not projection.get(&"route_entity_id", null) is StringName: + _free_fixture(fixture) + return + var managed_route: StringName = projection[&"route_entity_id"] + _expect( + runtime.spatial_subslot_service.place( + &"external_route", + runtime.spatial_subslot_service.CATEGORY_ROUTE, + [Vector3i(5, 2, PathType.FRONT)] + ).is_success(), + "public Spatial mutation creates a deterministic stale-dependency failure" + ) + var before_failure := _infrastructure_state(fixture) + var refunds_before := _ledger_category_count(runtime, &"CONSTRUCTION_REFUND") + var raw_demolished_ids: Array[StringName] = [] + runtime.construction_service.facility_demolished.connect( + func(demolished_id: StringName, _refund: Dictionary) -> void: + if demolished_id == instance_id: + raw_demolished_ids.append(demolished_id) + ) + var rejected = _submit( + runtime, + &"demolish_facility", + {&"instance_id": instance_id}, + &"infra.demolish.stale" + ) + _expect( + not rejected.is_success() + and not String(rejected.details.get(&"reason", "")).is_empty(), + "demolition rejects stale projection with a stable public reason" + ) + _expect( + _infrastructure_state(fixture) == before_failure, + "failed unprojection preserves projection, construction, World, and resources exactly" + ) + _expect( + raw_demolished_ids.is_empty() + and _ledger_category_count(runtime, &"CONSTRUCTION_REFUND") == refunds_before + and runtime.spatial_subslot_service.get_entity_cells(&"external_route") + == [Vector3i(5, 2, PathType.FRONT)], + "failed demolition emits no raw signal or refund and retains the external ROUTE" + ) + _expect( + runtime.route_subslot_path_adapter.synchronize_projection(&"infra.demolish.sync").is_success(), + "fixture repairs the stale route through the public adapter" + ) + var removed = _submit( + runtime, + &"demolish_facility", + {&"instance_id": instance_id}, + &"infra.demolish.commit" + ) + _expect(removed.is_success(), "proxy demolition commits after dependencies are fresh") + _expect( + not runtime.facility_infrastructure_projector.has_projection(instance_id) + and not runtime.spatial_subslot_service.has_entity(managed_route), + "demolition removes projector and Spatial projection together" + ) + _expect( + runtime.spatial_subslot_service.get_entity_cells(&"external_route") + == [Vector3i(5, 2, PathType.FRONT)] + and runtime.path_service.has_cell(Vector3i(5, 2, PathType.FRONT)), + "committed demolition preserves the unrelated external ROUTE projection" + ) + _expect( + runtime.construction_service.get_facility(instance_id).is_empty() + and runtime.world_service.get_entity_cells(instance_id).is_empty(), + "demolition removes Construction and primary World state together" + ) + _expect( + _saw_domain_event(runtime, &"FACILITY_DEMOLISHED", instance_id), + "committed proxy demolition publishes its domain event" + ) + _free_fixture(fixture) + + +func _test_v6_snapshot_strictness() -> void: + var fixture := _new_fixture(81_060) + var runtime: Variant = fixture[&"runtime"] + _expect(_promote_rank_two(fixture, &"infra.snapshot.rank"), "snapshot fixture reaches Rank 2") + var target := Vector3i(0, -1, PathType.FRONT) + var excavation_job_id := _complete_excavation(fixture, target, &"infra.snapshot.dig") + _expect(not excavation_job_id.is_empty(), "snapshot fixture stores completed excavation evidence") + if excavation_job_id.is_empty(): + _free_fixture(fixture) + return + var built := _complete_proxy( + fixture, + &"guest_corridor", + Vector3i(0, 1, PathType.FRONT), + &"infra.snapshot.proxy" + ) + _expect(built[&"ok"], "snapshot fixture stores one completed projection") + var snapshot: Dictionary = runtime.create_snapshot() + var expected_digest := SimulationDigest.compute(snapshot) + _expect(runtime.restore_snapshot(snapshot), "valid Runtime v6 snapshot restores") + _expect( + runtime.create_snapshot() == snapshot and runtime.latest_digest == expected_digest, + "Runtime v6 round trip is exact" + ) + + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"][&"unknown_infrastructure"] = {}, + "unknown v6 Runtime field" + ) + for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"].erase(key), + "missing %s field" % key + ) + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"][key] = [], + "non-Dictionary %s field" % key + ) + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"][key][&"unknown"] = true, + "unknown nested %s field" % key + ) + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"][&"route_projection"][&"projected_route_cells"].clear(), + "Spatial-to-adapter route crosslink tamper" + ) + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"][&"facility_infrastructure"][&"projections"].clear(), + "projector-to-managed-route crosslink tamper" + ) + _assert_runtime_restore_rejected( + runtime, + snapshot, + func(candidate: Dictionary) -> void: + candidate[&"systems"][&"runtime"][&"excavation"][&"jobs"][0][&"position"] = [2, -2], + "excavation-to-World completion crosslink tamper" + ) + _free_fixture(fixture) + + +func _test_v5_proxy_migration_determinism() -> void: + var source := _new_fixture(81_070) + var runtime: Variant = source[&"runtime"] + var built := _complete_proxy( + source, + &"guest_corridor", + Vector3i(0, 1, PathType.FRONT), + &"infra.legacy.proxy" + ) + _expect(built[&"ok"], "legacy fixture creates a completed proxy") + if not built[&"ok"]: + _free_fixture(source) + return + var instance_id: StringName = built[&"instance_id"] + var construction_instance: Dictionary = runtime.construction_service.get_facility(instance_id) + var legacy_registered := _register_legacy_operational_proxy(runtime, construction_instance) + _expect( + legacy_registered, + "legacy fixture reproduces the v5 operational-proxy shape" + ) + if not legacy_registered: + _free_fixture(source) + return + var baseline := _new_fixture(81_070) + var legacy: Dictionary = runtime.create_snapshot() + var legacy_runtime: Dictionary = legacy[&"systems"][&"runtime"] + var stripped_v6_events := _strip_v6_infrastructure_domain_history(legacy_runtime) + legacy_runtime[&"runtime_schema_version"] = 5 + for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: + legacy_runtime.erase(key) + legacy_runtime[&"path"] = baseline[&"runtime"].path_service.create_snapshot() + _free_fixture(baseline) + _expect( + stripped_v6_events > 0 and _legacy_v5_infrastructure_shape_is_clean(legacy_runtime), + "v5 fixture removes v6-only aggregate and DomainEvent history instead of relabelling v6" + ) + if not _legacy_v5_infrastructure_shape_is_clean(legacy_runtime): + _free_fixture(source) + return + + var first_target := _new_fixture(81_071) + var first_runtime: Variant = first_target[&"runtime"] + _expect(first_runtime.restore_snapshot(legacy), "v5 proxy snapshot migrates through Runtime restore") + var first_migrated: Dictionary = first_runtime.create_snapshot() + _expect( + int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6, + "v5 migration is immediately re-emitted as v6" + ) + _expect( + first_runtime.facility_infrastructure_projector.has_projection(instance_id) + and first_runtime.path_service.has_cell(Vector3i(0, 1, PathType.FRONT)), + "v5 construction proxy is deterministically reprojected into real Path" + ) + _expect( + not first_runtime.facility_service.has_instance(instance_id), + "v5 operational proxy is removed from FacilityService during migration" + ) + + var second_target := _new_fixture(81_072) + var second_runtime: Variant = second_target[&"runtime"] + _expect(second_runtime.restore_snapshot(legacy), "the same v5 proxy snapshot migrates a second time") + _expect( + second_runtime.create_snapshot() == first_migrated, + "v5 proxy reprojection is deterministic across Runtime instances" + ) + _free_fixture(second_target) + _free_fixture(first_target) + _free_fixture(source) + + +func _test_rank_expansion_preserves_route_union() -> void: + var fixture := _new_fixture(81_080) + var runtime: Variant = fixture[&"runtime"] + var route_cell := Vector3i(0, 1, PathType.FRONT) + var built := _complete_proxy( + fixture, + &"guest_corridor", + route_cell, + &"infra.expansion.proxy" + ) + _expect(built[&"ok"] and runtime.path_service.has_cell(route_cell), "Rank 1 fixture has a projected route outside base walkability") + if not built[&"ok"] or not runtime.path_service.has_cell(route_cell): + _free_fixture(fixture) + return + _expect(_promote_rank_two(fixture, &"infra.expansion.rank"), "rank expansion commits with an existing route") + _expect( + runtime.route_subslot_path_adapter.get_projected_route_cells().has(route_cell) + and runtime.path_service.has_cell(route_cell), + "rank expansion preserves projected ROUTE cells" + ) + _expect( + _same_cells( + runtime.route_subslot_path_adapter.get_base_walkable_cells(), + runtime.world_service.get_walkable_cells() + ), + "rank expansion replaces the adapter base with all expanded World walkability" + ) + _expect( + runtime.path_service.find_path( + Vector3i(0, 0, PathType.FRONT), route_cell + ) == [Vector3i(0, 0, PathType.FRONT), route_cell], + "expanded base plus pre-existing route remains reachable" + ) + _free_fixture(fixture) + + +func _promote_rank_two(fixture: Dictionary, prefix: StringName) -> bool: + var runtime: Variant = fixture[&"runtime"] + var reputation = _submit( + runtime, + &"update_progression_metrics", + {&"metrics": {&"reputation": 25}}, + StringName("%s.reputation" % prefix) + ) + var capital = _submit( + runtime, + &"change_resources", + { + &"deltas": { + &"gold": maxi( + 10_000 - runtime.economy_service.get_balance(&"PLAYER_CASH"), + 0 + ), + }, + }, + StringName("%s.capital" % prefix) + ) + var promoted = _submit( + runtime, + &"set_world_tree_rank", + {&"rank": 2}, + StringName("%s.promote" % prefix) + ) + return reputation.is_success() and capital.is_success() and promoted.is_success() \ + and fixture[&"state"].world_tree_rank == 2 + + +func _complete_excavation( + fixture: Dictionary, + cell: Vector3i, + key: StringName +) -> StringName: + var runtime: Variant = fixture[&"runtime"] + var result = _submit(runtime, &"start_excavation", {&"cell": cell}, key) + if not result.is_success(): + return &"" + var job_id := StringName(result.details.get(&"job_id", &"")) + var job: Dictionary = runtime.excavation_service.get_job(job_id) + fixture[&"clock"].advance_minutes(int(job.get(&"build_minutes", 0))) + return job_id if runtime.excavation_service.get_job(job_id).get(&"state", &"") \ + == runtime.excavation_service.STATE_COMPLETED else &"" + + +func _complete_proxy( + fixture: Dictionary, + definition_id: StringName, + origin: Vector3i, + key: StringName +) -> Dictionary: + var runtime: Variant = fixture[&"runtime"] + var result = _submit( + runtime, + &"build_facility", + {&"definition_id": definition_id, &"origin": origin, &"rotation": 0}, + key + ) + if not result.is_success(): + return {&"ok": false, &"result": result} + var job_id := StringName(result.details.get(&"job_id", &"")) + var job: Variant = runtime.construction_service.get_job(job_id) + if job == null: + return {&"ok": false, &"result": result} + fixture[&"clock"].advance_minutes(int(job.build_minutes)) + return { + &"ok": job.state == ConstructionJob.State.COMPLETED, + &"job_id": job_id, + &"instance_id": job.facility_instance_id, + &"result": result, + } + + +func _register_legacy_operational_proxy(runtime: Variant, instance: Dictionary) -> bool: + var definition: Variant = runtime.definition_registry.get_facility( + StringName(instance.get(&"definition_id", &"")) + ) + if definition == null: + return false + var instance_id := StringName(instance.get(&"instance_id", &"")) + # While this acceptance test straddles the v5/v6 boundary, an older Runtime + # may already have registered the proxy through its completion callback. A + # completed v6 Runtime deliberately will not, so construct the same legacy + # aggregate only when it is absent. + if runtime.facility_service.has_instance(instance_id): + return true + runtime.economy_service.register_account(StringName("FACILITY_REVENUE:%s" % instance_id)) + var result = runtime.facility_service.create_instance({ + &"instance_id": instance_id, + &"definition_id": definition.id, + &"origin": instance.get(&"origin", Vector3i.ZERO), + &"rotation": instance.get(&"rotation", 0), + &"occupied_cells": instance.get(&"occupied_cells", []), + &"local_inventory_id": StringName("facility.inventory:%s" % instance_id), + &"capacity": maxi(definition.capacity, 1), + &"queue_capacity": definition.queue_capacity, + &"base_service_minutes": definition.base_service_minutes, + &"base_quality": definition.base_quality, + &"base_price": definition.base_price, + &"staff_min": definition.staff_min, + &"staff_max": definition.staff_max, + &"created_at": int(instance.get(&"completed_at", 0)), + }) + return result.is_success() + + +func _strip_v6_infrastructure_domain_history(runtime_data: Dictionary) -> int: + var stream_value: Variant = runtime_data.get(&"domain_events", null) + if not stream_value is Dictionary: + return 0 + var stream: Dictionary = stream_value + var recent_value: Variant = stream.get("recent_events", null) + var published_value: Variant = stream.get("published_ids", null) + if not recent_value is Array or not published_value is Array: + return 0 + var removed_ids: Dictionary = {} + var retained_recent: Array[Dictionary] = [] + for raw_event: Variant in recent_value: + if not raw_event is Dictionary: + return 0 + var event: Dictionary = raw_event + if StringName(event.get("event_type", "")) in [ + &"FACILITY_INFRASTRUCTURE_PROJECTED", + &"EXCAVATION_COMPLETED", + ]: + removed_ids[StringName(event.get("event_id", ""))] = true + else: + retained_recent.append(event.duplicate(true)) + var retained_published: Array[String] = [] + for raw_event_id: Variant in published_value: + var event_id := StringName(raw_event_id) + if not removed_ids.has(event_id): + retained_published.append(String(event_id)) + stream["recent_events"] = retained_recent + stream["published_ids"] = retained_published + return removed_ids.size() + + +func _legacy_v5_infrastructure_shape_is_clean(runtime_data: Dictionary) -> bool: + for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: + if runtime_data.has(key) or runtime_data.has(String(key)): + return false + var stream_value: Variant = runtime_data.get(&"domain_events", null) + if not stream_value is Dictionary: + return false + for raw_event: Variant in stream_value.get("recent_events", []): + if not raw_event is Dictionary: + return false + if StringName(raw_event.get("event_type", "")) in [ + &"FACILITY_INFRASTRUCTURE_PROJECTED", + &"EXCAVATION_COMPLETED", + ]: + return false + var queue_value: Variant = runtime_data.get(&"command_queue", null) + if not queue_value is Dictionary: + return false + for queue_key: String in ["pending", "in_flight"]: + for raw_command: Variant in queue_value.get(queue_key, []): + if not raw_command is Dictionary: + return false + if StringName(raw_command.get("command_type", "")) in [ + &"start_excavation", &"pause_excavation", &"resume_excavation", + ]: + return false + return true + + +func _construction_job_snapshot(runtime: Variant, job_id: StringName) -> Dictionary: + for row: Variant in runtime.construction_service.create_snapshot().get(&"jobs", []): + if row is Dictionary and StringName(row.get(&"job_id", &"")) == job_id: + return row.duplicate(true) + return {} + + +func _infrastructure_state(fixture: Dictionary) -> Dictionary: + var runtime: Variant = fixture[&"runtime"] + return { + &"game_state": fixture[&"state"].create_snapshot(), + &"economy": runtime.economy_service.create_snapshot(), + &"world": runtime.world_service.create_snapshot(), + &"path": runtime.path_service.create_snapshot(), + &"construction": runtime.construction_service.create_snapshot(), + &"excavation": runtime.excavation_service.create_snapshot(), + &"spatial_subslots": runtime.spatial_subslot_service.create_snapshot(), + &"route_projection": runtime.route_subslot_path_adapter.create_snapshot(), + &"facility_infrastructure": runtime.facility_infrastructure_projector.create_snapshot(), + &"facilities": runtime.facility_service.create_snapshot(), + &"mana": runtime.mana_network_service.create_snapshot(), + } + + +func _completion_boundary_state(fixture: Dictionary) -> Dictionary: + # MANA is an earlier Clock phase and has legitimately committed the new + # minute before Construction discovers a projection failure. The atomic + # completion boundary starts at Construction and owns the state below. + var state := _infrastructure_state(fixture) + state.erase(&"mana") + return state + + +func _assert_runtime_restore_rejected( + runtime: Variant, + source: Dictionary, + mutator: Callable, + label: String +) -> void: + var before: Dictionary = runtime.create_snapshot() + var candidate: Dictionary = source.duplicate(true) + mutator.call(candidate) + _expect(not runtime.restore_snapshot(candidate), "%s is rejected" % label) + _expect(runtime.create_snapshot() == before, "%s rejection is Runtime-atomic" % label) + + +func _saw_domain_event(runtime: Variant, event_type: StringName, entity_id: StringName) -> bool: + for event: SimulationDomainEvent in runtime.domain_events.recent_events(): + if event.event_type == event_type and entity_id in event.entity_ids: + return true + return false + + +func _domain_event_count(runtime: Variant, event_type: StringName) -> int: + var count := 0 + for event: SimulationDomainEvent in runtime.domain_events.recent_events(): + if event.event_type == event_type: + count += 1 + return count + + +func _ledger_category_count(runtime: Variant, category: StringName) -> int: + var count := 0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.category == category: + count += 1 + return count + + +func _runtime_failure_diagnostic(runtime: Variant) -> Dictionary: + var report_methods: Array[StringName] = [ + &"get_infrastructure_report", + &"get_operations_report", + ] + for method_name: StringName in report_methods: + if not runtime.has_method(method_name): + continue + var raw_report: Variant = runtime.call(method_name) + if not raw_report is Dictionary: + continue + var report: Dictionary = raw_report + var latched_value: Variant = report.get( + &"failure_latched", + report.get(&"runtime_failure_latched", null) + ) + var raw_failure: Variant = report.get( + &"failure_report", + report.get(&"runtime_failure_report", null) + ) + if typeof(latched_value) != TYPE_BOOL or not raw_failure is Dictionary: + continue + var failure: Dictionary = raw_failure + var reason := String(failure.get( + &"reason", + failure.get(&"code", failure.get(&"message", "")) + )) + return { + &"latched": bool(latched_value), + &"reason": reason, + &"failure": failure.duplicate(true), + } + return {} + + +func _submit( + runtime: Variant, + command_type: StringName, + payload: Dictionary, + idempotency_key: StringName +) -> Variant: + return runtime.submit_command( + command_type, + payload, + &"player", + &"", + 100, + -1, + idempotency_key + ) + + +func _object_has_property(value: Object, property_name: StringName) -> bool: + for property: Dictionary in value.get_property_list(): + if StringName(property.get(&"name", property.get("name", ""))) == property_name: + return true + return false + + +func _same_cells(first: Array, second: Array) -> bool: + if first.size() != second.size(): + return false + var first_set: Dictionary = {} + for cell: Variant in first: + first_set[cell] = true + for cell: Variant in second: + if not first_set.has(cell): + return false + return true + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_runtime_infrastructure_integration.gd.uid b/tests/s1/test_runtime_infrastructure_integration.gd.uid new file mode 100644 index 0000000..d228dd6 --- /dev/null +++ b/tests/s1/test_runtime_infrastructure_integration.gd.uid @@ -0,0 +1 @@ +uid://b4465rs47daij diff --git a/tests/s1/test_runtime_world_construction.gd b/tests/s1/test_runtime_world_construction.gd new file mode 100644 index 0000000..d25abeb --- /dev/null +++ b/tests/s1/test_runtime_world_construction.gd @@ -0,0 +1,188 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("S1: runtime world and construction integration") + _reset_runtime() + _test_world_bootstrap() + _test_rank_expansion() + _test_build_to_completion() + _test_overlap_is_atomic() + _test_mid_build_snapshot() + _test_cancel_and_demolish() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + +func _reset_runtime() -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 8080) + _clock.set_paused(true) + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + +func _test_world_bootstrap() -> void: + var bounds: Dictionary = _runtime.world_service.get_bounds() + _expect(bounds == {&"min_x": -12, &"max_x": 12, &"min_y": 0, &"max_y": 6}, "Rank 1 world uses canonical map bounds") + _expect(_runtime.world_service.is_walkable(Vector3i(-12, 0, WorldService.LAYER_FRONT)), "starter FRONT boundary corridor is walkable") + _expect(_runtime.world_service.is_support_anchor(Vector3i(0, 0, WorldService.LAYER_FRONT)), "starter boundary corridor is structurally anchored") + _expect(_runtime.path_service.has_connector(&"starter.staff_door"), "starter staff door explicitly connects FRONT and BACK") + +func _test_rank_expansion() -> void: + var reputation = _runtime.submit_command( + &"update_progression_metrics", + {&"metrics": {&"reputation": 25}}, + &"player", &"", 100, -1, &"s1.rank.reputation" + ) + var capital = _runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": 5_000}}, + &"player", &"", 100, -1, &"s1.rank.capital" + ) + _expect(reputation.is_success() and capital.is_success(), "Rank 2 eligibility facts are recorded before promotion") + var result = _runtime.submit_command( + &"set_world_tree_rank", + {&"rank": 2}, + &"player", &"", 100, -1, &"s1.rank.two" + ) + _expect(result.is_success() and _state.world_tree_rank == 2, "rank command expands progression exactly once") + _expect(_runtime.world_service.get_bounds() == {&"min_x": -18, &"max_x": 18, &"min_y": -4, &"max_y": 10}, "Rank 2 uses canonical expanded bounds") + _expect(_runtime.world_service.is_walkable(Vector3i(18, 0, WorldService.LAYER_FRONT)), "new boundary corridor cells become walkable") + _expect(not _runtime.world_service.is_excavated(Vector3i(0, -1, WorldService.LAYER_FRONT)), "new underground cells remain unexcavated") + var normalize_capital = _runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": -5_000}}, + &"player", &"", 100, -1, &"s1.rank.capital.normalize" + ) + _expect(normalize_capital.is_success() and _state.get_resource(&"gold") == 5_000, "Rank fixture restores the construction budget") + var rejected = _runtime.submit_command( + &"set_world_tree_rank", {&"rank": 1}, &"player", &"", 100, -1, &"s1.rank.shrink" + ) + _expect(not rejected.is_success(), "world rank and bounds cannot shrink") + +func _test_build_to_completion() -> void: + var result = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"reception", &"origin": Vector3i(-6, 0, WorldService.LAYER_FRONT), &"rotation": 0}, + &"player", &"", 50, -1, &"s1.build.reception" + ) + var build_label := "valid build command creates a construction job" + if not result.is_success(): + build_label += " (%s: %s; %s)" % [CommandResult.Code.keys()[result.code], result.message, result.details] + _expect(result.is_success(), build_label) + var job_id := StringName(result.details.get(&"job_id", &"")) + var job = _runtime.construction_service.get_job(job_id) + _expect(job != null and job.state == ConstructionJob.State.PLANNED, "new facility begins as PLANNED") + _expect(_state.get_resource(&"gold") == 4500 and _state.get_resource(&"materials") == 42, "construction cost is paid once") + _expect(not job_id.is_empty() and _runtime.world_service.get_occupant(Vector3i(-6, 0, 0)) == job_id, "construction job reserves its footprint") + _expect(_runtime.latest_digest.length() == 64, "build command leaves a serializable runtime snapshot") + if job == null: + return + _clock.advance_minutes(119) + _expect(job.state == ConstructionJob.State.BUILDING and job.progress < ConstructionJob.MAX_PROGRESS, "job remains BUILDING before its final minute") + _clock.advance_one_minute() + _expect(job.state == ConstructionJob.State.COMPLETED, "job completes at its configured build time") + var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities() + _expect(facilities.size() == 1, "completion creates one facility instance") + var instance_id := StringName(facilities[0][&"instance_id"]) + _expect(_runtime.world_service.get_occupant(Vector3i(-6, 0, 0)) == instance_id, "completion transfers world occupancy to the facility") + var saw_completion := false + for event in _runtime.domain_events.recent_events(): + if event.event_type == &"FACILITY_COMPLETED" and instance_id in event.entity_ids: + saw_completion = true + _expect(saw_completion, "facility completion is published as a domain event") + +func _test_overlap_is_atomic() -> void: + var before_resources: Dictionary = _state.create_snapshot() + var before_job_count: int = _runtime.construction_service.get_jobs().size() + var result = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"reception", &"origin": [-6, 0, 0], &"rotation": 0}, + &"player", &"", 50, -1, &"s1.build.overlap" + ) + _expect(not result.is_success(), "overlapping construction command is rejected") + _expect(_state.create_snapshot() == before_resources, "rejected placement spends no resource") + _expect(_runtime.construction_service.get_jobs().size() == before_job_count, "rejected placement creates no job") + +func _test_mid_build_snapshot() -> void: + var result = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"warehouse", &"origin": Vector3i(2, 0, WorldService.LAYER_BACK), &"rotation": 0}, + &"player", &"", 40, -1, &"s1.build.warehouse" + ) + _expect(result.is_success(), "BACK-layer warehouse construction starts") + var job_id := StringName(result.details[&"job_id"]) + _clock.advance_minutes(90) + var snapshot: Dictionary = _runtime.create_snapshot() + var expected_digest := SimulationDigest.compute(snapshot) + var expected_progress: int = _runtime.construction_service.get_job(job_id).progress + _clock.advance_minutes(90) + _expect(_runtime.construction_service.get_job(job_id).state == ConstructionJob.State.COMPLETED, "warehouse can complete after the snapshot") + _expect(_runtime.restore_snapshot(snapshot), "mid-build world and construction snapshot restores") + _expect(_runtime.latest_digest == expected_digest, "mid-build restore reproduces the exact digest") + _expect(_runtime.construction_service.get_job(job_id).progress == expected_progress, "mid-build integer progress restores exactly") + _clock.advance_minutes(90) + _expect(_runtime.construction_service.get_job(job_id).state == ConstructionJob.State.COMPLETED, "restored build completes once") + +func _test_cancel_and_demolish() -> void: + var materials_before_cancel: int = _state.get_resource(&"materials") + var start = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"cafeteria", &"origin": Vector3i(7, 0, WorldService.LAYER_FRONT), &"rotation": 90}, + &"player", &"", 30, -1, &"s1.build.cancel_target" + ) + _expect(start.is_success(), "cancellable construction starts") + var job_id := StringName(start.details[&"job_id"]) + var cancel = _runtime.submit_command( + &"cancel_construction", + {&"job_id": job_id}, + &"player", &"", 30, -1, &"s1.cancel.target" + ) + _expect(cancel.is_success(), "zero-progress construction cancels") + _expect(_state.get_resource(&"materials") == materials_before_cancel, "zero-progress cancellation returns all materials") + _expect(_runtime.world_service.get_entity_cells(job_id).is_empty(), "cancellation releases reserved world cells") + + var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities() + var reception_id := StringName(facilities[0][&"instance_id"]) + var materials_before_demolition: int = _state.get_resource(&"materials") + var demolition = _runtime.submit_command( + &"demolish_facility", + {&"instance_id": reception_id}, + &"player", &"", 30, -1, &"s1.demolish.reception" + ) + _expect(demolition.is_success(), "completed facility can be demolished through a command") + _expect(_state.get_resource(&"materials") == materials_before_demolition + 2, "demolition returns 25 percent of reception materials") + _expect(_runtime.world_service.get_entity_cells(reception_id).is_empty(), "demolition releases facility occupancy") + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_runtime_world_construction.gd.uid b/tests/s1/test_runtime_world_construction.gd.uid new file mode 100644 index 0000000..298eef5 --- /dev/null +++ b/tests/s1/test_runtime_world_construction.gd.uid @@ -0,0 +1 @@ +uid://bonrrpg25e8s diff --git a/tests/s1/test_spatial_subslot_service.gd b/tests/s1/test_spatial_subslot_service.gd new file mode 100644 index 0000000..eb0a82e --- /dev/null +++ b/tests/s1/test_spatial_subslot_service.gd @@ -0,0 +1,312 @@ +extends SceneTree + +const SpatialSubslotServiceType = preload("res://scripts/world/spatial_subslot_service.gd") +const WorldServiceType = preload("res://scripts/world/world_service.gd") +const DigestType = preload("res://scripts/core/simulation_digest.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: Spatial subslots") + _test_three_independent_slots_and_world_coexistence() + _test_revision_dirty_and_replacement() + _test_strict_input_limits() + _test_compatibility_callback_atomicity_and_reentry() + _test_snapshot_round_trip_and_corruption_rejection() + _test_deterministic_insertion_order() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_three_independent_slots_and_world_coexistence() -> void: + var world := WorldServiceType.new() + world.reset({&"min_x": -3, &"max_x": 3, &"min_y": -3, &"max_y": 3}) + var shared := Vector3i(0, 0, 0) + _expect(world.occupy(&"major_facility", [shared], &"facility_large"), "major World occupant registers first") + var service := SpatialSubslotServiceType.new() + var route_cells := [Vector3i(1, 0, 0), shared, Vector3i(0, -1, 1)] + _expect(service.place(&"route_main", service.CATEGORY_ROUTE, route_cells).is_success(), "multi-cell ROUTE shares a primary World cell") + _expect(world.get_occupant(shared) == &"major_facility", "subslot placement never mutates primary World occupancy") + _expect(service.place(&"decor_banner", service.CATEGORY_DECORATION, [shared]).is_success(), "DECORATION shares the ROUTE cell") + _expect(service.place(&"utility_pipe", service.CATEGORY_UTILITY, [shared]).is_success(), "UTILITY shares ROUTE and DECORATION") + _expect( + service.get_cell_slots(shared) == { + &"ROUTE": &"route_main", + &"DECORATION": &"decor_banner", + &"UTILITY": &"utility_pipe", + }, + "one layer cell exposes exactly three independent category slots" + ) + _expect( + service.get_entity_cells(&"route_main") == [ + Vector3i(0, -1, 1), shared, Vector3i(1, 0, 0), + ], + "one entity owns multiple cells in canonical y/x/layer order" + ) + _expect( + service.get_route_cells() == [Vector3i(0, -1, 1), shared, Vector3i(1, 0, 0)], + "route query is ready for deterministic Path graph input" + ) + var before_overlap := service.create_snapshot() + _expect(not service.place(&"route_overlap", service.CATEGORY_ROUTE, [shared]).is_success(), "second ROUTE in one cell is rejected") + _expect(service.create_snapshot() == before_overlap, "same-category overlap rejection is atomic") + _expect(not service.place(&"route_main", service.CATEGORY_UTILITY, [Vector3i(2, 0, 0)]).is_success(), "one entity ID cannot own a second category") + + var returned_cells := service.get_entity_cells(&"route_main") + returned_cells.clear() + var returned_entities := service.get_entities() + returned_entities[0][&"cells"].clear() + var returned_slots := service.get_cell_slots(shared) + returned_slots.clear() + _expect(service.get_entity_cells(&"route_main").size() == 3, "entity cell query is defensive") + _expect(service.get_entities().size() == 3, "entity records are defensive") + _expect(service.get_cell_slots(shared).size() == 3, "cell slot query is defensive") + _expect(service.validate_invariants()[&"ok"], "three-way coexistence preserves all crosslinks") + + +func _test_revision_dirty_and_replacement() -> void: + var service := SpatialSubslotServiceType.new() + var a := Vector3i(1, 1, 0) + var b := Vector3i(-1, 0, 1) + var c := Vector3i(2, -1, 0) + _expect(service.place(&"route", service.CATEGORY_ROUTE, [a, b]).is_success(), "first mutation succeeds") + _expect(service.revision == 1 and service.get_revision_for_category(service.CATEGORY_ROUTE) == 1, "ROUTE mutation advances global and category revisions once") + _expect(service.get_revision_for_category(service.CATEGORY_DECORATION) == 0, "unmodified category revision remains zero") + _expect(service.place(&"decor", service.CATEGORY_DECORATION, [a]).is_success(), "second category mutation succeeds") + _expect(service.revision == 2 and service.get_revision_for_category(service.CATEGORY_DECORATION) == 2, "DECORATION records its exact last revision") + _expect(service.get_dirty_cells() == [b, a], "dirty cells are canonical and deduplicated") + var consumed := service.consume_dirty_cells() + consumed.clear() + _expect(service.get_dirty_cells().is_empty() and service.revision == 2, "dirty consumption is defensive and does not advance revision") + + var replacement := service.replace_entity_cells(&"route", [c, a]) + _expect(replacement.is_success() and replacement.details[&"changed"], "entity cell set can be atomically replaced") + _expect(service.revision == 3 and service.get_revision_for_category(service.CATEGORY_ROUTE) == 3, "replacement advances only its category revision") + _expect(service.get_dirty_cells() == [c, b, a], "replacement dirties removed, retained and added cells once") + var no_op_revision := service.revision + _expect(service.replace_entity_cells(&"route", [a, c]).is_success(), "canonical-equivalent replacement is a successful no-op") + _expect(service.revision == no_op_revision, "replacement no-op does not change revision") + _expect(service.remove(&"decor").is_success(), "entity removal succeeds") + _expect(service.revision == 4 and service.get_revision_for_category(service.CATEGORY_DECORATION) == 4, "removal advances exact category revision") + _expect(service.get_slot_entity(a, service.CATEGORY_ROUTE) == &"route", "removal preserves another category in the same cell") + _expect(service.get_slot_entity(a, service.CATEGORY_DECORATION).is_empty(), "removal clears only the owned category slot") + _expect(not service.remove(&"decor").is_success(), "double removal is rejected") + _expect(service.validate_invariants()[&"ok"], "replacement and removal preserve strict crosslinks") + + +func _test_strict_input_limits() -> void: + var service := SpatialSubslotServiceType.new() + var valid_snapshot := service.create_snapshot() + _expect(not service.place(12, service.CATEGORY_ROUTE, [Vector3i.ZERO]).is_success(), "integer entity ID is rejected") + _expect(not service.place(&"", service.CATEGORY_ROUTE, [Vector3i.ZERO]).is_success(), "empty entity ID is rejected") + _expect(not service.place(StringName("x".repeat(service.MAX_ENTITY_ID_LENGTH + 1)), service.CATEGORY_ROUTE, [Vector3i.ZERO]).is_success(), "oversized entity ID is rejected") + _expect(not service.place(&"bad id", service.CATEGORY_ROUTE, [Vector3i.ZERO]).is_success(), "unstable entity ID is rejected") + _expect(not service.place(&"lower_category", &"route", [Vector3i.ZERO]).is_success(), "non-canonical category is rejected") + _expect(not service.place(&"numeric_category", 0, [Vector3i.ZERO]).is_success(), "numeric category is rejected rather than coerced") + _expect(not service.place(&"not_array", service.CATEGORY_ROUTE, Vector3i.ZERO).is_success(), "non-Array cells are rejected") + _expect(not service.place(&"empty_cells", service.CATEGORY_ROUTE, []).is_success(), "empty cell set is rejected") + _expect(not service.place(&"duplicate_cells", service.CATEGORY_ROUTE, [Vector3i.ZERO, Vector3i.ZERO]).is_success(), "duplicate cells are rejected") + _expect(not service.place(&"wrong_cell_type", service.CATEGORY_ROUTE, [Vector2i.ZERO]).is_success(), "non-Vector3i cell is rejected") + _expect(not service.place(&"wrong_layer", service.CATEGORY_ROUTE, [Vector3i(0, 0, 2)]).is_success(), "unknown layer is rejected") + _expect(not service.place(&"deep_coordinate", service.CATEGORY_ROUTE, [Vector3i(0, service.MAX_COORDINATE_ABS + 1, 0)]).is_success(), "coordinate depth limit is enforced") + var too_many_cells: Array[Vector3i] = [] + for x: int in range(service.MAX_CELLS_PER_ENTITY + 1): + too_many_cells.append(Vector3i(x, 0, 0)) + _expect(not service.place(&"too_many_cells", service.CATEGORY_ROUTE, too_many_cells).is_success(), "per-entity cell capacity is enforced") + _expect(service.create_snapshot() == valid_snapshot and service.revision == 0, "all malformed input failures preserve the empty state") + _expect(service.get_entity_ids(&"route").is_empty(), "invalid category query fails closed") + _expect(service.get_revision_for_category(&"route") == -1, "invalid category revision query fails closed") + + +func _test_compatibility_callback_atomicity_and_reentry() -> void: + var service := SpatialSubslotServiceType.new() + var callback_payloads: Array[Dictionary] = [] + service.configure_compatibility_validator( + func(payload: Dictionary) -> Dictionary: + callback_payloads.append(payload.duplicate(true)) + payload[&"after_cells"].clear() + return {&"ok": true, &"details": {&"pass_through": true}} + ) + _expect(service.place(&"pass_route", service.CATEGORY_ROUTE, [Vector3i.ZERO]).is_success(), "pass-through validator can approve placement") + _expect(callback_payloads[0][&"operation"] == &"PLACE", "validator receives explicit operation context") + _expect(callback_payloads[0][&"after_cells"] == [Vector3i.ZERO], "validator payload captured by caller retains exact candidate") + _expect(service.get_entity_cells(&"pass_route") == [Vector3i.ZERO], "callback payload mutation cannot alter committed cells") + var callback_report := service.get_last_callback_report() + callback_report[&"details"].clear() + _expect(service.get_last_callback_report()[&"details"] == {&"pass_through": true}, "callback report is a defensive deep copy") + + service.configure_compatibility_validator( + func(_payload: Dictionary) -> Dictionary: + return {&"ok": false, &"reason": &"PRIMARY_BLOCKS_CATEGORY"} + ) + var before_rejection := service.create_snapshot() + var rejected = service.replace_entity_cells(&"pass_route", [Vector3i(1, 0, 0)]) + _expect(not rejected.is_success() and rejected.details[&"rollback_ok"], "callback rejection reports successful pre-commit rollback") + _expect(service.create_snapshot() == before_rejection, "callback failure preserves snapshot and revision exactly") + + service.configure_compatibility_validator( + func(_payload: Dictionary) -> Variant: + return true + ) + _expect(not service.place(&"invalid_response", service.CATEGORY_UTILITY, [Vector3i(2, 0, 0)]).is_success(), "non-Dictionary callback response is rejected") + _expect(service.create_snapshot() == before_rejection, "invalid callback response is atomic") + + var nested_result: Array = [null] + var configure_during_callback: Array = [true] + service.configure_compatibility_validator( + func(_payload: Dictionary) -> Dictionary: + nested_result[0] = service.place(&"nested", service.CATEGORY_DECORATION, [Vector3i(3, 0, 0)]) + configure_during_callback[0] = service.configure_compatibility_validator(Callable()) + return {&"ok": true} + ) + _expect(service.place(&"outer", service.CATEGORY_DECORATION, [Vector3i(2, 0, 0)]).is_success(), "outer callback mutation commits") + _expect(nested_result[0] != null and not nested_result[0].is_success(), "callback reentry is rejected") + _expect(not configure_during_callback[0], "callback cannot replace its own boundary during mutation") + _expect(service.has_entity(&"outer") and not service.has_entity(&"nested"), "reentry rejection commits only the outer entity") + + service.configure_compatibility_validator( + func(_payload: Dictionary) -> Dictionary: + service._entity_cells[&"tampered"] = [Vector3i(9, 9, 0)] + return {&"ok": true} + ) + var before_tamper := service.create_snapshot() + var tamper_result = service.place(&"tamper_probe", service.CATEGORY_UTILITY, [Vector3i(4, 0, 0)]) + _expect(not tamper_result.is_success() and tamper_result.details[&"rollback_ok"], "direct callback tamper is detected and rolled back") + _expect(service.create_snapshot() == before_tamper and not service.has_entity(&"tampered"), "callback tamper rollback restores every internal crosslink") + _expect(service.get_last_callback_report()[&"tampered"], "callback report records tamper detection") + + service.configure_compatibility_validator( + func(_payload: Dictionary) -> Dictionary: + service._compatibility_validator = Callable() + return {&"ok": true} + ) + var before_boundary_tamper := service.create_snapshot() + var boundary_tamper_result = service.place(&"boundary_tamper", service.CATEGORY_UTILITY, [Vector3i(5, 0, 0)]) + _expect(not boundary_tamper_result.is_success() and boundary_tamper_result.details[&"rollback_ok"], "callback cannot replace its validator boundary directly") + _expect(service.create_snapshot() == before_boundary_tamper, "validator-boundary tamper rollback is atomic") + service.configure_compatibility_validator(Callable()) + + +func _test_snapshot_round_trip_and_corruption_rejection() -> void: + var source := SpatialSubslotServiceType.new() + source.place(&"z_route", source.CATEGORY_ROUTE, [Vector3i(2, 0, 0), Vector3i(1, -1, 1)]) + source.place(&"a_decor", source.CATEGORY_DECORATION, [Vector3i(2, 0, 0)]) + source.place(&"m_utility", source.CATEGORY_UTILITY, [Vector3i(-1, 2, 1)]) + source.consume_dirty_cells() + source.replace_entity_cells(&"z_route", [Vector3i(3, 0, 0), Vector3i(1, -1, 1)]) + var snapshot := source.create_snapshot() + _expect(snapshot[&"entities"].map(func(row: Dictionary) -> String: return row[&"entity_id"]) == ["a_decor", "m_utility", "z_route"], "snapshot entities are lexical") + var restored := SpatialSubslotServiceType.new() + _expect(restored.restore_snapshot(snapshot), "strict current snapshot restores") + _expect(restored.create_snapshot() == snapshot, "snapshot round trip is exact") + _expect(DigestType.compute(restored.create_snapshot()) == DigestType.compute(snapshot), "snapshot digest round trip is deterministic") + _expect(restored.validate_invariants()[&"ok"], "restored entity/category/cell graph is valid") + var before := restored.create_snapshot() + + var version_type := snapshot.duplicate(true) + version_type[&"schema_version"] = "1" + _atomic_reject(restored, version_type, before, "string schema version") + var unknown_field := snapshot.duplicate(true) + unknown_field[&"unknown"] = true + _atomic_reject(restored, unknown_field, before, "unknown top-level field") + var revision_type := snapshot.duplicate(true) + revision_type[&"revision"] = 4.0 + _atomic_reject(restored, revision_type, before, "floating revision") + var revision_bound := snapshot.duplicate(true) + revision_bound[&"revision"] = source.MAX_REVISION + 1 + _atomic_reject(restored, revision_bound, before, "oversized revision") + var category_keys := snapshot.duplicate(true) + category_keys[&"category_revisions"].erase(&"UTILITY") + _atomic_reject(restored, category_keys, before, "missing category revision") + var category_revision := snapshot.duplicate(true) + category_revision[&"category_revisions"][&"ROUTE"] = int(category_revision[&"revision"]) + 1 + _atomic_reject(restored, category_revision, before, "category revision above global revision") + var missing_latest_category := snapshot.duplicate(true) + missing_latest_category[&"category_revisions"][&"ROUTE"] = int(missing_latest_category[&"revision"]) - 1 + _atomic_reject(restored, missing_latest_category, before, "no category at global revision") + var entity_order := snapshot.duplicate(true) + entity_order[&"entities"].reverse() + _atomic_reject(restored, entity_order, before, "unordered entities") + var entity_extra := snapshot.duplicate(true) + entity_extra[&"entities"][0][&"extra"] = 1 + _atomic_reject(restored, entity_extra, before, "unknown entity field") + var entity_id_type := snapshot.duplicate(true) + entity_id_type[&"entities"][0][&"entity_id"] = 1 + _atomic_reject(restored, entity_id_type, before, "non-string entity ID") + var invalid_category := snapshot.duplicate(true) + invalid_category[&"entities"][0][&"category"] = "DECOR" + _atomic_reject(restored, invalid_category, before, "unknown category") + var unsorted_cells := snapshot.duplicate(true) + unsorted_cells[&"entities"][2][&"cells"].reverse() + _atomic_reject(restored, unsorted_cells, before, "unordered entity cells") + var duplicate_category_slot := snapshot.duplicate(true) + duplicate_category_slot[&"entities"].insert(2, { + &"entity_id": "n_route", + &"category": "ROUTE", + &"cells": [Vector3i(3, 0, 0)], + }) + _atomic_reject(restored, duplicate_category_slot, before, "duplicate category slot") + var invalid_cell := snapshot.duplicate(true) + invalid_cell[&"entities"][0][&"cells"] = [Vector3i(0, 0, 2)] + _atomic_reject(restored, invalid_cell, before, "invalid entity layer") + var dirty_order := snapshot.duplicate(true) + dirty_order[&"dirty_cells"].reverse() + _atomic_reject(restored, dirty_order, before, "unordered dirty cells") + var dirty_duplicate := snapshot.duplicate(true) + dirty_duplicate[&"dirty_cells"].append(dirty_duplicate[&"dirty_cells"][0]) + _atomic_reject(restored, dirty_duplicate, before, "duplicate dirty cell") + var depth := snapshot.duplicate(true) + var deep_value: Variant = [] + for _index: int in range(source.MAX_SNAPSHOT_DEPTH + 2): + deep_value = [deep_value] + depth[&"entities"] = deep_value + _atomic_reject(restored, depth, before, "excessive snapshot depth") + var entity_capacity := snapshot.duplicate(true) + var oversized_entities: Array = [] + oversized_entities.resize(source.MAX_ENTITIES + 1) + entity_capacity[&"entities"] = oversized_entities + _atomic_reject(restored, entity_capacity, before, "entity collection capacity") + + var internal_before := restored.create_snapshot() + restored._slots[Vector3i(99, 99, 0)] = {restored.CATEGORY_ROUTE: &"ghost"} + _expect(not restored.validate_invariants()[&"ok"], "live invariant audit detects an orphan cell-side slot") + _expect(restored.restore_snapshot(internal_before), "valid snapshot repairs audited private corruption") + + +func _test_deterministic_insertion_order() -> void: + var first := SpatialSubslotServiceType.new() + var second := SpatialSubslotServiceType.new() + first.place(&"z", first.CATEGORY_ROUTE, [Vector3i(2, 0, 1), Vector3i(-2, 0, 0)]) + first.place(&"a", first.CATEGORY_ROUTE, [Vector3i(0, 1, 0)]) + second.place(&"a", second.CATEGORY_ROUTE, [Vector3i(0, 1, 0)]) + second.place(&"z", second.CATEGORY_ROUTE, [Vector3i(-2, 0, 0), Vector3i(2, 0, 1)]) + _expect(first.create_snapshot() == second.create_snapshot(), "snapshot ignores Dictionary and input-cell insertion order") + _expect(first.get_route_cells() == second.get_route_cells(), "route projection order is deterministic") + + +func _atomic_reject( + service: SpatialSubslotService, + candidate: Dictionary, + before: Dictionary, + label: String +) -> void: + _expect(not service.restore_snapshot(candidate), "%s is rejected" % label) + _expect(service.create_snapshot() == before, "%s rejection is atomic" % label) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_spatial_subslot_service.gd.uid b/tests/s1/test_spatial_subslot_service.gd.uid new file mode 100644 index 0000000..3691d2f --- /dev/null +++ b/tests/s1/test_spatial_subslot_service.gd.uid @@ -0,0 +1 @@ +uid://dnk1clco7jj33 diff --git a/tests/s1/test_world_service.gd b/tests/s1/test_world_service.gd new file mode 100644 index 0000000..9f2dc95 --- /dev/null +++ b/tests/s1/test_world_service.gd @@ -0,0 +1,199 @@ +extends SceneTree + +const WorldServiceType = preload("res://scripts/world/world_service.gd") +const DigestType = preload("res://scripts/core/simulation_digest.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S1: World Service") + _test_bounds_zones_and_footprints() + _test_occupancy_validation_and_release() + _test_walkability_excavation_and_revisions() + _test_connected_support_validation() + _test_snapshot_round_trip_and_atomic_restore() + _test_snapshot_order_is_deterministic() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_bounds_zones_and_footprints() -> void: + var world = WorldServiceType.new() + _expect(world.reset(_bounds(-2, 2, -2, 2)), "dictionary bounds are accepted") + _expect(world.is_in_bounds(Vector3i(-2, -2, 0)), "FRONT minimum corner is in bounds") + _expect(world.is_in_bounds(Vector3i(2, 2, 1)), "BACK maximum corner is in bounds") + _expect(not world.is_in_bounds(Vector3i(0, 0, 2)), "unknown layer is out of bounds") + _expect(not world.is_in_bounds(Vector3i(3, 0, 0)), "x outside bounds is rejected") + _expect(world.get_zone(Vector3i(0, 1, 0)) == world.ZONE_ABOVE_GROUND, "positive y is above ground") + _expect(world.get_zone(0) == world.ZONE_BOUNDARY, "zero y is boundary") + _expect(world.get_zone(Vector2i(0, -1)) == world.ZONE_UNDERGROUND, "negative y is underground") + + var footprint := world.cells_for_footprint(Vector3i(-1, -1, 1), Vector2i(2, 2)) + _expect( + footprint == [ + Vector3i(-1, -1, 1), Vector3i(0, -1, 1), + Vector3i(-1, 0, 1), Vector3i(0, 0, 1), + ], + "rectangular footprint has stable row-major ordering" + ) + _expect(world.cells_for_footprint(Vector3i.ZERO, Vector2i.ZERO).is_empty(), "empty footprint is rejected") + var before := world.get_bounds() + _expect(not world.reset(Rect2i(0, 0, 0, 2)), "invalid reset is rejected") + _expect(world.get_bounds() == before, "failed reset preserves the current world") + + +func _test_occupancy_validation_and_release() -> void: + var world = WorldServiceType.new() + world.reset(_bounds(-3, 3, -2, 2)) + var room_cells := world.cells_for_footprint(Vector3i(0, 0, 0), Vector2i(2, 1)) + _expect(world.occupy(&"room-b", room_cells, &"facility_small"), "valid entity occupies its footprint") + _expect(world.get_occupant(Vector3i(0, 0, 0)) == &"room-b", "cell points back to occupant") + _expect(world.get_occupant_kind(Vector3i(1, 0, 0)) == &"facility_small", "occupant kind is retained") + _expect(world.get_entity_cells(&"room-b") == room_cells, "entity points to canonical cells") + + var returned_cells := world.get_entity_cells(&"room-b") + returned_cells.clear() + _expect(world.get_entity_cells(&"room-b") == room_cells, "returned cell arrays cannot mutate world state") + + var overlap := world.validate_occupancy([Vector3i(1, 0, 0)], &"room-a") + _expect(not overlap[&"ok"] and overlap[&"errors"].has(world.ERROR_OCCUPIED), "overlapping occupation reports OCCUPIED") + var revision_before_failure: int = world.revision + _expect(not world.occupy(&"room-a", [Vector3i(1, 0, 0)], &"facility"), "overlapping occupy is atomic") + _expect(world.revision == revision_before_failure, "failed occupy does not change revision") + _expect(not world.occupy(&"room-b", [Vector3i(2, 0, 0)], &"facility"), "same entity cannot occupy twice") + + var malformed := world.validate_occupancy([ + Vector3i(0, 1, 0), + Vector3i(0, 1, 0), + Vector3i(0, 1, 1), + Vector3i(9, 1, 0), + Vector2i.ZERO, + ]) + _expect(malformed[&"errors"].has(world.ERROR_DUPLICATE_CELL), "duplicate cell is reported") + _expect(malformed[&"errors"].has(world.ERROR_LAYER_MISMATCH), "layer crossing is reported") + _expect(malformed[&"errors"].has(world.ERROR_OUT_OF_BOUNDS), "out-of-bounds cell is reported") + _expect(malformed[&"errors"].has(world.ERROR_INVALID_CELL_TYPE), "non-Vector3i cell is reported") + + _expect(world.release(&"room-b"), "release succeeds for existing entity") + _expect(world.get_occupant(Vector3i(0, 0, 0)).is_empty(), "release clears cell-side occupation") + _expect(world.get_entity_cells(&"room-b").is_empty(), "release clears entity-side occupation") + _expect(not world.release(&"room-b"), "double release is rejected") + + +func _test_walkability_excavation_and_revisions() -> void: + var world = WorldServiceType.new() + world.reset(_bounds(-1, 1, -2, 2)) + var path_cell := Vector3i(0, 1, 0) + var cave_front := Vector3i(0, -1, 0) + var cave_back := Vector3i(0, -1, 1) + _expect(not world.is_walkable(path_cell), "walkability defaults to false") + _expect(world.set_walkable(path_cell, true), "walkability can be enabled") + _expect(world.is_walkable(path_cell), "enabled walkability is readable") + _expect(world.dirty_revision == world.revision, "dirty revision exposes the current world revision") + var walk_revision: int = world.revision + _expect(world.set_walkable(path_cell, true), "same walkability is a successful no-op") + _expect(world.revision == walk_revision, "walkability no-op does not dirty world") + + _expect(world.is_excavated(Vector3i(0, 0, 0)), "boundary is usable without excavation") + _expect(not world.is_excavated(cave_front), "underground defaults to unexcavated") + _expect(world.set_excavated(cave_front, true), "underground spatial cell can be excavated") + _expect(world.is_excavated(cave_front) and world.is_excavated(cave_back), "excavation is shared by FRONT and BACK") + _expect(world.excavation_revision == world.revision, "excavation tracks its dirty revision") + _expect(world.get_dirty_cells().has(cave_front) and world.get_dirty_cells().has(cave_back), "both layers become dirty") + var dirty := world.consume_dirty_cells() + _expect(not dirty.is_empty() and world.get_dirty_cells().is_empty(), "dirty cells can be consumed deterministically") + _expect(not world.set_walkable(Vector3i(99, 0, 0), true), "out-of-bounds walkability mutation is rejected") + + +func _test_connected_support_validation() -> void: + var world = WorldServiceType.new() + world.reset(_bounds(-5, 5, 0, 2)) + _expect(world.set_support(Vector3i(0, 0, 0), 3), "explicit support capacity is stored") + _expect(world.occupy(&"existing-room", [Vector3i(1, 0, 0)], &"facility_small"), "existing structural load is stored") + var supported := world.validate_support([Vector3i(2, 0, 0), Vector3i(3, 0, 0)], 1) + _expect(supported[&"ok"], "component capacity covers existing and proposed load") + _expect(supported[&"required_weight"] == 3, "support report includes existing structural load") + _expect(supported[&"available_capacity"] == 3, "support report exposes finite capacity") + + var overloaded := world.validate_support([ + Vector3i(2, 0, 0), Vector3i(3, 0, 0), Vector3i(4, 0, 0), + ], 1) + _expect(not overloaded[&"ok"] and overloaded[&"errors"].has(world.ERROR_UNSUPPORTED), "overloaded component is rejected") + _expect(overloaded[&"unsupported_cells"].size() == 3, "all proposed cells in failed component are identified") + + _expect(world.set_support(Vector3i(-2, 0, 1), 0, true), "infinite structural anchor is stored") + var anchored := world.validate_support([Vector3i(-1, 0, 1), Vector3i(0, 0, 1)], 99) + _expect(anchored[&"ok"] and anchored[&"available_capacity"] == -1, "connected anchor supports any finite weight") + _expect(not world.validate_support([Vector3i(0, 0, 0)], 0)[&"ok"], "non-positive proposed weight is rejected") + + +func _test_snapshot_round_trip_and_atomic_restore() -> void: + var source = WorldServiceType.new() + source.reset(_bounds(-3, 3, -3, 3)) + source.occupy(&"warehouse", [Vector3i(1, 0, 1), Vector3i(0, 0, 1)], &"facility_medium") + source.set_walkable(Vector3i(-1, 0, 0), true) + source.set_excavated(Vector3i(2, -2, 0), true) + source.set_excavated(Vector3i(-2, 1, 0), false) + source.set_support(Vector3i(0, 0, 0), 5, false) + source.set_support(Vector3i(-1, 0, 1), 0, true) + var snapshot := source.create_snapshot() + + var restored = WorldServiceType.new() + restored.reset(Rect2i(0, 0, 1, 1)) + _expect(restored.restore_snapshot(snapshot), "valid world snapshot restores") + _expect(restored.create_snapshot() == snapshot, "world snapshot round trip is exact") + _expect(DigestType.compute(restored.create_snapshot()) == DigestType.compute(snapshot), "round trip preserves deterministic digest") + + var corrupt: Dictionary = snapshot.duplicate(true) + corrupt[&"occupancies"].append({ + &"entity_id": &"overlap", + &"kind": &"facility", + &"cells": [Vector3i(0, 0, 1)], + }) + var before_failed_restore := restored.create_snapshot() + _expect(not restored.restore_snapshot(corrupt), "overlapping snapshot is rejected") + _expect(restored.create_snapshot() == before_failed_restore, "failed restore is fully atomic") + + +func _test_snapshot_order_is_deterministic() -> void: + var first = WorldServiceType.new() + var second = WorldServiceType.new() + first.reset(_bounds(-2, 2, -1, 1)) + second.reset(_bounds(-2, 2, -1, 1)) + first.occupy(&"z-entity", [Vector3i(1, 0, 0)], &"decoration") + first.occupy(&"a-entity", [Vector3i(-1, 0, 0)], &"decoration") + second.occupy(&"a-entity", [Vector3i(-1, 0, 0)], &"decoration") + second.occupy(&"z-entity", [Vector3i(1, 0, 0)], &"decoration") + first.set_walkable(Vector3i(1, 1, 1), true) + first.set_walkable(Vector3i(-1, -1, 0), true) + second.set_walkable(Vector3i(-1, -1, 0), true) + second.set_walkable(Vector3i(1, 1, 1), true) + _expect(first.create_snapshot() == second.create_snapshot(), "snapshot ignores Dictionary insertion order") + _expect(DigestType.compute(first.create_snapshot()) == DigestType.compute(second.create_snapshot()), "equivalent worlds have identical digest") + + +func _bounds(min_x: int, max_x: int, min_y: int, max_y: int) -> Dictionary: + return { + &"min_x": min_x, + &"max_x": max_x, + &"min_y": min_y, + &"max_y": max_y, + } + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s1/test_world_service.gd.uid b/tests/s1/test_world_service.gd.uid new file mode 100644 index 0000000..584031f --- /dev/null +++ b/tests/s1/test_world_service.gd.uid @@ -0,0 +1 @@ +uid://nj6v4aby5clu diff --git a/tests/s2/test_economy_service.gd b/tests/s2/test_economy_service.gd new file mode 100644 index 0000000..e219de6 --- /dev/null +++ b/tests/s2/test_economy_service.gd @@ -0,0 +1,192 @@ +extends SceneTree + +const LedgerEntryType = preload("res://scripts/economy/ledger_entry.gd") +const EconomyServiceType = preload("res://scripts/economy/economy_service.gd") +const CommandResultType = preload("res://scripts/core/command_result.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: Economy Service") + _test_initialization_and_insufficient_balance() + _test_idempotency_posts_once() + _test_reverse_and_partial_refund() + _test_compound_post_is_atomic() + _test_daily_aggregation_and_reconciliation() + _test_snapshot_round_trip_is_atomic() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_initialization_and_insufficient_balance() -> void: + var economy := _economy() + var initialized = economy.initialize_account(&"PLAYER_CASH", 1_000, 360, &"init-player") + _expect(initialized.is_success(), "initial Gold is posted through SYSTEM_SOURCE") + _expect(economy.get_balance(&"PLAYER_CASH") == 1_000, "initialization debits player cash") + _expect(economy.get_balance(EconomyServiceType.SYSTEM_SOURCE) == -1_000, "SYSTEM_SOURCE is the explicit counter-account") + var entry = economy.get_transaction_entries(initialized.details[&"transaction_id"])[0] + _expect(entry.amount == 1_000 and entry.category == &"INITIALIZATION", "initialization creates a positive integer ledger entry") + + var before := economy.create_snapshot() + var rejected = economy.transfer( + &"PLAYER_CASH", &"GUEST_WALLET:guest_1", 1_001, 361, &"TEST", &"", &"too-much" + ) + _expect(not rejected.is_success(), "an account without credit cannot overspend") + _expect(rejected.code == CommandResultType.Code.VALIDATION_FAILED, "insufficient Gold is a validation failure") + _expect(economy.create_snapshot() == before, "rejected transfer changes neither ledger nor balances") + _expect(not economy.register_account(&"PLAYER_CASH"), "an account definition cannot be replaced to bypass its policy") + + +func _test_idempotency_posts_once() -> void: + var economy := _economy() + economy.initialize_account(&"PLAYER_CASH", 500, 360, &"init") + var first = economy.transfer( + &"PLAYER_CASH", &"GUEST_WALLET:guest_1", 125, 400, &"GIFT", &"player", &"gift-once" + ) + var entry_count := economy.get_entries().size() + var duplicate = economy.transfer( + &"PLAYER_CASH", &"GUEST_WALLET:guest_1", 125, 400, &"GIFT", &"player", &"gift-once" + ) + _expect(first.is_success(), "first idempotent command posts") + _expect(duplicate.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, "duplicate idempotency key is rejected explicitly") + _expect(economy.get_entries().size() == entry_count, "duplicate command appends no second entry") + _expect(economy.get_balance(&"PLAYER_CASH") == 375, "sender is credited exactly once") + _expect(economy.get_balance(&"GUEST_WALLET:guest_1") == 125, "receiver is debited exactly once") + _expect( + economy.get_transaction_id_for_idempotency_key(&"gift-once") == first.details[&"transaction_id"], + "idempotency index resolves the original transaction" + ) + + +func _test_reverse_and_partial_refund() -> void: + var economy := _economy() + economy.initialize_account(&"GUEST_WALLET:guest_1", 500, 360, &"init-guest") + var payment = economy.transfer( + &"GUEST_WALLET:guest_1", &"FACILITY_REVENUE:inn", 200, 500, &"SERVICE_SALE", &"inn", &"sale-1" + ) + var payment_id: StringName = payment.details[&"transaction_id"] + var reversed = economy.reverse(payment_id, 510, &"reverse-sale") + _expect(reversed.is_success(), "an existing transaction reverses with a new entry") + _expect(economy.get_balance(&"GUEST_WALLET:guest_1") == 500, "reversal restores the payer balance") + _expect(economy.get_balance(&"FACILITY_REVENUE:inn") == 0, "reversal restores the receiver balance") + var reverse_entry = economy.get_transaction_entries(reversed.details[&"transaction_id"])[0] + _expect(reverse_entry.related_transaction_id == payment_id, "reversal retains the original transaction reference") + _expect(reverse_entry.debit_account == &"GUEST_WALLET:guest_1" and reverse_entry.credit_account == &"FACILITY_REVENUE:inn", "reversal swaps debit and credit") + _expect(not economy.reverse(payment_id, 511, &"reverse-again").is_success(), "a transaction cannot be fully reversed twice") + + var second_payment = economy.transfer( + &"GUEST_WALLET:guest_1", &"FACILITY_REVENUE:inn", 120, 520, &"SERVICE_SALE", &"inn", &"sale-2" + ) + var second_id: StringName = second_payment.details[&"transaction_id"] + _expect(economy.refund(second_id, 40, 530, &"refund-1").is_success(), "a one-line transaction supports a partial refund") + _expect(economy.refund(second_id, 80, 540, &"refund-2").is_success(), "remaining refundable Gold can be returned") + _expect(economy.get_refunded_amount(second_id) == 120, "refund total is derived from related entries") + _expect(not economy.refund(second_id, 1, 541, &"refund-over").is_success(), "refunds cannot exceed the source transaction") + + +func _test_compound_post_is_atomic() -> void: + var economy := _economy() + economy.initialize_account(&"PLAYER_CASH", 400, 360, &"init-player") + economy.initialize_account(&"GUEST_WALLET:guest_1", 50, 360, &"init-guest") + var before := economy.create_snapshot() + var entries: Array = [ + _entry(600, &"STAFF_WALLET:staff_1", &"PLAYER_CASH", 100, &"PAYROLL"), + _entry(600, &"STAFF_WALLET:staff_1", &"GUEST_WALLET:guest_1", 51, &"PAYROLL"), + ] + var rejected = economy.post(entries, &"compound-fail") + _expect(not rejected.is_success(), "one invalid line rejects a compound transaction") + _expect(economy.create_snapshot() == before, "compound failure rolls back every line and index") + + entries[1].amount = 50 + var posted = economy.post(entries, &"compound-ok") + _expect(posted.is_success(), "a fully valid compound transaction posts") + _expect(economy.get_transaction_entries(posted.details[&"transaction_id"]).size() == 2, "compound lines share one transaction ID") + _expect(economy.get_balance(&"STAFF_WALLET:staff_1") == 150, "compound debit deltas commit together") + _expect(economy.get_balance(&"PLAYER_CASH") == 300 and economy.get_balance(&"GUEST_WALLET:guest_1") == 0, "compound credits commit together") + + +func _test_daily_aggregation_and_reconciliation() -> void: + var economy := _economy() + economy.initialize_account(&"PLAYER_CASH", 1_000, 360, &"init") + economy.transfer(&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 75, 700, &"GIFT", &"player", &"gift") + economy.transfer(&"PLAYER_CASH", &"STAFF_WALLET:staff_1", 125, 1_500, &"WAGE", &"staff_1", &"wage") + var day_one := economy.get_daily_summary(1) + _expect(day_one[&"transaction_count"] == 2 and day_one[&"balanced"], "day close counts only transactions inside its minute range") + _expect(day_one[&"category_totals"][&"INITIALIZATION"] == 1_000, "day close aggregates category amounts") + _expect(day_one[&"account_totals"][&"PLAYER_CASH"][&"net"] == 925, "day close aggregates per-account debit, credit and net") + _expect(economy.validate_ledger()[&"ok"], "ledger and derived balance cache reconcile") + + economy._balance_cache[&"PLAYER_CASH"] = 999_999 + var mismatch := economy.validate_ledger() + _expect(not mismatch[&"ok"] and mismatch[&"errors"][-1][&"code"] == &"BALANCE_CACHE_MISMATCH", "reconciliation detects a damaged balance cache") + _expect(economy.rebuild_balance_cache(), "balance cache can be rebuilt only from valid ledger entries") + _expect(economy.get_balance(&"PLAYER_CASH") == 800, "rebuild never invents a compensating ledger entry") + _expect(economy.validate_ledger()[&"ok"], "rebuilt balance cache reconciles exactly") + + +func _test_snapshot_round_trip_is_atomic() -> void: + var source := _economy() + source.set_current_minute(777) + source.initialize_account(&"PLAYER_CASH", 900, 360, &"init") + source.transfer(&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 120, 650, &"GIFT", &"player", &"gift") + var snapshot := source.create_snapshot() + var restored := EconomyServiceType.new() + _expect(restored.restore_snapshot(snapshot), "valid economy snapshot restores") + _expect(restored.create_snapshot() == snapshot, "economy snapshot round trip is exact") + _expect(restored.validate_ledger()[&"ok"], "restored ledger and balances reconcile") + + var before_invalid := restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"entries"][0][&"amount"] = 0 + _expect(not restored.restore_snapshot(corrupt), "snapshot containing a zero-amount entry is rejected") + _expect(restored.create_snapshot() == before_invalid, "failed snapshot restore is fully atomic") + + var duplicate_account := snapshot.duplicate(true) + duplicate_account[&"accounts"].append(duplicate_account[&"accounts"][0].duplicate(true)) + _expect(not restored.restore_snapshot(duplicate_account), "snapshot with duplicate account definitions is rejected") + _expect(restored.create_snapshot() == before_invalid, "second rejected restore also preserves live state") + + +func _economy() -> EconomyService: + return EconomyServiceType.new({ + &"PLAYER_CASH": {&"allow_negative": false}, + &"GUEST_WALLET:guest_1": {&"allow_negative": false}, + &"FACILITY_REVENUE:inn": {&"allow_negative": false}, + &"STAFF_WALLET:staff_1": {&"allow_negative": false}, + &"STAFF_PAYABLE:staff_1": {&"allow_negative": true}, + }) + + +func _entry( + absolute_minute: int, + debit_account: StringName, + credit_account: StringName, + amount: int, + category: StringName +) -> LedgerEntry: + return LedgerEntryType.new().configure( + &"", + absolute_minute, + debit_account, + credit_account, + amount, + category + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s2/test_economy_service.gd.uid b/tests/s2/test_economy_service.gd.uid new file mode 100644 index 0000000..9f643eb --- /dev/null +++ b/tests/s2/test_economy_service.gd.uid @@ -0,0 +1 @@ +uid://dskbexctiolv5 diff --git a/tests/s2/test_facility_service.gd b/tests/s2/test_facility_service.gd new file mode 100644 index 0000000..51d0a5b --- /dev/null +++ b/tests/s2/test_facility_service.gd @@ -0,0 +1,312 @@ +extends SceneTree + +const FacilityInstanceType = preload("res://scripts/facility/facility_instance.gd") +const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd") +const FacilityServiceType = preload("res://scripts/facility/facility_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: Facility Service") + _test_state_priority_and_opening_hours() + _test_policy_revision_and_request_snapshot() + _test_deterministic_queue_and_departure() + _test_capacity_progress_and_boundaries() + _test_interruption_and_recovery() + _test_determinism() + _test_snapshot_round_trip_is_atomic() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_state_priority_and_opening_hours() -> void: + var facility := _facility(&"priority_room", 1, 4, 1, 2) + facility.set_operational_conditions({&"active_staff_ids": []}, 0) + _expect(facility.state == FacilityInstanceType.State.NO_STAFF, "staff shortage initially blocks opening") + facility.set_operational_conditions({ + &"durability": 0, + &"access_available": false, + &"power_available": false, + &"operator_open": false, + &"active_staff_ids": [], + &"degraded_reasons": [&"DIRTY"], + }, 600) + _expect(facility.state == FacilityInstanceType.State.BROKEN, "BROKEN wins over every other state") + _expect(facility.state_reasons.size() == 6, "all simultaneous state reasons are retained") + facility.set_operational_conditions({ + &"durability": 5_000, + &"access_available": false, + &"power_available": false, + &"operator_open": false, + &"active_staff_ids": [], + &"degraded_reasons": [], + }, 600) + _expect(facility.state == FacilityInstanceType.State.NO_ACCESS, "NO_ACCESS wins after repair") + facility.set_operational_conditions({&"access_available": true}, 600) + _expect(facility.state == FacilityInstanceType.State.NO_POWER, "NO_POWER wins over staffing and closure") + facility.set_operational_conditions({&"power_available": true}, 600) + _expect(facility.state == FacilityInstanceType.State.NO_STAFF, "NO_STAFF wins over manual closure") + facility.set_operational_conditions({&"active_staff_ids": [&"staff_a"]}, 600) + _expect(facility.state == FacilityInstanceType.State.CLOSED, "manual closure follows operational failures") + facility.set_operational_conditions({&"operator_open": true, &"durability": 2_999}, 600) + _expect(facility.state == FacilityInstanceType.State.DEGRADED, "low durability produces DEGRADED") + facility.set_operational_conditions({&"durability": 5_000}, 600) + _expect(facility.state == FacilityInstanceType.State.OPEN, "healthy staffed facility opens") + _expect(facility.set_opening_hours(600, 1_200), "valid opening hours are accepted") + facility.evaluate_state(500) + _expect(facility.state == FacilityInstanceType.State.CLOSED, "schedule closes facility before opening") + facility.evaluate_state(600) + _expect(facility.state == FacilityInstanceType.State.OPEN, "opening minute is inclusive") + facility.evaluate_state(1_200) + _expect(facility.state == FacilityInstanceType.State.CLOSED, "closing minute is exclusive") + _expect(facility.set_opening_hours(1_200, 0), "midnight close creates a valid overnight interval") + _expect(facility.is_within_opening_hours(1_300), "midnight-close schedule remains open late at night") + + +func _test_policy_revision_and_request_snapshot() -> void: + var service := FacilityServiceType.new() + var facility := _facility(&"policy_room", 1, 3, 0, 1) + _expect(service.register_instance(facility).is_success(), "facility instance registers") + _expect(not service.register_instance(facility).is_success(), "duplicate facility instance ID is rejected") + var changed = service.set_policy(facility.instance_id, {&"price_mode": &"standard"}) + _expect(changed.is_success() and facility.policy_revision == 1, "immediate policy patch increments revision") + var request := service.create_request( + &"policy_request", + facility.instance_id, + [&"guest_a"], + &"meal", + 100, + {&"quoted_price": 120} + ) + _expect(service.enqueue_request(request, 100).is_success(), "policy-quoted request enters queue") + service.set_policy(facility.instance_id, {&"price_mode": &"premium"}) + _expect(request.policy_snapshot[&"price_mode"] == &"standard", "queued request retains original policy snapshot") + _expect(request.policy_revision == 1 and facility.policy_revision == 2, "request retains original policy revision") + var scheduled = service.set_policy(facility.instance_id, {&"risk_mode": true}, 120) + _expect(scheduled.is_success() and facility.policy_pending_revision == 3, "future policy receives pending revision") + service.advance_one_minute(119) + _expect(not facility.policy_current.has(&"risk_mode"), "pending policy does not apply early") + service.advance_one_minute(120) + _expect(facility.policy_current.get(&"risk_mode", false), "pending policy applies at exact minute") + _expect(facility.policy_revision == 3, "scheduled application increments current revision once") + + +func _test_deterministic_queue_and_departure() -> void: + var service := FacilityServiceType.new() + var facility := _facility(&"queue_room", 1, 3, 0, 2) + service.register_instance(facility) + var request_b := _request(service, facility, &"request_b", 10, 0, 2) + var request_a := _request(service, facility, &"request_a", 10, 0, 2) + var request_high := _request(service, facility, &"request_high", 11, 5, 2) + _expect(service.enqueue_request(request_b, 10).is_success(), "first request enters queue") + _expect(service.enqueue_request(request_a, 10).is_success(), "same-minute request enters queue") + _expect(service.enqueue_request(request_high, 11).is_success(), "later high-priority request enters queue") + _expect( + _queue_ids(service, facility.instance_id) == [&"request_high", &"request_a", &"request_b"], + "queue sorts by priority, arrival minute, then request ID" + ) + _expect(request_high.expected_wait == 0, "high-priority request wait estimate excludes requests behind it") + _expect(not service.enqueue_request(request_a, 12).is_success(), "duplicate request ID is rejected permanently") + var overflow := _request(service, facility, &"request_overflow", 12, 0, 2) + _expect(not service.enqueue_request(overflow, 12).is_success(), "queue capacity rejects overflow") + _expect(service.leave_queue(facility.instance_id, &"request_a").is_success(), "guest can leave queue") + _expect(_queue_ids(service, facility.instance_id) == [&"request_high", &"request_b"], "queue departure removes entry immediately") + _expect(not service.enqueue_request(request_a, 13).is_success(), "departed request ID cannot be reused") + + +func _test_capacity_progress_and_boundaries() -> void: + var service := FacilityServiceType.new() + var facility := _facility(&"capacity_room", 2, 4, 0, 2) + service.register_instance(facility) + var starts: Array[StringName] = [] + var completions: Array[StringName] = [] + service.configure_callbacks( + func(request: ServiceRequest, _facility_instance: FacilityInstance, _minute: int) -> bool: + starts.append(request.request_id) + return true, + func(service_instance: ServiceInstance, _request_value: ServiceRequest, _facility_instance: FacilityInstance, _minute: int) -> bool: + completions.append(service_instance.service_id) + return true + ) + for request_id: StringName in [&"request_c", &"request_a", &"request_b"]: + service.enqueue_request(_request(service, facility, request_id, 100, 0, 2), 100) + var filled := service.fill_available_slots(facility.instance_id, 100) + _expect(filled.size() == 2 and filled[0].is_success() and filled[1].is_success(), "capacity opens exactly two slots") + _expect(starts == [&"request_a", &"request_b"], "slot starts follow deterministic request-ID tie break") + _expect(facility.active_services.size() == 2, "two services occupy both slots") + _expect(not service.start_next_service(facility.instance_id, 100).is_success(), "full facility cannot start a third service") + service.advance_one_minute(101) + _expect(completions.is_empty(), "two-minute service does not complete after one tick") + service.advance_one_minute(102) + _expect(completions.size() == 2, "both occupied slots complete independently") + _expect(facility.active_services.size() == 1, "completion fills one freed slot from queue") + _expect(starts == [&"request_a", &"request_b", &"request_c"], "next queued request is promoted after completion") + + var rejecting := FacilityServiceType.new() + var rejection_room := _facility(&"rejection_room", 1, 2, 0, 1) + rejecting.register_instance(rejection_room) + rejecting.configure_callbacks( + func(_request_value: ServiceRequest, _instance: FacilityInstance, _minute: int) -> bool: + return true, + func(_service_value: ServiceInstance, _request_value: ServiceRequest, _instance: FacilityInstance, _minute: int) -> String: + return "ledger transaction rejected" + ) + rejecting.enqueue_request(_request(rejecting, rejection_room, &"atomic_request", 0, 0, 1), 0) + var started = rejecting.start_next_service(rejection_room.instance_id, 0) + var service_id: StringName = started.details[&"service_id"] + var results := rejecting.advance_one_minute(1) + _expect(results.any(func(result: CommandResult) -> bool: return not result.is_success()), "completion callback failure is surfaced") + _expect(rejection_room.active_services.has(service_id), "failed completion transaction leaves service active") + _expect(rejecting.get_request(&"atomic_request") != null, "failed completion transaction retains request") + + +func _test_interruption_and_recovery() -> void: + var service := FacilityServiceType.new() + var facility := _facility(&"interrupt_room", 1, 2, 0, 3) + service.register_instance(facility) + service.enqueue_request(_request(service, facility, &"interrupt_request", 0, 0, 3), 0) + var started = service.start_next_service(facility.instance_id, 0) + var service_id: StringName = started.details[&"service_id"] + service.set_operational_status(facility.instance_id, {&"power_available": false}, 1) + service.advance_one_minute(1) + var active := service.get_service(service_id) + _expect(active.state == ServiceInstanceType.State.PAUSED, "power loss safely pauses active service") + _expect(active.elapsed_minutes == 0, "paused service makes no progress") + service.set_operational_status(facility.instance_id, {&"power_available": true}, 2) + service.advance_one_minute(2) + _expect(active.state == ServiceInstanceType.State.ACTIVE and active.elapsed_minutes == 1, "power restoration resumes service") + service.interrupt_service(service_id, FacilityServiceType.INTERRUPTION_PLAYER_CLOSED, 2) + _expect(active.state == ServiceInstanceType.State.ACTIVE, "normal player closure lets active service finish") + service.set_operational_status(facility.instance_id, {&"durability": 0}, 3) + service.advance_one_minute(3) + _expect(active.state == ServiceInstanceType.State.FAILED, "facility breakage immediately fails active service") + _expect(facility.active_services.is_empty(), "failed service frees its capacity slot") + + var grace_service := FacilityServiceType.new() + var grace_room := _facility(&"grace_room", 1, 1, 0, 3) + grace_service.register_instance(grace_room) + grace_service.enqueue_request(_request(grace_service, grace_room, &"grace_request", 10, 0, 3), 10) + var grace_started = grace_service.start_next_service(grace_room.instance_id, 10) + var grace_id: StringName = grace_started.details[&"service_id"] + grace_service.interrupt_service(grace_id, FacilityServiceType.INTERRUPTION_STAFF_UNAVAILABLE, 10) + grace_service.advance_one_minute(14) + _expect(grace_service.get_service(grace_id).state == ServiceInstanceType.State.WAITING_FOR_STAFF, "staff replacement waits through minute four") + grace_service.advance_one_minute(15) + _expect(grace_service.get_service(grace_id).state == ServiceInstanceType.State.FAILED, "staff replacement fails at five-minute deadline") + + +func _test_determinism() -> void: + var first := _deterministic_run() + var second := _deterministic_run() + _expect(first == second, "identical commands produce byte-equivalent facility snapshots") + + +func _test_snapshot_round_trip_is_atomic() -> void: + var service := FacilityServiceType.new() + var facility := _facility(&"save_room", 1, 3, 0, 4) + service.register_instance(facility) + service.set_policy(facility.instance_id, {&"mode": &"quiet"}) + service.set_policy(facility.instance_id, {&"mode": &"busy"}, 500) + service.enqueue_request(_request(service, facility, &"active_request", 400, 2, 4), 400) + service.enqueue_request(_request(service, facility, &"queued_request", 401, 0, 4), 401) + service.start_next_service(facility.instance_id, 401) + service.advance_one_minute(402) + var snapshot := service.create_snapshot() + var restored := FacilityServiceType.new() + _expect(restored.restore_snapshot(snapshot), "valid facility snapshot restores") + _expect(restored.create_snapshot() == snapshot, "snapshot round trip preserves full facility state") + var before_invalid := restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"requests"].append(corrupt[&"requests"][0].duplicate(true)) + _expect(not restored.restore_snapshot(corrupt), "duplicate request in snapshot is rejected") + _expect(restored.create_snapshot() == before_invalid, "rejected restore leaves live state untouched") + var corrupt_reference := snapshot.duplicate(true) + corrupt_reference[&"instances"][0][&"queue_entries"] = ["missing_request"] + _expect(not restored.restore_snapshot(corrupt_reference), "orphan queue reference is rejected") + _expect(restored.create_snapshot() == before_invalid, "orphan rejection is also atomic") + + +func _deterministic_run() -> Dictionary: + var service := FacilityServiceType.new() + var facility := _facility(&"deterministic_room", 2, 6, 0, 2) + service.register_instance(facility) + for request_data: Array in [ + [&"request_d", 0], + [&"request_b", 1], + [&"request_c", 1], + [&"request_a", 0], + ]: + var request_id: StringName = request_data[0] + var priority: int = request_data[1] + service.enqueue_request(_request(service, facility, request_id, 50, priority, 2), 50) + service.fill_available_slots(facility.instance_id, 50) + service.advance_one_minute(51) + service.advance_one_minute(52) + service.advance_one_minute(53) + return service.create_snapshot() + + +func _facility( + instance_id: StringName, + capacity: int, + queue_capacity: int, + staff_min: int, + service_minutes: int +) -> FacilityInstance: + return FacilityInstanceType.new().configure( + instance_id, + &"test_facility", + { + &"capacity": capacity, + &"queue_capacity": queue_capacity, + &"staff_min": staff_min, + &"staff_max": maxi(staff_min, 2), + &"active_staff_ids": [&"staff_a"] if staff_min > 0 else [], + &"base_service_minutes": service_minutes, + &"base_quality": 75, + &"base_price": 100, + } + ) + + +func _request( + service: FacilityService, + facility: FacilityInstance, + request_id: StringName, + created_at: int, + priority: int, + service_minutes: int +) -> ServiceRequest: + return service.create_request( + request_id, + facility.instance_id, + [StringName("guest_%s" % request_id)], + &"test_service", + created_at, + {&"priority": priority, &"service_minutes": service_minutes} + ) + + +func _queue_ids(service: FacilityService, instance_id: StringName) -> Array[StringName]: + var ids: Array[StringName] = [] + for request: ServiceRequest in service.get_queue(instance_id): + ids.append(request.request_id) + return ids + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s2/test_facility_service.gd.uid b/tests/s2/test_facility_service.gd.uid new file mode 100644 index 0000000..43ad98f --- /dev/null +++ b/tests/s2/test_facility_service.gd.uid @@ -0,0 +1 @@ +uid://bgrxu5w7jm0an diff --git a/tests/s2/test_inventory_logistics.gd b/tests/s2/test_inventory_logistics.gd new file mode 100644 index 0000000..5d3c67d --- /dev/null +++ b/tests/s2/test_inventory_logistics.gd @@ -0,0 +1,591 @@ +extends SceneTree + +const InventoryServiceScript = preload("res://scripts/inventory/inventory_service.gd") +const LogisticsRequestScript = preload("res://scripts/inventory/logistics_request.gd") +const LogisticsDispatcherScript = preload("res://scripts/inventory/logistics_dispatcher.gd") +const InventoryReservationScript = preload("res://scripts/inventory/inventory_reservation.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: Inventory and Logistics") + _test_reservation_limits_and_idempotency() + _test_transfer_never_duplicates_stock() + _test_expiration_rules() + _test_orphan_repair_report() + _test_dispatch_priority_capacity_and_tie_break() + _test_dispatch_missing_carrier_preserves_reserved_load() + _test_atomic_transfer_batches_match_sequential_operations() + _test_idempotency_conflicts_are_preflighted() + _test_request_transitions() + _test_snapshot_round_trip_and_atomic_restore() + _test_strict_snapshot_migration_and_id_tombstones() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_reservation_limits_and_idempotency() -> void: + var service = InventoryServiceScript.new() + var added: Dictionary = service.add_stack(&"warehouse", &"meal", 10, 90, -1, 0, 2) + _expect(added[&"ok"], "stock can be added to an owner inventory") + var first: Dictionary = service.reserve_item( + &"warehouse", &"meal", 7, &"cafeteria", &"service", 10, 60, &"reserve-a" + ) + _expect(first[&"ok"] and service.available_quantity(&"warehouse", &"meal", 10) == 3, "reservation reduces available but not physical quantity") + var replay: Dictionary = service.reserve_item( + &"warehouse", &"meal", 7, &"cafeteria", &"service", 10, 60, &"reserve-a" + ) + _expect(replay[&"ok"] and replay[&"replay"] and service.get_reservations(true).size() == 1, "reservation operation id replays without creating stock claims") + var rejected: Dictionary = service.reserve_item( + &"warehouse", &"meal", 4, &"kitchen", &"service", 10, 60, &"reserve-b" + ) + _expect(not rejected[&"ok"] and rejected[&"code"] == &"INSUFFICIENT_AVAILABLE", "over-reservation is rejected atomically") + var reservation_id: StringName = first[&"reservation_ids"][0] + var consumed: Dictionary = service.consume_reservation(reservation_id, &"consume-a", 20) + var quantity_after := service.total_quantity(&"warehouse", &"meal") + var consumed_again: Dictionary = service.consume_reservation(reservation_id, &"consume-b", 21) + _expect(consumed[&"ok"] and quantity_after == 3, "consumption removes exactly the reserved physical stock") + _expect(consumed_again[&"ok"] and consumed_again[&"replay"] and service.total_quantity(&"warehouse", &"meal") == quantity_after, "a consumed reservation cannot consume twice") + _expect(service.validate_invariants()[&"ok"], "reserve and consume preserve quantity minus reserved invariant") + + +func _test_transfer_never_duplicates_stock() -> void: + var service = InventoryServiceScript.new() + service.add_stack(&"source", &"material", 12, 75, 500, 3, 3) + var before := service.total_quantity(&"source", &"material") + service.total_quantity(&"destination", &"material") + var moved: Dictionary = service.transfer_available( + &"source", &"destination", &"material", 5, &"transfer-a", 20 + ) + var after := service.total_quantity(&"source", &"material") + service.total_quantity(&"destination", &"material") + _expect(moved[&"ok"] and before == after and after == 12, "owner transfer conserves global item quantity") + _expect(service.total_quantity(&"source", &"material") == 7 and service.total_quantity(&"destination", &"material") == 5, "transfer subtracts source before adding destination") + var replay: Dictionary = service.transfer_available( + &"source", &"destination", &"material", 5, &"transfer-a", 20 + ) + _expect(replay[&"replay"] and service.total_quantity(&"destination", &"material") == 5, "transfer replay cannot duplicate destination stock") + + var reserved: Dictionary = service.reserve_item( + &"source", &"material", 4, &"delivery-1", &"logistics", 21, 100, &"pickup-reserve" + ) + var pickup: Dictionary = service.transfer_reserved( + reserved[&"reservation_ids"], &"carrier-cargo", &"pickup-a", 22 + ) + _expect(pickup[&"ok"] and pickup[&"weight"] == 12, "reserved pickup transfers the exact reserved weight") + _expect(service.total_quantity(&"source", &"material") == 3 and service.total_quantity(&"carrier-cargo", &"material") == 4, "reserved pickup consumes reservation and creates cargo once") + _expect(service.validate_invariants()[&"ok"], "available and reserved remain valid after transfer") + + +func _test_expiration_rules() -> void: + var service = InventoryServiceScript.new() + var added: Dictionary = service.add_stack(&"cold-store", &"food", 8, 80, 30, 0, 1) + var reservation: Dictionary = service.reserve_item( + &"cold-store", &"food", 3, &"service-a", &"meal", 10, 50, &"expiry-reserve" + ) + _expect(not service.is_stack_expired(added[&"stack_id"], 29), "stack remains valid before expires_at") + _expect(service.is_stack_expired(added[&"stack_id"], 30), "stack expires exactly at expires_at") + var report: Dictionary = service.process_expired(30) + _expect(report[&"discarded_quantity"] == 5 and service.total_quantity(&"cold-store", &"food") == 3, "expiration discards only unreserved quantity") + var consume: Dictionary = service.consume_reservation(reservation[&"reservation_ids"][0], &"expired-consume", 30) + _expect(not consume[&"ok"] and consume[&"code"] == &"STACK_EXPIRED", "reserved stack is checked again before consumption") + service.process_expired(50) + _expect(service.total_quantity(&"cold-store", &"food") == 0, "reservation expiry releases and discards remaining expired stock") + + +func _test_orphan_repair_report() -> void: + var service = InventoryServiceScript.new() + var added: Dictionary = service.add_stack(&"store", &"part", 5) + var reservation: Dictionary = service.reserve_item( + &"store", &"part", 2, &"repair-job", &"build", 0, -1, &"orphan-reserve" + ) + var stacks: Dictionary = service._inventories[&"store"] + stacks.erase(added[&"stack_id"]) + service._inventories[&"store"] = stacks + var report: Dictionary = service.audit_and_repair_reservations(5) + var repaired_reservation = service.get_reservation(reservation[&"reservation_ids"][0]) + _expect(report[&"orphan_count"] == 1 and report[&"released_reservation_ids"].size() == 1, "orphan repair returns a structured repair report") + _expect(repaired_reservation.state == InventoryReservationScript.State.RELEASED, "orphan repair closes the dangling reservation") + + +func _test_dispatch_priority_capacity_and_tie_break() -> void: + var high = _reserved_request(&"request-high", 90, 6, 300) + var low = _reserved_request(&"request-low", 20, 6, 400) + var dispatcher = LogisticsDispatcherScript.new() + var carriers := { + &"carrier-b": { + &"position": Vector3i(1, 0, 1), + &"capacity": 10, + &"current_load": 0, + &"role": &"logistics", + &"path_minutes": {&"default": 5}, + }, + &"carrier-a": { + &"position": Vector3i(0, 0, 1), + &"capacity": 10, + &"current_load": 0, + &"role": &"logistics", + &"path_minutes": {&"default": 5}, + }, + } + var assignments: Array[Dictionary] = dispatcher.dispatch([low, high], carriers, 100) + _expect(assignments.size() == 2 and assignments[0][&"request_id"] == &"request-high", "dispatcher evaluates requests in deterministic priority order") + _expect(assignments[0][&"carrier_id"] == &"carrier-a", "equal scores use lexical carrier id as tie break") + _expect(assignments[1][&"carrier_id"] == &"carrier-b", "reserved load prevents assigning beyond one carrier capacity") + _expect(dispatcher.get_reserved_load(&"carrier-a") == 6 and dispatcher.get_reserved_load(&"carrier-b") == 6, "dispatcher records capacity reservations for later passes") + var reordered_dispatcher = LogisticsDispatcherScript.new() + var reordered_carriers := { + &"carrier-a": carriers[&"carrier-a"], + &"carrier-b": carriers[&"carrier-b"], + } + var reordered_assignments: Array[Dictionary] = reordered_dispatcher.dispatch([ + _reserved_request(&"request-high", 90, 6, 300), + _reserved_request(&"request-low", 20, 6, 400), + ], reordered_carriers, 100) + _expect( + reordered_assignments == assignments, + "one-pass carrier ordering remains deterministic across request and Dictionary insertion order" + ) + + +func _test_dispatch_missing_carrier_preserves_reserved_load() -> void: + var dispatcher = LogisticsDispatcherScript.new() + var carrier := { + &"carrier-a": { + &"position": Vector3i.ZERO, + &"capacity": 10, + &"current_load": 0, + &"role": &"logistics", + &"path_minutes": 1, + }, + } + var first = _reserved_request(&"request-carrier-present", 90, 6, 300) + _expect( + dispatcher.dispatch([first], carrier, 100).size() == 1, + "carrier accepts an assignment within capacity" + ) + var second = _reserved_request(&"request-carrier-absent", 80, 6, 310) + _expect( + dispatcher.dispatch([second], {}, 101).is_empty() + and dispatcher.get_reserved_load(&"carrier-a") == 6, + "temporarily absent carrier keeps its accepted reserved load" + ) + var absent_snapshot: Dictionary = dispatcher.create_snapshot() + var restored = LogisticsDispatcherScript.new() + _expect( + restored.restore_snapshot(absent_snapshot) + and restored.create_snapshot() == absent_snapshot, + "missing-carrier reservation snapshot restores exactly" + ) + _expect( + restored.dispatch([second], carrier, 102).is_empty() + and restored.get_reserved_load(&"carrier-a") == 6, + "reappearing carrier cannot accept another six units into capacity ten" + ) + _expect( + restored.release_assignment(&"carrier-a", 6) == 0 + and restored.create_snapshot()[&"carrier_loads"].is_empty(), + "explicit assignment release removes the retained carrier load" + ) + + +func _test_atomic_transfer_batches_match_sequential_operations() -> void: + var seed = InventoryServiceScript.new() + seed.add_stack(&"warehouse", &"supply", 4, 87, 600, 3, 2, &"batch-source") + var reservation_a: Dictionary = seed.reserve_item( + &"warehouse", &"supply", 2, &"request-a", &"delivery", 10, 100, &"batch-reserve-a" + ) + var reservation_b: Dictionary = seed.reserve_item( + &"warehouse", &"supply", 2, &"request-b", &"delivery", 10, 100, &"batch-reserve-b" + ) + var ids_a: Array[StringName] = [] + var ids_b: Array[StringName] = [] + ids_a.assign(reservation_a[&"reservation_ids"]) + ids_b.assign(reservation_b[&"reservation_ids"]) + + var sequential = InventoryServiceScript.new() + var batched = InventoryServiceScript.new() + sequential.restore_snapshot(seed.create_snapshot()) + batched.restore_snapshot(seed.create_snapshot()) + sequential.transfer_reserved(ids_a, &"cargo-a", &"pickup-a", 20) + sequential.transfer_reserved(ids_b, &"cargo-b", &"pickup-b", 20) + var pickup_entries: Array[Dictionary] = [ + {&"reservation_ids": ids_a, &"destination_inventory_id": &"cargo-a", &"operation_id": &"pickup-a"}, + {&"reservation_ids": ids_b, &"destination_inventory_id": &"cargo-b", &"operation_id": &"pickup-b"}, + ] + var pickup_batch: Dictionary = batched.transfer_reserved_batch(pickup_entries, 20) + _expect( + pickup_batch[&"ok"] and pickup_batch[&"transfer_count"] == 2, + "reserved-stock batch applies every ordered pickup atomically" + ) + _expect( + batched.create_snapshot() == sequential.create_snapshot(), + "reserved-stock batch is snapshot-equivalent to sequential public operations" + ) + var before_replay := batched.create_snapshot() + var pickup_replay: Dictionary = batched.transfer_reserved_batch(pickup_entries, 20) + _expect( + pickup_replay[&"ok"] + and pickup_replay[&"results"][0][&"replay"] + and pickup_replay[&"results"][1][&"replay"] + and batched.create_snapshot() == before_replay, + "batch replay preserves per-operation idempotency without another transfer" + ) + + var pickup_snapshot := batched.create_snapshot() + sequential.transfer_available(&"cargo-a", &"destination", &"supply", 2, &"deliver-a", 21) + sequential.transfer_available(&"cargo-b", &"destination", &"supply", 2, &"deliver-b", 21) + var delivery_batch: Dictionary = batched.transfer_available_batch([ + { + &"source_inventory_id": &"cargo-a", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 2, + &"operation_id": &"deliver-a", + }, + { + &"source_inventory_id": &"cargo-b", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 2, + &"operation_id": &"deliver-b", + }, + ], 21) + _expect( + delivery_batch[&"ok"] and batched.create_snapshot() == sequential.create_snapshot(), + "available-stock batch preserves FEFO results, stack ids, and operation order" + ) + + var rollback = InventoryServiceScript.new() + rollback.restore_snapshot(pickup_snapshot) + var before_failure := rollback.create_snapshot() + var failed_batch: Dictionary = rollback.transfer_available_batch([ + { + &"source_inventory_id": &"cargo-a", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 2, + &"operation_id": &"rollback-first", + }, + { + &"source_inventory_id": &"missing-cargo", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 1, + &"operation_id": &"rollback-second", + }, + ], 22) + _expect( + not failed_batch[&"ok"] and failed_batch[&"code"] == &"ATOMIC_BATCH_FAILED", + "a failed line rejects the complete transfer batch" + ) + _expect( + rollback.create_snapshot() == before_failure, + "failed batch rolls back stock, generated ids, and idempotency records exactly" + ) + + +func _test_idempotency_conflicts_are_preflighted() -> void: + var sequential = InventoryServiceScript.new() + sequential.add_stack(&"source-a", &"supply", 4) + sequential.add_stack(&"source-b", &"supply", 4) + var first: Dictionary = sequential.transfer_available( + &"source-a", &"destination", &"supply", 2, &"shared-operation", 10 + ) + var after_first := sequential.create_snapshot() + var conflict: Dictionary = sequential.transfer_available( + &"source-b", &"destination", &"supply", 2, &"shared-operation", 10 + ) + _expect(first[&"ok"], "first normalized input binds its operation id") + _expect( + not conflict[&"ok"] + and conflict[&"code"] == &"IDEMPOTENCY_CONFLICT" + and sequential.create_snapshot() == after_first, + "sequential operation-id reuse with different input is rejected without side effects" + ) + var action_conflict: Dictionary = sequential.reserve_item( + &"source-a", &"supply", 1, &"requester", &"delivery", 10, -1, &"shared-operation" + ) + _expect( + not action_conflict[&"ok"] + and action_conflict[&"code"] == &"IDEMPOTENCY_CONFLICT" + and sequential.create_snapshot() == after_first, + "operation id is bound to its action as well as normalized input" + ) + + var duplicate_batch = InventoryServiceScript.new() + duplicate_batch.add_stack(&"source-a", &"supply", 4) + duplicate_batch.add_stack(&"source-b", &"supply", 4) + var before_duplicate := duplicate_batch.create_snapshot() + var duplicate_result: Dictionary = duplicate_batch.transfer_available_batch([ + { + &"source_inventory_id": &"source-a", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 2, + &"operation_id": &"batch-shared", + }, + { + &"source_inventory_id": &"source-b", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 2, + &"operation_id": &"batch-shared", + }, + ], 10) + _expect( + not duplicate_result[&"ok"] + and duplicate_result[&"code"] == &"IDEMPOTENCY_CONFLICT" + and duplicate_batch.create_snapshot() == before_duplicate, + "A-to-D then B-to-D duplicate operation id is rejected before the batch mutates" + ) + + var existing_conflict = InventoryServiceScript.new() + existing_conflict.add_stack(&"source-a", &"supply", 4) + existing_conflict.add_stack(&"source-b", &"supply", 4) + existing_conflict.transfer_available( + &"source-a", &"destination", &"supply", 1, &"already-bound", 11 + ) + var before_existing_conflict := existing_conflict.create_snapshot() + var existing_result: Dictionary = existing_conflict.transfer_available_batch([ + { + &"source_inventory_id": &"source-b", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 1, + &"operation_id": &"would-be-first", + }, + { + &"source_inventory_id": &"source-b", + &"destination_inventory_id": &"destination", + &"item_id": &"supply", + &"quantity": 1, + &"operation_id": &"already-bound", + }, + ], 11) + _expect( + not existing_result[&"ok"] + and existing_result[&"code"] == &"IDEMPOTENCY_CONFLICT" + and existing_conflict.create_snapshot() == before_existing_conflict, + "existing operation-id conflict is found before an earlier batch row can apply" + ) + + +func _test_request_transitions() -> void: + var request = _reserved_request(&"request-state", 50, 4, 200) + _expect(request.assign(&"carrier", 20), "RESERVED request accepts a carrier assignment") + _expect(request.transition_to(LogisticsRequestScript.State.PICKING_UP, "", 21), "ASSIGNED transitions to PICKING_UP") + _expect(request.transition_to(LogisticsRequestScript.State.DELIVERING, "", 22), "PICKING_UP transitions to DELIVERING") + _expect(request.transition_to(LogisticsRequestScript.State.COMPLETED, "", 23), "DELIVERING transitions to COMPLETED") + _expect(not request.transition_to(LogisticsRequestScript.State.OPEN, "", 24), "terminal logistics request cannot reopen") + + +func _test_snapshot_round_trip_and_atomic_restore() -> void: + var original = InventoryServiceScript.new() + original.add_stack(&"warehouse", &"supply", 10, 88, 700, 5, 2) + original.reserve_item(&"warehouse", &"supply", 4, &"facility", &"restock", 10, 80, &"snapshot-reserve") + var snapshot: Dictionary = original.create_snapshot() + var restored = InventoryServiceScript.new() + _expect(restored.restore_snapshot(snapshot), "valid inventory snapshot restores") + _expect(restored.create_snapshot() == snapshot, "inventory snapshot round trip is exact") + + var before_invalid: Dictionary = restored.create_snapshot() + var invalid: Dictionary = snapshot.duplicate(true) + invalid[&"inventories"][0][&"stacks"][0][&"reserved_quantity"] = 99 + _expect(not restored.restore_snapshot(invalid), "invalid inventory snapshot is rejected") + _expect(restored.create_snapshot() == before_invalid, "failed restore leaves live inventory state untouched") + + var request = _reserved_request(&"request-save", 70, 3, 500) + var request_snapshot: Dictionary = request.create_snapshot() + var restored_request = LogisticsRequestScript.from_snapshot(request_snapshot) + _expect(restored_request != null and restored_request.create_snapshot() == request_snapshot, "logistics request round trip preserves state") + + var dispatcher = LogisticsDispatcherScript.new() + dispatcher.dispatch([request], { + &"carrier": { + &"capacity": 10, + &"current_load": 0, + &"role": &"porter", + &"path_minutes": 2, + &"position": Vector3i.ZERO, + }, + }, 40) + var dispatcher_snapshot: Dictionary = dispatcher.create_snapshot() + var restored_dispatcher = LogisticsDispatcherScript.new() + _expect(restored_dispatcher.restore_snapshot(dispatcher_snapshot), "dispatcher snapshot restores") + _expect(restored_dispatcher.create_snapshot() == dispatcher_snapshot, "dispatcher snapshot round trip is exact") + + +func _test_strict_snapshot_migration_and_id_tombstones() -> void: + var base = InventoryServiceScript.new() + base.add_stack(&"warehouse", &"supply", 3, 90, 100, 5, 1) + base.reserve_item( + &"warehouse", &"supply", 1, &"requester", &"delivery", 6, 90, &"legacy-reserve" + ) + var snapshot: Dictionary = base.create_snapshot() + var guard = InventoryServiceScript.new() + _expect(guard.restore_snapshot(snapshot), "strict snapshot fixture restores before corruption checks") + var guard_snapshot := guard.create_snapshot() + + var negative_minute := snapshot.duplicate(true) + negative_minute[&"current_minute"] = -1 + var negative_created := snapshot.duplicate(true) + negative_created[&"inventories"][0][&"stacks"][0][&"created_at"] = -1 + var negative_expiry := snapshot.duplicate(true) + negative_expiry[&"inventories"][0][&"stacks"][0][&"expires_at"] = -2 + var invalid_terminal := snapshot.duplicate(true) + invalid_terminal[&"reservations"][0][&"terminal_at"] = 0 + _expect( + not guard.restore_snapshot(negative_minute) + and not guard.restore_snapshot(negative_created) + and not guard.restore_snapshot(negative_expiry) + and not guard.restore_snapshot(invalid_terminal), + "restore rejects negative or state-inconsistent canonical timestamps instead of clamping" + ) + _expect( + guard.create_snapshot() == guard_snapshot, + "strict timestamp rejection is atomic" + ) + + var low_stack_sequence := snapshot.duplicate(true) + low_stack_sequence[&"next_stack_sequence"] = 1 + var low_reservation_sequence := snapshot.duplicate(true) + low_reservation_sequence[&"next_reservation_sequence"] = 1 + _expect( + not guard.restore_snapshot(low_stack_sequence) + and not guard.restore_snapshot(low_reservation_sequence) + and guard.create_snapshot() == guard_snapshot, + "next sequences at or below existing canonical ids are atomically rejected" + ) + + var tombstone_source = InventoryServiceScript.new() + var first_stack: Dictionary = tombstone_source.add_stack(&"source", &"part", 1) + var reservation: Dictionary = tombstone_source.reserve_item( + &"source", &"part", 1, &"consumer", &"use", 0, -1, &"tombstone-reserve" + ) + tombstone_source.consume_reservation( + reservation[&"reservation_ids"][0], &"tombstone-consume", 1 + ) + var tombstone_snapshot := tombstone_source.create_snapshot() + _expect( + tombstone_source.total_quantity(&"source", &"part") == 0 + and tombstone_snapshot[&"known_stack_ids"].has(String(first_stack[&"stack_id"])), + "deleted generated stack id remains in persisted known-id history" + ) + var corrupt_tombstone_sequence := tombstone_snapshot.duplicate(true) + corrupt_tombstone_sequence[&"next_stack_sequence"] = 1 + var tombstone_restored = InventoryServiceScript.new() + _expect( + not tombstone_restored.restore_snapshot(corrupt_tombstone_sequence), + "tombstone prevents a corrupted sequence from reusing a deleted auto id" + ) + _expect( + tombstone_restored.restore_snapshot(tombstone_snapshot) + and tombstone_restored.create_snapshot() == tombstone_snapshot + and tombstone_restored.add_stack(&"source", &"part", 1)[&"stack_id"] == &"stack_00000002", + "valid tombstone snapshot round-trips exactly and allocates the next unused id" + ) + var legacy_deleted = InventoryServiceScript.new() + var migrated_deleted_ok := legacy_deleted.restore_snapshot( + _as_legacy_inventory_snapshot(tombstone_snapshot) + ) + _expect( + migrated_deleted_ok + and legacy_deleted.create_snapshot()[&"known_stack_ids"].has("stack_00000001"), + "version-1 counter migration reconstructs tombstones for deleted generated ids" + ) + + var legacy_snapshot := _as_legacy_inventory_snapshot(snapshot) + var migrated = InventoryServiceScript.new() + var legacy_restored := migrated.restore_snapshot(legacy_snapshot) + var legacy_replay: Dictionary = migrated.reserve_item( + &"warehouse", &"supply", 1, &"requester", &"delivery", 6, 90, &"legacy-reserve" + ) + _expect( + legacy_restored and legacy_replay[&"ok"] and legacy_replay[&"replay"], + "version-1 snapshot migrates while preserving legacy replay behavior" + ) + var rebound_conflict: Dictionary = migrated.reserve_item( + &"warehouse", &"supply", 2, &"requester", &"delivery", 6, 90, &"legacy-reserve" + ) + _expect( + not rebound_conflict[&"ok"] and rebound_conflict[&"code"] == &"IDEMPOTENCY_CONFLICT", + "first legacy replay upgrades the operation id to an input-bound record" + ) + var migrated_snapshot: Dictionary = migrated.create_snapshot() + var migrated_roundtrip = InventoryServiceScript.new() + _expect( + migrated_snapshot[&"version"] == 2 + and migrated_roundtrip.restore_snapshot(migrated_snapshot) + and migrated_roundtrip.create_snapshot() == migrated_snapshot, + "migrated version-2 snapshot is canonical and exact on subsequent restore" + ) + var ambiguous_legacy := _as_legacy_inventory_snapshot(snapshot) + ambiguous_legacy[&"idempotency_results"].append({ + &"operation_key": "transfer|legacy-reserve", + &"result": {&"ok": true, &"code": &"OK", &"replay": false}, + }) + var ambiguous = InventoryServiceScript.new() + var ambiguous_loaded := ambiguous.restore_snapshot(ambiguous_legacy) + var ambiguous_use: Dictionary = ambiguous.transfer_available( + &"warehouse", &"destination", &"supply", 1, &"legacy-reserve", 6 + ) + _expect( + ambiguous_loaded + and not ambiguous_use[&"ok"] + and ambiguous_use[&"code"] == &"IDEMPOTENCY_CONFLICT", + "legacy cross-action duplicate ids load compatibly and fail closed on replay" + ) + + +func _as_legacy_inventory_snapshot(snapshot: Dictionary) -> Dictionary: + var legacy_snapshot := snapshot.duplicate(true) + legacy_snapshot[&"version"] = 1 + legacy_snapshot.erase(&"known_stack_ids") + legacy_snapshot.erase(&"known_reservation_ids") + var legacy_results: Array[Dictionary] = [] + for record: Dictionary in legacy_snapshot[&"idempotency_results"]: + legacy_results.append({ + &"operation_key": "%s|%s" % [record[&"action"], record[&"operation_id"]], + &"result": (record[&"result"] as Dictionary).duplicate(true), + }) + legacy_snapshot[&"idempotency_results"] = legacy_results + return legacy_snapshot + + +func _reserved_request( + request_id: StringName, + priority: int, + weight: int, + deadline: int +): + var request = LogisticsRequestScript.new().configure( + request_id, + &"warehouse", + &"facility", + {&"supply": weight}, + priority, + 10, + deadline, + weight + ) + var reservations: Array[StringName] = [StringName("reservation-%s" % request_id)] + var stacks: Array[StringName] = [StringName("stack-%s" % request_id)] + request.mark_reserved(reservations, stacks, 11) + return request + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s2/test_inventory_logistics.gd.uid b/tests/s2/test_inventory_logistics.gd.uid new file mode 100644 index 0000000..79b62f1 --- /dev/null +++ b/tests/s2/test_inventory_logistics.gd.uid @@ -0,0 +1 @@ +uid://c0445hcoy7wh2 diff --git a/tests/s2/test_ledger_backed_resources.gd b/tests/s2/test_ledger_backed_resources.gd new file mode 100644 index 0000000..269b8af --- /dev/null +++ b/tests/s2/test_ledger_backed_resources.gd @@ -0,0 +1,75 @@ +extends SceneTree + +const StateScript = preload("res://autoload/game_state.gd") +const EconomyScript = preload("res://scripts/economy/economy_service.gd") +const AdapterScript = preload("res://scripts/economy/ledger_backed_resource_state.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: ledger-backed resource adapter") + var state = StateScript.new() + state.reset() + var economy = EconomyScript.new({ + &"PLAYER_CASH": false, + &"CONSTRUCTION_EXPENSE": false, + }) + economy.initialize_account(&"PLAYER_CASH", state.get_resource(&"gold"), 360, &"initial-player") + var minute := 400 + var adapter = AdapterScript.new( + state, + economy, + &"CONSTRUCTION_EXPENSE", + &"CONSTRUCTION", + &"CONSTRUCTION_REFUND", + &"construction_service", + func() -> int: return minute + ) + + _expect(adapter.can_afford({&"gold": 500, &"materials": 8}), "combined ledger and material balances validate together") + _expect(adapter.spend({&"gold": 500, &"materials": 8}), "construction cost commits through one resource contract") + _expect(economy.get_balance(&"PLAYER_CASH") == 4500, "Gold spending changes the authoritative ledger balance") + _expect(state.get_resource(&"gold") == 4500, "GameState Gold remains a read-compatible UI mirror") + _expect(state.get_resource(&"materials") == 42, "non-Gold resources commit with the same spend") + _expect(economy.get_balance(&"CONSTRUCTION_EXPENSE") == 500, "construction uses an explicit counter-account") + var expense_entries: Array = economy.get_entries() + _expect(expense_entries[-1].absolute_minute == 400 and expense_entries[-1].category == &"CONSTRUCTION", "ledger entry captures minute and construction category") + + minute = 410 + _expect(adapter.apply_resource_deltas({&"gold": 125, &"materials": 2}), "demolition refund restores Gold and material atomically") + _expect(economy.get_balance(&"PLAYER_CASH") == 4625 and state.get_resource(&"gold") == 4625, "refund keeps ledger and UI mirror equal") + _expect(economy.get_balance(&"CONSTRUCTION_EXPENSE") == 375, "refund reduces its original expense counter-account") + _expect(state.get_resource(&"materials") == 44, "material refund is applied once") + + var before: Dictionary = adapter.create_snapshot() + _expect(not adapter.spend({&"gold": 10_000, &"materials": 1}), "insufficient Gold rejects a mixed spend") + _expect(adapter.create_snapshot() == before, "rejected mixed spend changes neither ledger nor resources") + _expect(not adapter.apply_resource_deltas({&"gold": 1000}), "refund cannot exceed the recorded expense balance") + _expect(adapter.create_snapshot() == before, "failed refund rolls back every resource and ledger field") + + adapter.spend({&"gold": 100, &"materials": 1}) + _expect(adapter.restore_snapshot(before), "adapter restores resource and economy snapshots together") + _expect(adapter.create_snapshot() == before, "adapter snapshot round trip is exact") + _expect(economy.validate_ledger()[&"ok"], "restored authoritative ledger still reconciles") + + state.free() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s2/test_ledger_backed_resources.gd.uid b/tests/s2/test_ledger_backed_resources.gd.uid new file mode 100644 index 0000000..cfb66a0 --- /dev/null +++ b/tests/s2/test_ledger_backed_resources.gd.uid @@ -0,0 +1 @@ +uid://bfd08k411g8a7 diff --git a/tests/s2/test_mana_network_service.gd b/tests/s2/test_mana_network_service.gd new file mode 100644 index 0000000..054eb4b --- /dev/null +++ b/tests/s2/test_mana_network_service.gd @@ -0,0 +1,135 @@ +extends SceneTree + +const ManaNetworkServiceScript = preload("res://scripts/mana/mana_network_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: deterministic Mana network") + _test_graph_and_priority_allocation() + _test_brownout_threshold_and_hold() + _test_storage_flow() + _test_cadence_and_disabled_nodes() + _test_snapshot_and_invariants() + + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_graph_and_priority_allocation() -> void: + var service = ManaNetworkServiceScript.new() + _expect(service.register_source(&"tree", 10), "a source registers with integer output") + _expect(service.register_connector(&"trunk_bus"), "a connector registers independently from the walking graph") + _expect(service.register_consumer(&"clinic", 6, service.ConsumerPriority.ESSENTIAL), "an essential consumer registers") + _expect(service.register_consumer(&"casino", 5, service.ConsumerPriority.LUXURY), "a luxury consumer registers") + service.connect_nodes(&"tree", &"trunk_bus") + service.connect_nodes(&"clinic", &"trunk_bus") + service.connect_nodes(&"casino", &"trunk_bus") + var report: Dictionary = service.evaluate_now(0) + _expect(report[&"evaluated"] and report[&"components"].size() == 1, "connected nodes evaluate as one Mana component") + _expect(service.get_consumer_state(&"clinic") == service.ConsumerState.ACTIVE, "essential demand is supplied first") + _expect(service.get_allocated_mana(&"clinic") == 6, "essential consumer receives its full demand") + _expect(service.get_consumer_state(&"casino") == service.ConsumerState.DEGRADED, "remaining 80 percent supply creates a brownout") + _expect(service.get_allocated_mana(&"casino") == 4, "lower-priority consumer receives only the deterministic remainder") + + var isolated = ManaNetworkServiceScript.new() + isolated.register_source(&"source", 4) + isolated.register_consumer(&"z_consumer", 4, isolated.ConsumerPriority.NORMAL) + isolated.register_consumer(&"a_consumer", 4, isolated.ConsumerPriority.NORMAL) + isolated.connect_nodes(&"source", &"z_consumer") + isolated.connect_nodes(&"source", &"a_consumer") + isolated.evaluate_now(0) + _expect(isolated.get_allocated_mana(&"a_consumer") == 4, "equal priorities use consumer id as a stable tie break") + _expect(isolated.get_allocated_mana(&"z_consumer") == 0, "stable tie break leaves the later consumer unpowered") + + +func _test_brownout_threshold_and_hold() -> void: + var service = ManaNetworkServiceScript.new() + service.register_source(&"source", 75) + service.register_consumer(&"workshop", 100) + service.connect_nodes(&"source", &"workshop") + service.evaluate_now(0) + _expect(service.get_consumer_state(&"workshop") == service.ConsumerState.DEGRADED, "exactly 75 percent supply is DEGRADED") + service.set_source_output(&"source", 74) + service.evaluate_now(10) + _expect(service.get_consumer_state(&"workshop") == service.ConsumerState.NO_POWER, "supply below 75 percent becomes NO_POWER") + _expect(service.get_node(&"workshop")[&"hold_until"] == 20, "NO_POWER starts a ten-minute minimum off hold") + service.set_source_output(&"source", 100) + service.evaluate_now(15) + _expect(service.get_allocated_mana(&"workshop") == 0, "consumer cannot oscillate on during its off hold") + service.evaluate_now(20) + _expect(service.get_consumer_state(&"workshop") == service.ConsumerState.ACTIVE, "consumer recovers at the first evaluation after its hold") + _expect(service.get_allocated_mana(&"workshop") == 100, "recovered consumer receives full demand") + + +func _test_storage_flow() -> void: + var service = ManaNetworkServiceScript.new() + service.register_source(&"source", 4) + service.register_storage(&"battery", 20, 10, 6) + service.register_consumer(&"lights", 10, service.ConsumerPriority.ESSENTIAL) + service.connect_nodes(&"source", &"battery") + service.connect_nodes(&"battery", &"lights") + var discharge_report: Dictionary = service.evaluate_now(0) + _expect(discharge_report[&"components"][0][&"discharged"] == 6, "storage discharge is capped by its max flow") + _expect(service.get_storage_current(&"battery") == 4, "discharged Mana is removed exactly once") + _expect(service.get_consumer_state(&"lights") == service.ConsumerState.ACTIVE, "stored Mana completes essential supply") + service.set_source_output(&"source", 20) + var charge_report: Dictionary = service.evaluate_now(10) + _expect(charge_report[&"components"][0][&"charged"] == 6, "surplus charges storage at its max flow") + _expect(service.get_storage_current(&"battery") == 10, "charging preserves storage capacity invariants") + + +func _test_cadence_and_disabled_nodes() -> void: + var service = ManaNetworkServiceScript.new() + service.register_source(&"source", 5) + service.register_consumer(&"pump", 5) + service.connect_nodes(&"source", &"pump") + _expect(not service.advance_one_minute(1)[&"evaluated"], "Mana allocation skips non-ten-minute ticks") + _expect(service.advance_one_minute(10)[&"evaluated"], "Mana allocation runs on a ten-minute boundary") + _expect(not service.advance_one_minute(10)[&"evaluated"], "the same boundary cannot evaluate twice") + _expect(service.set_node_enabled(&"pump", false), "a consumer can be administratively disabled") + _expect(service.get_consumer_state(&"pump") == service.ConsumerState.DISABLED, "disabled state applies immediately") + service.evaluate_now(20) + _expect(service.get_allocated_mana(&"pump") == 0, "disabled consumer receives no Mana") + _expect(service.set_node_enabled(&"pump", true), "a disabled consumer can be enabled") + service.evaluate_now(30) + _expect(service.get_consumer_state(&"pump") == service.ConsumerState.ACTIVE, "enabled consumer returns at the next evaluation") + + +func _test_snapshot_and_invariants() -> void: + var original = ManaNetworkServiceScript.new() + original.register_source(&"tree", 12) + original.register_connector(&"junction") + original.register_storage(&"crystal", 30, 8, 4) + original.register_consumer(&"room", 10) + original.connect_nodes(&"tree", &"junction") + original.connect_nodes(&"junction", &"crystal") + original.connect_nodes(&"junction", &"room") + original.evaluate_now(10) + var snapshot: Dictionary = original.create_snapshot() + var restored = ManaNetworkServiceScript.new() + _expect(restored.restore_snapshot(snapshot), "Mana graph and runtime state restore from a snapshot") + _expect(restored.create_snapshot() == snapshot, "restored Mana snapshot is byte-structure equivalent") + _expect(restored.node_count() == 4 and restored.edge_count() == 3, "snapshot restores all nodes and undirected edges") + _expect(restored.validate_invariants()[&"valid"], "restored allocation, storage and graph invariants hold") + _expect(restored.unregister_node(&"junction"), "removing a node also removes its incident edges") + _expect(restored.edge_count() == 0, "node removal cannot leave orphan Mana edges") + _expect(restored.validate_invariants()[&"valid"], "node removal keeps graph symmetry valid") + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s2/test_mana_network_service.gd.uid b/tests/s2/test_mana_network_service.gd.uid new file mode 100644 index 0000000..61530aa --- /dev/null +++ b/tests/s2/test_mana_network_service.gd.uid @@ -0,0 +1 @@ +uid://cv3qj8jpvyxd7 diff --git a/tests/s2/test_runtime_operations_cycle.gd b/tests/s2/test_runtime_operations_cycle.gd new file mode 100644 index 0000000..10dd6d8 --- /dev/null +++ b/tests/s2/test_runtime_operations_cycle.gd @@ -0,0 +1,247 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant +var _facility_id: StringName +var _facility_inventory_id: StringName + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S2: runtime supply-to-service cycle") + _reset_runtime() + _build_operational_cafeteria() + _deliver_inputs_through_logistics() + _run_service_sale_with_round_trip() + _test_power_projection() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime() -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, 2202) + _clock.set_paused(true) + _expect(report[&"valid"], "runtime initializes all S2 definitions") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 5000, "starting Gold is initialized through the ledger") + _expect(_runtime.economy_service.validate_ledger()[&"ok"], "initial ledger reconciles") + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _build_operational_cafeteria() -> void: + var build = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT), &"rotation": 0}, + &"player", &"", 50, -1, &"s2.build.cafeteria" + ) + _expect(build.is_success(), "cafeteria construction starts") + _clock.advance_minutes(180) + var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities() + _expect(facilities.size() == 1, "construction produces one authoritative facility") + _facility_id = StringName(facilities[0][&"instance_id"]) + var operational = _runtime.facility_service.get_instance(_facility_id) + _expect(operational != null and operational.definition_id == &"cafeteria", "completed construction registers with FacilityService") + _expect(operational.state == FacilityInstance.State.NO_STAFF, "staff requirement prevents premature opening") + _expect(_runtime.economy_service.has_account(StringName("FACILITY_REVENUE:%s" % _facility_id)), "facility receives a dedicated revenue account") + _expect(not _runtime.mana_network_service.get_node(_facility_id).is_empty(), "Mana demand is registered for the completed facility") + _facility_inventory_id = operational.local_inventory_id + var staffing = _runtime.submit_command( + &"set_facility_conditions", + {&"facility_id": _facility_id, &"conditions": {&"active_staff_ids": [&"cook_1", &"server_1"]}}, + &"manager", _facility_id, 20, -1, &"s2.staff.cafeteria" + ) + _expect(staffing.is_success(), "two active staff can be assigned through a command") + _expect(operational.state == FacilityInstance.State.OPEN, "staffed and powered cafeteria becomes operational") + _expect(_state.get_resource(&"gold") == 4200, "construction Gold is paid once through the authoritative ledger") + _expect(_runtime.economy_service.get_balance(&"CONSTRUCTION_EXPENSE") == 800, "construction counter-account retains the capital flow") + + +func _deliver_inputs_through_logistics() -> void: + var stocked = _runtime.submit_command( + &"add_inventory_stack", + { + &"owner_inventory_id": &"supplier.main", + &"item_id": &"ingredient/basic_meal", + &"quantity": 3, + &"quality": 70, + &"unit_weight": 1, + }, + &"supplier", &"supplier.main", 10, -1, &"s2.stock.ingredients" + ) + _expect(stocked.is_success(), "supplier stock enters InventoryService") + var created = _runtime.submit_command( + &"create_logistics_request", + { + &"request_id": &"delivery_meals_1", + &"source_inventory_id": &"supplier.main", + &"destination_inventory_id": _facility_inventory_id, + &"item_requirements": {&"ingredient/basic_meal": 2}, + &"priority": 80, + &"deadline": _clock.absolute_minute + 120, + }, + &"manager", _facility_id, 10, -1, &"s2.logistics.create" + ) + _expect(created.is_success(), "logistics request reserves supplier stock") + var logistics: LogisticsRequest = _runtime.logistics_requests[&"delivery_meals_1"] + _expect(logistics.state == LogisticsRequest.State.RESERVED and logistics.total_weight == 2, "request records reserved weight and state") + _expect(_runtime.inventory_service.available_quantity(&"supplier.main", &"ingredient/basic_meal") == 1, "reserved supplier stock cannot be reused") + + var dispatched = _runtime.submit_command( + &"dispatch_logistics", + {&"carriers": { + &"porter_1": {&"capacity": 10, &"current_load": 0, &"path_minutes": 2, &"role": &"logistics"}, + &"porter_2": {&"capacity": 10, &"current_load": 0, &"path_minutes": 4, &"role": &"carrier"}, + }}, + &"dispatcher", &"delivery_meals_1", 10, -1, &"s2.logistics.dispatch" + ) + _expect(dispatched.is_success(), "dispatcher evaluates the reserved request") + _expect(logistics.assigned_carrier_id == &"porter_1", "deterministic score chooses the suitable nearest carrier") + _expect(logistics.state == LogisticsRequest.State.ASSIGNED, "dispatch advances request to ASSIGNED") + + var start_pickup = _advance_delivery(&"s2.logistics.to_pickup") + _expect(start_pickup.is_success() and logistics.state == LogisticsRequest.State.PICKING_UP, "assigned carrier begins pickup") + var picked_up = _advance_delivery(&"s2.logistics.pickup") + _expect(picked_up.is_success() and logistics.state == LogisticsRequest.State.DELIVERING, "source arrival transfers reserved stock into cargo") + _expect(_runtime.inventory_service.total_quantity(&"cargo:porter_1", &"ingredient/basic_meal") == 2, "cargo owns exactly the picked quantity") + _expect(_runtime.inventory_service.total_quantity(&"supplier.main", &"ingredient/basic_meal") == 1, "pickup subtracts source stock without duplication") + var delivered = _advance_delivery(&"s2.logistics.delivery") + _expect(delivered.is_success() and logistics.state == LogisticsRequest.State.COMPLETED, "destination arrival completes logistics") + _expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 2, "facility inventory receives the delivered inputs") + _expect(_runtime.inventory_service.total_quantity(&"cargo:porter_1", &"ingredient/basic_meal") == 0, "completed delivery leaves no duplicated cargo") + _expect(_runtime.logistics_dispatcher.get_reserved_load(&"porter_1") == 0, "carrier capacity releases on completion") + _expect(_runtime.inventory_service.validate_invariants()[&"ok"], "delivery preserves every inventory invariant") + + +func _advance_delivery(idempotency_key: StringName) -> CommandResult: + return _runtime.submit_command( + &"advance_logistics_request", + {&"request_id": &"delivery_meals_1"}, + &"porter_1", &"delivery_meals_1", 10, -1, idempotency_key + ) + + +func _run_service_sale_with_round_trip() -> void: + var wallet = _runtime.submit_command( + &"register_guest_wallet", + {&"guest_id": &"guest_1", &"gold": 100}, + &"guest_1", &"guest_1", 10, -1, &"s2.guest.wallet" + ) + _expect(wallet.is_success(), "guest wallet is initialized through SYSTEM_SOURCE") + var requested = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"meal_request_1", + &"facility_id": _facility_id, + &"guest_ids": [&"guest_1"], + &"service_type": &"meal", + &"options": {&"service_minutes": 3}, + }, + &"guest_1", _facility_id, 10, -1, &"s2.service.request" + ) + _expect(requested.is_success(), "service request reserves its recipe input and enters the queue") + _expect(_runtime.inventory_service.available_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 1, "queued service protects one ingredient from reuse") + _clock.advance_one_minute() + var active: Array = _runtime.facility_service.get_active_services(_facility_id) + _expect(active.size() == 1, "facility queue fills an available service slot") + var service_id: StringName = active[0].service_id + _expect(active[0].price_snapshot == 35 and active[0].duration_minutes == 3, "service freezes quoted price and duration") + + var mid_snapshot: Dictionary = _runtime.create_snapshot() + var mid_digest := SimulationDigest.compute(mid_snapshot) + _clock.advance_minutes(3) + var completed: ServiceInstance = _runtime.facility_service.get_service(service_id) + _expect(completed != null and completed.state == ServiceInstance.State.COMPLETED, "three active minutes complete the service") + _expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 1, "completion consumes exactly one reserved ingredient") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == 65, "guest pays the immutable quoted price") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4235, "service cash sweep increases player Gold") + _expect(_state.get_resource(&"gold") == 4235, "UI Gold mirror follows the ledger transaction") + _expect(_runtime.economy_service.validate_ledger()[&"ok"], "service sale remains double-entry balanced") + var saw_completion := false + for event in _runtime.domain_events.recent_events(): + if event.event_type == &"SERVICE_COMPLETED" and service_id in event.entity_ids: + saw_completion = true + _expect(saw_completion, "service completion is projected as a domain event") + var final_digest: String = _runtime.latest_digest + + _expect(_runtime.restore_snapshot(mid_snapshot), "mid-service snapshot restores all S2 services") + _expect(_runtime.latest_digest == mid_digest, "mid-service restore reproduces the exact digest") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == 100, "restore rolls payment back to the pre-completion ledger") + _clock.advance_minutes(3) + _expect(_runtime.latest_digest == final_digest, "replaying completion reproduces inventory, ledger, events and IDs exactly") + _expect(_runtime.economy_service.get_entries().filter(func(entry: LedgerEntry) -> bool: return entry.category == &"SERVICE_SALE").size() == 1, "replayed service sale is recorded exactly once") + + var no_stock = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"meal_request_too_many", + &"facility_id": _facility_id, + &"guest_ids": [&"guest_1"], + &"options": {&"service_minutes": 20}, + }, + &"guest_1", _facility_id, 10, -1, &"s2.service.second" + ) + _expect(no_stock.is_success(), "remaining delivered input can fund one more queued service") + var before_rejected: Dictionary = _runtime.inventory_service.create_snapshot() + var rejected = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"meal_request_no_stock", + &"facility_id": _facility_id, + &"guest_ids": [&"guest_1"], + }, + &"guest_1", _facility_id, 10, -1, &"s2.service.no_stock" + ) + _expect(not rejected.is_success(), "service without available recipe input is rejected") + _expect(_runtime.inventory_service.create_snapshot() == before_rejected, "rejected service request leaves reservations and stock unchanged") + + +func _test_power_projection() -> void: + _runtime.mana_network_service.set_source_output(_runtime.STARTER_MANA_SOURCE, 0) + var minutes_to_boundary: int = 10 - (_clock.absolute_minute % 10) + _clock.advance_minutes(minutes_to_boundary) + var facility = _runtime.facility_service.get_instance(_facility_id) + _expect(_runtime.mana_network_service.get_consumer_state(_facility_id) == ManaNetworkService.ConsumerState.NO_POWER, "Mana shortage shuts the cafeteria consumer down") + _expect(facility.state == FacilityInstance.State.NO_POWER, "Mana consumer state projects into FacilityService") + var interrupted_request_id := &"meal_request_too_many" + var interrupted_services: Array = _runtime.facility_service.get_active_services(_facility_id) + _expect( + _runtime.facility_service.get_request(interrupted_request_id) != null \ + and interrupted_services.size() == 1 \ + and interrupted_services[0].state == ServiceInstance.State.PAUSED, + "power loss safely pauses the active service without losing its request" + ) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s2/test_runtime_operations_cycle.gd.uid b/tests/s2/test_runtime_operations_cycle.gd.uid new file mode 100644 index 0000000..e9a75c9 --- /dev/null +++ b/tests/s2/test_runtime_operations_cycle.gd.uid @@ -0,0 +1 @@ +uid://d2yqh462oc5t2 diff --git a/tests/s3/test_guest_service.gd b/tests/s3/test_guest_service.gd new file mode 100644 index 0000000..ca0640b --- /dev/null +++ b/tests/s3/test_guest_service.gd @@ -0,0 +1,778 @@ +extends SceneTree + +const GuestDefinitionType = preload("res://scripts/guest/guest_definition.gd") +const GuestRuntimeType = preload("res://scripts/guest/guest_runtime.gd") +const GuestServiceType = preload("res://scripts/guest/guest_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S3: deterministic Guest AI") + _test_definition_and_runtime_contract() + _test_need_updates_and_single_clamp() + _test_need_context_and_adult_gate() + _test_shared_need_context_fast_paths() + _test_batched_need_updates_match_individual_api() + _test_candidate_filtering() + _test_integer_utility_and_stable_tie_break() + _test_state_machine_and_loop_guard() + _test_atomic_queue_abandonment() + _test_service_interruption_is_idempotent() + _test_service_need_effect_schema() + _test_path_failure_safe_recovery() + _test_exit_persistence_boundary() + _test_snapshot_round_trip_and_atomic_rejection() + _test_v1_need_migration_and_schema_rejection() + _test_deterministic_decision_snapshot() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_definition_and_runtime_contract() -> void: + var definition := GuestDefinitionType.new().configure(&"adventurer", { + &"tags": [&"adventurer", &"general", &"general"], + &"wealth_min": 100, + &"wealth_max": 500, + &"base_patience": 45, + &"risk_tolerance": 80, + &"preference_weights": {&"meal": 8, &"quiet": -2}, + &"group_size_weights": {1: 80, 2: 20}, + &"legal_sensitivity": 60, + &"quality_sensitivity": 70, + &"adult_verified": false, + &"need_rates": {&"hunger": 3, &"sleep": -2, &"leisure": 1}, + &"credit_limit_gold": 100, + &"minimum_quality": 40, + }) + _expect(definition.is_valid(), "complete guest definition validates") + _expect(definition.tags == [&"adventurer", &"general"], "definition tags are unique and stable-sorted") + _expect(definition.get_need_rate(&"intimacy") == 0, "omitted canonical Need rates normalize to zero") + _expect(definition.need_rates.size() == 6, "definition always materializes the six canonical Need IDs") + var round_trip := GuestDefinitionType.from_snapshot(definition.create_snapshot()) + _expect(round_trip != null, "definition reconstructs from its canonical snapshot") + _expect(round_trip.create_snapshot() == definition.create_snapshot(), "definition snapshot round trip is exact") + var legacy_definition := GuestDefinitionType.new().configure(&"legacy_alias", { + &"need_rates": {&"rest": 1}, + }) + _expect(not legacy_definition.is_valid(), "legacy Definition Need aliases are invalid outside v1 migration") + var legacy_runtime := GuestRuntimeType.new().configure(&"legacy_guest", &"adventurer", { + &"needs": {&"social": 10}, + }) + _expect(not legacy_runtime.is_valid(), "legacy Runtime Need aliases are invalid outside v1 migration") + var coerced_adult := GuestDefinitionType.new().configure(&"coerced_adult", { + &"adult_verified": "true", + }) + _expect(not coerced_adult.is_valid(), "adult verification cannot be supplied through truthy string coercion") + + var service := GuestServiceType.new() + _expect(service.register_definition(definition), "valid definition registers") + _expect(not service.register_definition(definition), "duplicate definition ID is rejected") + var guest := service.spawn_guest(&"guest_contract", &"adventurer", 10, { + &"planned_exit_at": 200, + &"current_cell": Vector3i(2, 3, 0), + }) + _expect(guest != null and guest.is_valid(), "guest runtime spawns with required authoritative fields") + _expect(guest.wallet_gold == 100 and guest.credit_limit_gold == 100, "definition supplies deterministic wallet floor and credit limit") + _expect(guest.patience_remaining == 45, "definition supplies base queue patience") + _expect(service.validate_invariants()[&"valid"], "fresh GuestService invariants hold") + + +func _test_need_updates_and_single_clamp() -> void: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"guest_needs", &"adventurer", 0, { + &"state": GuestRuntimeType.State.MOVING, + &"needs": { + &"hunger": 99, + &"sleep": 1, + &"hygiene": 99, + &"medical": 99, + &"leisure": 99, + &"intimacy": 99, + }, + &"planned_exit_at": 500, + }) + var report: Dictionary = service.update_needs(guest.guest_id, { + &"dirt": 2, + &"injury": 2, + &"illness": 2, + &"need_effects": { + &"hunger": 5, + &"sleep": -5, + &"hygiene": 3, + &"medical": 3, + &"leisure": 3, + &"intimacy": 3, + }, + }) + _expect(report[&"ok"], "Need update succeeds for a live guest") + _expect(report[&"deltas"]["hunger"] == 10, "base, movement and external hunger effects add before clamp") + _expect(report[&"deltas"]["sleep"] == -5, "positive and negative sleep effects combine before clamp") + _expect(guest.needs[&"hunger"] == 100 and guest.needs[&"sleep"] == 0, "Need upper and lower bounds clamp once") + _expect(guest.needs[&"hygiene"] == 100 and guest.needs[&"medical"] == 100, "dirt, injury and illness update their distinct Needs") + _expect(guest.needs[&"leisure"] == 100 and guest.needs[&"intimacy"] == 100, "leisure and intimacy effects use canonical IDs") + _expect(service.advance_one_minute(1)[&"ok"], "one-minute GuestService cadence advances") + _expect(not service.advance_one_minute(1)[&"ok"], "the same minute cannot apply Need drift twice") + + +func _test_need_context_and_adult_gate() -> void: + var service := _service_with_definitions() + var verified := service.spawn_guest(&"verified_context", &"verified_noble", 0, { + &"needs": {&"hunger": 0, &"sleep": 0, &"hygiene": 10, &"medical": 20, &"leisure": 0, &"intimacy": 30}, + &"planned_exit_at": 500, + }) + var unverified := service.spawn_guest(&"unverified_context", &"adventurer", 0, { + &"needs": {&"hunger": 0, &"sleep": 0, &"hygiene": 10, &"medical": 20, &"leisure": 0, &"intimacy": 30}, + &"planned_exit_at": 500, + }) + var day := service.update_needs(verified.guest_id, { + &"unstimulated": false, + &"dirt": 5, + &"injury": 4, + &"illness": 3, + &"is_night": false, + &"intimacy_day": 3, + &"intimacy_night": 7, + }) + _expect(day[&"deltas"]["hygiene"] == 5, "integer dirt context raises Hygiene only") + _expect(day[&"deltas"]["medical"] == 7, "injury and illness combine into Medical") + _expect(day[&"deltas"]["intimacy"] == 3, "day context uses the adult-verified Intimacy day rate") + var night := service.update_needs(verified.guest_id, { + &"unstimulated": false, + &"is_night": true, + &"intimacy_day": 3, + &"intimacy_night": 7, + }) + _expect(night[&"deltas"]["intimacy"] == 7, "night context selects the separate Intimacy night rate") + var blocked := service.update_needs(unverified.guest_id, { + &"unstimulated": false, + &"is_night": true, + &"intimacy_night": 7, + }) + _expect(blocked[&"deltas"]["intimacy"] == 0, "unverified guests cannot receive contextual adult Intimacy drift") + + var before_invalid := service.create_snapshot() + var wrong_type := service.update_needs(verified.guest_id, {&"injury": "4"}) + _expect(not wrong_type[&"ok"], "stringified Need context integers are rejected") + _expect(service.create_snapshot() == before_invalid, "invalid context leaves live Need state untouched") + var legacy_alias := service.update_needs(verified.guest_id, { + &"need_effects": {&"rest": -5}, + }) + _expect(not legacy_alias[&"ok"], "legacy Need aliases are rejected outside snapshot migration") + _expect(service.create_snapshot() == before_invalid, "legacy context rejection is atomic") + + +func _test_batched_need_updates_match_individual_api() -> void: + var batched := _service_with_definitions() + batched.spawn_guest(&"z_guest", &"adventurer", 0, { + &"state": GuestRuntimeType.State.MOVING, + &"group_id": &"party", + &"needs": {&"hunger": 10, &"sleep": 20, &"hygiene": 30, &"medical": 40, &"leisure": 50, &"intimacy": 60}, + &"planned_exit_at": 500, + }) + batched.spawn_guest(&"a_guest", &"verified_noble", 0, { + &"needs": {&"hunger": 5, &"sleep": 15, &"hygiene": 25, &"medical": 35, &"leisure": 45, &"intimacy": 55}, + &"planned_exit_at": 500, + }) + var individual := GuestServiceType.new() + individual.restore_snapshot(batched.create_snapshot()) + var default_context := { + &"unstimulated": false, + &"dirt": 1, + &"need_effects": {&"sleep": 3}, + } + var z_context := { + &"solo_guest": true, + &"injury": 2, + &"need_effects": {&"hunger": -1, &"leisure": 4}, + } + var expected_reports: Array[Dictionary] = [ + individual.update_needs(&"a_guest", default_context), + individual.update_needs(&"z_guest", z_context), + ] + individual.current_minute = 1 + var batch_report := batched.advance_one_minute(1, { + &"default": default_context, + "z_guest": z_context, + }) + _expect( + batch_report[&"guest_reports"] == expected_reports, + "batched Need cadence preserves individual report values and stable guest-id order" + ) + _expect( + batched.create_snapshot() == individual.create_snapshot(), + "optimized batched Need mutation is snapshot-equivalent to individual updates" + ) + + +func _test_shared_need_context_fast_paths() -> void: + var source := _service_with_definitions() + for guest_id: StringName in [&"z_guest", &"a_guest"]: + source.spawn_guest(guest_id, &"adventurer", 0, { + &"state": GuestRuntimeType.State.MOVING, + &"needs": { + &"hunger": 10, + &"sleep": 20, + &"hygiene": 30, + &"medical": 40, + &"leisure": 50, + &"intimacy": 60, + }, + &"planned_exit_at": 500, + }) + var start := source.create_snapshot() + + var empty_fast := GuestServiceType.new() + var empty_reference := GuestServiceType.new() + empty_fast.restore_snapshot(start) + empty_reference.restore_snapshot(start) + var empty_report := empty_fast.advance_one_minute(1) + var empty_reference_report := empty_reference.advance_one_minute(1, { + &"default": {}, + &"a_guest": {}, + &"z_guest": {}, + }) + _expect( + empty_report == empty_reference_report, + "empty shared Need context preserves the preflight-path report exactly" + ) + _expect( + empty_fast.create_snapshot() == empty_reference.create_snapshot(), + "empty shared Need context preserves the preflight-path snapshot exactly" + ) + + var common_context := { + &"unstimulated": false, + &"dirt": 2, + &"injury": 1, + &"need_effects": {&"sleep": 3, &"leisure": -2}, + } + var default_fast := GuestServiceType.new() + var default_reference := GuestServiceType.new() + default_fast.restore_snapshot(start) + default_reference.restore_snapshot(start) + var default_report := default_fast.advance_one_minute(1, { + &"default": common_context, + }) + var default_reference_report := default_reference.advance_one_minute(1, { + &"default": common_context, + &"a_guest": common_context, + &"z_guest": common_context, + }) + _expect( + default_report == default_reference_report, + "default-only shared Need context preserves the override-path report exactly" + ) + _expect( + default_fast.create_snapshot() == default_reference.create_snapshot(), + "default-only shared Need context preserves the override-path snapshot exactly" + ) + + var invalid_default := GuestServiceType.new() + invalid_default.restore_snapshot(start) + var before_invalid := invalid_default.create_snapshot() + var invalid_report := invalid_default.advance_one_minute(1, { + &"default": {&"injury": "1"}, + }) + _expect(not invalid_report[&"ok"], "invalid default-only Need context is rejected") + _expect( + invalid_default.create_snapshot() == before_invalid, + "invalid default-only Need context is atomic across the population and minute" + ) + + var malformed_default := GuestServiceType.new() + var malformed_reference := GuestServiceType.new() + malformed_default.restore_snapshot(start) + malformed_reference.restore_snapshot(start) + var malformed_report := malformed_default.advance_one_minute(1, { + &"default": "legacy malformed default", + }) + var malformed_reference_report := malformed_reference.advance_one_minute(1) + _expect( + malformed_report == malformed_reference_report, + "non-Dictionary default retains the legacy empty-context meaning" + ) + _expect( + malformed_default.create_snapshot() == malformed_reference.create_snapshot(), + "malformed-default compatibility remains snapshot-exact" + ) + + +func _test_candidate_filtering() -> void: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"guest_filter", &"adventurer", 0, { + &"wallet_gold": 500, + &"credit_limit_gold": 100, + &"planned_exit_at": 1_000, + }) + var candidates: Array = [ + _candidate(&"cannot_pay", {&"price_gold": 501}), + _candidate(&"adult_room", {&"adult_only": true}), + _candidate(&"unreachable", {&"reachable": false}), + _candidate(&"closed", {&"is_open": false}), + _candidate(&"too_slow", {&"wait_minutes": 120}), + _candidate(&"too_risky", {&"risk_level": 81}), + _candidate(&"too_illegal", {&"illegal_level": 41}), + _candidate(&"too_poor", {&"expected_quality": 39}), + _candidate(&"legacy_relief", {&"need_relief": {&"fun": 40}}), + _candidate(&"legacy_effects", {&"need_effects": {&"safety": -20}}), + _candidate(&"canonical_effects", {&"need_effects": {&"medical": -20}}), + _candidate(&"credit_ok", {&"price_gold": 550, &"credit_allowed": true}), + _candidate(&"normal_ok"), + ] + var filtered := service.extract_candidates(guest.guest_id, candidates, { + &"absolute_minute": 10, + &"adult_content_enabled": true, + &"wait_hard_limit": 120, + }) + _expect(_candidate_ids(filtered) == [&"canonical_effects", &"credit_ok", &"normal_ok"], "candidate extraction applies every hard filter before Utility") + var reasons := _rejection_map(service.get_last_candidate_rejections(guest.guest_id)) + _expect(reasons[&"cannot_pay"] == &"CANNOT_PAY", "SYS-GST-001 rejects an unaffordable facility") + _expect(reasons[&"adult_room"] == &"ADULT_NOT_VERIFIED", "SYS-GST-002 rejects restricted content before Utility") + _expect(reasons[&"unreachable"] == &"UNREACHABLE", "unreachable facility is removed") + _expect(reasons[&"too_slow"] == &"WAIT_HARD_LIMIT", "wait equal to the hard limit is removed") + _expect(reasons[&"too_risky"] == &"RISK_REFUSED", "risk above guest tolerance is removed") + _expect(reasons[&"too_illegal"] == &"ILLEGALITY_REFUSED", "illegal level above sensitivity threshold is removed") + _expect(reasons[&"too_poor"] == &"QUALITY_TOO_LOW", "quality below guest minimum is removed") + _expect(reasons[&"legacy_relief"] == &"INVALID_NEED_MAP", "candidate Need maps cannot use legacy aliases") + _expect(reasons[&"legacy_effects"] == &"INVALID_NEED_MAP", "candidate need_effects also reject migration-only aliases") + guest.wallet_gold = 100 + var reserve_filtered := service.extract_candidates(guest.guest_id, [ + _candidate(&"meal_with_cash", {&"price_gold": 80}), + _candidate(&"leisure_below_reserve", { + &"price_gold": 80, + &"service_category": &"entertainment", + }), + ], {&"absolute_minute": 10}) + _expect(_candidate_ids(reserve_filtered) == [&"meal_with_cash"], "discretionary spending preserves the personality-based survival reserve") + guest.wallet_gold = 500 + + var verified := service.spawn_guest(&"guest_verified", &"verified_noble", 0, { + &"wallet_gold": 1_000, + &"planned_exit_at": 1_000, + }) + var adult_candidates := service.extract_candidates(verified.guest_id, [ + _candidate(&"adult_room", {&"adult_only": true}), + ], {&"adult_content_enabled": true, &"absolute_minute": 1}) + _expect(adult_candidates.size() == 1, "verified adult guest can retain a permitted restricted candidate") + var content_blocked := service.extract_candidates(verified.guest_id, [ + _candidate(&"adult_room", {&"adult_only": true}), + ], {&"adult_content_enabled": false, &"absolute_minute": 1}) + _expect(content_blocked.is_empty(), "Content Filter still overrides verified adult status") + + var callback_order: Array[StringName] = [] + service.extract_candidates(guest.guest_id, [ + _candidate(&"z_path"), + _candidate(&"a_path"), + ], { + &"absolute_minute": 1, + &"reachability_callback": func(_runtime: GuestRuntime, facility: Dictionary, _context: Dictionary) -> bool: + callback_order.append(facility[&"facility_id"]) + return true, + }) + _expect(callback_order == [&"a_path", &"z_path"], "reachability callbacks run in stable facility-ID order") + + +func _test_integer_utility_and_stable_tie_break() -> void: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"guest_utility", &"adventurer", 0, { + &"wallet_gold": 1_000, + &"planned_exit_at": 1_000, + &"state": GuestRuntimeType.State.DECIDING, + &"needs": {&"hunger": 90}, + }) + var shared := { + &"need_relief": 40, + &"preference": 2, + &"expected_quality": 3, + &"novelty": 0, + &"group_fit": 4, + &"price_burden": 5, + &"travel_minutes": 6, + &"wait_minutes": 7, + &"risk": 8, + &"risk_sensitivity": 2, + &"repetition_penalty": 9, + &"urgency": 90, + &"minimum_quality": 0, + } + var candidate_z := shared.duplicate(true) + candidate_z[&"facility_id"] = &"z_room" + var candidate_a := shared.duplicate(true) + candidate_a[&"facility_id"] = &"a_room" + var evaluated := service.evaluate_candidate(guest.guest_id, candidate_z) + _expect(evaluated[&"utility"] is int, "Utility result is an integer") + _expect(evaluated[&"utility"] == 264, "Utility follows the documented integer term equation exactly") + var ranked := service.rank_candidates(guest.guest_id, [candidate_z, candidate_a]) + _expect(ranked[0][&"facility_id"] == &"a_room", "SYS-GST-003 resolves a full Utility tie by facility_id") + var decision := service.decide(guest.guest_id, [candidate_z, candidate_a], 1, { + &"exit_threshold": 15, + }) + _expect(decision[&"ok"] and decision[&"candidate"][&"facility_id"] == &"a_room", "decision reserves the stable winning candidate") + _expect(guest.state == GuestRuntimeType.State.MOVING and guest.target_facility_id == &"a_room", "successful decision enters MOVING with one target") + + +func _test_state_machine_and_loop_guard() -> void: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"guest_states", &"adventurer", 0, { + &"planned_exit_at": 500, + }) + _expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.ENTERING, 10)[&"ok"], "SPAWNING enters ENTERING") + _expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.DECIDING, 10)[&"ok"], "ENTERING enters DECIDING") + _expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.MOVING, 10)[&"ok"], "DECIDING enters MOVING") + _expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.REPLANNING, 10)[&"ok"], "MOVING enters REPLANNING") + var guarded := service.transition_guest(guest.guest_id, GuestRuntimeType.State.DECIDING, 10) + _expect(not guarded[&"ok"] and guarded[&"loop_guard"], "fifth transition in one Tick activates AI loop guard") + _expect(guest.state == GuestRuntimeType.State.DECIDING, "AI loop guard stops the guest in DECIDING") + _expect(service.get_diagnostics()[-1][&"code"] == &"AI_LOOP_GUARD", "loop guard leaves a diagnostic record") + var invalid := service.transition_guest(guest.guest_id, GuestRuntimeType.State.DESPAWNING, 11) + _expect(not invalid[&"ok"], "invalid state-machine edge is rejected") + + +func _test_atomic_queue_abandonment() -> void: + var service := _service_with_definitions() + var guest := _moving_guest(service, &"guest_queue") + var enqueue_calls := [0] + var entered := service.enter_queue( + guest.guest_id, + &"meal_room", + &"request_queue", + 4, + 1, + func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> bool: + enqueue_calls[0] += 1 + return true + ) + _expect(entered[&"ok"] and enqueue_calls[0] == 1, "queue entry crosses the Callable boundary once") + _expect(guest.state == GuestRuntimeType.State.QUEUING and guest.patience_remaining == 1, "queue entry records facility, request and initial patience") + var failed_calls := [0] + var failed_leave := service.tick_queue_patience( + guest.guest_id, + 5, + 0, + func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> bool: + failed_calls[0] += 1 + return false + ) + _expect(not failed_leave[&"ok"] and failed_calls[0] == 1, "external queue removal failure is surfaced") + _expect(guest.state == GuestRuntimeType.State.QUEUING and guest.patience_remaining == 1, "failed queue removal leaves guest state completely unchanged") + _expect(guest.target_facility_id == &"meal_room" and guest.queue_request_id == &"request_queue", "failed queue removal retains both cross-service references") + var successful_calls := [0] + var abandoned := service.tick_queue_patience( + guest.guest_id, + 5, + 0, + func(callback_guest_id: StringName, facility_id: StringName, request_id: StringName) -> Dictionary: + successful_calls[0] += 1 + return {&"ok": callback_guest_id == guest.guest_id and facility_id == &"meal_room" and request_id == &"request_queue"} + ) + _expect(abandoned[&"ok"] and abandoned[&"abandoned"] and successful_calls[0] == 1, "SYS-GST-004 removes an exhausted guest through one atomic callback") + _expect(guest.state == GuestRuntimeType.State.ABANDONING and guest.patience_remaining == 0, "successful patience exhaustion enters ABANDONING") + _expect(guest.target_facility_id.is_empty() and guest.queue_request_id.is_empty(), "atomic abandonment clears facility and queue ownership together") + + +func _test_service_interruption_is_idempotent() -> void: + var service := _service_with_definitions() + var guest := _moving_guest(service, &"guest_interrupt") + _expect(service.begin_service(guest.guest_id, &"bath", &"service_1", 4)[&"ok"], "moving guest begins a facility service") + var callback_calls := [0] + var first := service.interrupt_service( + guest.guest_id, + &"service_1", + 5, + func(_guest_id: StringName, _service_id: StringName) -> bool: + callback_calls[0] += 1 + return true + ) + var replay := service.interrupt_service( + guest.guest_id, + &"service_1", + 5, + func(_guest_id: StringName, _service_id: StringName) -> bool: + callback_calls[0] += 1 + return true + ) + _expect(first[&"ok"] and not first[&"replayed"], "first facility stop interrupts the active service") + _expect(replay[&"ok"] and replay[&"replayed"], "duplicate facility stop is an idempotent replay") + _expect(callback_calls[0] == 1, "SYS-GST-005 invokes interruption settlement only once") + _expect(guest.state == GuestRuntimeType.State.INTERRUPTED and guest.current_service_id.is_empty(), "interruption frees guest service ownership") + + +func _test_service_need_effect_schema() -> void: + var service := _service_with_definitions() + var guest := _moving_guest(service, &"guest_effect_schema") + guest.needs[&"sleep"] = 95 + guest.needs[&"hygiene"] = 5 + _expect(service.begin_service(guest.guest_id, &"bath", &"service_effects", 4)[&"ok"], "canonical Need-effect fixture begins service") + var before_invalid := service.create_snapshot() + var invalid := service.complete_service( + guest.guest_id, + &"service_effects", + 5, + 80, + {&"rest": -50} + ) + _expect(not invalid[&"ok"], "service completion rejects a legacy Need-effect alias") + _expect(service.create_snapshot() == before_invalid, "invalid service effects do not partially complete service") + var completed := service.complete_service( + guest.guest_id, + &"service_effects", + 5, + 120, + {&"sleep": 20, &"hygiene": -20} + ) + _expect(completed[&"ok"] and completed[&"satisfaction_sample"] == 100, "canonical service effects complete with satisfaction clamp") + _expect(guest.needs[&"sleep"] == 100 and guest.needs[&"hygiene"] == 0, "service Need effects clamp each canonical axis once") + + +func _test_path_failure_safe_recovery() -> void: + var service := _service_with_definitions() + var guest := _moving_guest(service, &"guest_path") + var recovery_targets: Array = [ + {&"kind": &"emergency_exit", &"cell": Vector3i(9, 0, 0)}, + {&"kind": &"entrance", &"cell": Vector3i(5, 0, 0)}, + {&"kind": &"guidance", &"cell": Vector3i(2, 0, 0)}, + ] + var reachable := func(_from: Vector3i, target: Vector3i, _guest_id: StringName, _data: Dictionary) -> bool: + return target != Vector3i(2, 0, 0) + var first := service.record_path_failure(guest.guest_id, 4, recovery_targets, reachable) + var second := service.record_path_failure(guest.guest_id, 5, recovery_targets, reachable) + _expect(first[&"failure_count"] == 1 and not first[&"recovery_started"], "first path failure enters REPLANNING") + _expect(second[&"failure_count"] == 2 and not second[&"recovery_started"], "second path failure remains recoverable") + var third := service.record_path_failure(guest.guest_id, 6, recovery_targets, reachable) + _expect(third[&"ok"] and third[&"recovery_started"], "SYS-GST-007 starts safe recovery on the third path failure") + _expect(guest.state == GuestRuntimeType.State.LEAVING, "third failure cannot leave the guest stuck in REPLANNING") + _expect(guest.recovery_kind == &"entrance" and guest.recovery_target_cell == Vector3i(5, 0, 0), "recovery tries guidance, entrance, then emergency exit in order") + _expect(service.get_diagnostics()[-1][&"code"] == &"GUEST_PATH_RECOVERY", "safe path recovery emits a diagnostic record") + + var fallback_service := _service_with_definitions() + var fallback_guest := _moving_guest(fallback_service, &"guest_fallback") + fallback_service.record_path_failure(fallback_guest.guest_id, 4) + fallback_service.record_path_failure(fallback_guest.guest_id, 5) + var fallback := fallback_service.record_path_failure(fallback_guest.guest_id, 6, [], Callable()) + _expect(fallback[&"recovery_kind"] == &"emergency_despawn", "no-route case uses a virtual emergency egress rather than deadlocking") + + +func _test_exit_persistence_boundary() -> void: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"guest_exit", &"adventurer", 0, { + &"state": GuestRuntimeType.State.LEAVING, + &"wallet_gold": 777, + &"debt_gold": 90, + &"satisfaction_total": 180, + &"satisfaction_samples": 2, + &"unresolved_incident_ids": [&"incident_b", &"incident_a"], + &"outstanding_reservation_ids": [&"reservation_1"], + &"planned_exit_at": 500, + }) + var result := service.despawn_guest(guest.guest_id, 1) + _expect(result[&"ok"] and not service.has_guest(guest.guest_id), "leaving guest runtime is released after exit") + var record := service.get_persistent_record(guest.guest_id) + _expect(record[&"debt_gold"] == 90, "SYS-GST-006 preserves unresolved debt") + _expect(record[&"unresolved_incident_ids"] == ["incident_a", "incident_b"], "SYS-GST-006 preserves unresolved incidents") + _expect(record[&"outstanding_reservation_ids"] == ["reservation_1"], "SYS-GST-006 preserves outstanding reservations") + _expect(not record.has(&"wallet_gold") and not record.has(&"needs") and not record.has(&"satisfaction_total"), "ordinary transient guest state does not survive despawn") + + +func _test_snapshot_round_trip_and_atomic_rejection() -> void: + var original := _service_with_definitions() + var moving := _moving_guest(original, &"guest_saved_moving") + var queued := _moving_guest(original, &"guest_saved_queue") + original.enter_queue(queued.guest_id, &"cafeteria", &"request_saved", 4, 12) + moving.path_failure_count = 1 + moving.suspicion = 33 + original.advance_one_minute(20) + var snapshot := original.create_snapshot() + var restored := GuestServiceType.new() + _expect(restored.restore_snapshot(snapshot), "GuestService restores a valid snapshot") + _expect(restored.create_snapshot() == snapshot, "GuestService snapshot round trip is byte-structure equivalent") + _expect(restored.validate_invariants()[&"valid"], "restored guest state satisfies every invariant") + + var before_corrupt := restored.create_snapshot() + var corrupt_need := snapshot.duplicate(true) + corrupt_need[&"guests"][0][&"needs"]["hunger"] = 101 + _expect(not restored.restore_snapshot(corrupt_need), "out-of-range Need rejects snapshot restore") + _expect(restored.create_snapshot() == before_corrupt, "rejected Need snapshot leaves live state untouched") + var duplicate_guest := snapshot.duplicate(true) + duplicate_guest[&"guests"].append(duplicate_guest[&"guests"][0].duplicate(true)) + _expect(not restored.restore_snapshot(duplicate_guest), "duplicate guest ID rejects snapshot restore") + _expect(restored.create_snapshot() == before_corrupt, "duplicate rejection is also atomic") + var orphan_type := snapshot.duplicate(true) + orphan_type[&"guests"][0][&"guest_type_id"] = "missing_type" + _expect(not restored.restore_snapshot(orphan_type), "unknown GuestDefinition reference rejects restore") + _expect(restored.create_snapshot() == before_corrupt, "orphan-reference rejection preserves live state") + + +func _test_v1_need_migration_and_schema_rejection() -> void: + var source := _service_with_definitions() + source.spawn_guest(&"guest_legacy", &"verified_noble", 0, { + &"needs": {&"hunger": 11, &"sleep": 22, &"hygiene": 0, &"medical": 44, &"leisure": 55, &"intimacy": 66}, + &"planned_exit_at": 500, + }) + var canonical := source.create_snapshot() + var legacy := canonical.duplicate(true) + legacy[&"version"] = 1 + for definition: Dictionary in legacy[&"definitions"]: + var rates: Dictionary = definition[&"need_rates"] + definition[&"need_rates"] = { + &"hunger": rates["hunger"], + &"rest": rates["sleep"], + &"fun": rates["leisure"], + &"social": rates["intimacy"], + &"safety": rates["medical"], + } + for guest_snapshot: Dictionary in legacy[&"guests"]: + var needs: Dictionary = guest_snapshot[&"needs"] + guest_snapshot[&"needs"] = { + &"hunger": needs["hunger"], + &"rest": needs["sleep"], + &"fun": needs["leisure"], + &"social": needs["intimacy"], + &"safety": needs["medical"], + } + + var migrated := GuestServiceType.new() + _expect(migrated.restore_snapshot(legacy), "GuestService deterministically migrates an exact v1 five-Need snapshot") + var migrated_snapshot := migrated.create_snapshot() + var migrated_needs: Dictionary = migrated_snapshot[&"guests"][0][&"needs"] + _expect(migrated_snapshot[&"version"] == 2, "v1 migration emits only the current GuestService schema") + _expect(migrated_needs == { + "hunger": 11, + "hygiene": 0, + "intimacy": 66, + "leisure": 55, + "medical": 44, + "sleep": 22, + }, "v1 rest/fun/social/safety map to sleep/leisure/intimacy/medical with Hygiene zero") + _expect(migrated.validate_invariants()[&"valid"], "migrated six-Need graph satisfies current invariants") + + var stable_before := migrated.create_snapshot() + var missing := canonical.duplicate(true) + missing[&"guests"][0][&"needs"].erase("hygiene") + _expect(not migrated.restore_snapshot(missing), "current snapshots reject a missing canonical Need") + _expect(migrated.create_snapshot() == stable_before, "missing-Need rejection is atomic") + var unknown := canonical.duplicate(true) + unknown[&"guests"][0][&"needs"]["safety"] = 1 + _expect(not migrated.restore_snapshot(unknown), "current snapshots reject unknown legacy Need keys") + _expect(migrated.create_snapshot() == stable_before, "unknown-Need rejection is atomic") + var wrong_type := canonical.duplicate(true) + wrong_type[&"definitions"][0][&"need_rates"]["sleep"] = "1" + _expect(not migrated.restore_snapshot(wrong_type), "current definition Need rates reject implicit integer coercion") + _expect(migrated.create_snapshot() == stable_before, "definition type rejection is atomic") + var mixed_v1 := legacy.duplicate(true) + mixed_v1[&"guests"][0][&"needs"]["sleep"] = 22 + _expect(not migrated.restore_snapshot(mixed_v1), "v1 migration rejects mixed legacy/current Need maps") + _expect(migrated.create_snapshot() == stable_before, "mixed-schema rejection preserves live state") + + +func _test_deterministic_decision_snapshot() -> void: + var first := _deterministic_run() + var second := _deterministic_run() + _expect(first == second, "same guest definitions and candidates produce an identical authoritative snapshot") + + +func _deterministic_run() -> Dictionary: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"guest_determinism", &"adventurer", 0, { + &"wallet_gold": 1_000, + &"planned_exit_at": 1_000, + &"state": GuestRuntimeType.State.DECIDING, + &"needs": {&"hunger": 80, &"leisure": 50}, + }) + var candidates: Array = [ + _candidate(&"z_meal", {&"need_relief": {&"hunger": 60}, &"facility_tags": [&"meal"], &"travel_minutes": 4}), + _candidate(&"a_meal", {&"need_relief": {&"hunger": 60}, &"facility_tags": [&"meal"], &"travel_minutes": 4}), + _candidate(&"theater", {&"need_relief": {&"leisure": 50}, &"facility_tags": [&"leisure"], &"travel_minutes": 3}), + ] + service.decide(guest.guest_id, candidates, 1, {&"absolute_minute": 1}) + service.update_needs(guest.guest_id, {&"dirt": 2}) + return service.create_snapshot() + + +func _service_with_definitions() -> GuestService: + var service := GuestServiceType.new() + service.register_definition(GuestDefinitionType.new().configure(&"adventurer", { + &"tags": [&"adventurer", &"general"], + &"wealth_min": 100, + &"wealth_max": 500, + &"base_patience": 45, + &"risk_tolerance": 80, + &"preference_weights": {&"meal": 8, &"leisure": 3}, + &"group_size_weights": {1: 100}, + &"legal_sensitivity": 60, + &"quality_sensitivity": 70, + &"adult_verified": false, + &"need_rates": {&"hunger": 3, &"sleep": -2, &"leisure": 1}, + &"credit_limit_gold": 100, + &"minimum_quality": 40, + })) + service.register_definition(GuestDefinitionType.new().configure(&"verified_noble", { + &"tags": [&"wealthy", &"vip"], + &"wealth_min": 1_000, + &"wealth_max": 2_000, + &"base_patience": 20, + &"risk_tolerance": 40, + &"legal_sensitivity": 20, + &"adult_verified": true, + &"minimum_quality": 40, + })) + return service + + +func _moving_guest(service: GuestService, guest_id: StringName) -> GuestRuntime: + var guest := service.spawn_guest(guest_id, &"adventurer", 0, { + &"wallet_gold": 500, + &"planned_exit_at": 1_000, + }) + service.transition_guest(guest_id, GuestRuntimeType.State.ENTERING, 1) + service.transition_guest(guest_id, GuestRuntimeType.State.DECIDING, 2) + service.transition_guest(guest_id, GuestRuntimeType.State.MOVING, 3) + return guest + + +func _candidate(facility_id: StringName, patch: Dictionary = {}) -> Dictionary: + var candidate := { + &"facility_id": facility_id, + &"is_open": true, + &"reachable": true, + &"price_gold": 100, + &"wait_minutes": 10, + &"risk_level": 10, + &"illegal_level": 10, + &"expected_quality": 60, + &"need_relief": 50, + } + for raw_key: Variant in patch: + candidate[raw_key] = patch[raw_key] + return candidate + + +func _candidate_ids(candidates: Array[Dictionary]) -> Array[StringName]: + var result: Array[StringName] = [] + for candidate: Dictionary in candidates: + result.append(StringName(candidate[&"facility_id"])) + return result + + +func _rejection_map(rejections: Array[Dictionary]) -> Dictionary: + var result: Dictionary = {} + for rejection: Dictionary in rejections: + result[StringName(rejection[&"facility_id"])] = StringName(rejection[&"reason"]) + return result + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s3/test_guest_service.gd.uid b/tests/s3/test_guest_service.gd.uid new file mode 100644 index 0000000..93b3e58 --- /dev/null +++ b/tests/s3/test_guest_service.gd.uid @@ -0,0 +1 @@ +uid://b5un0csk8il6t diff --git a/tests/s3/test_runtime_actor_cycle.gd b/tests/s3/test_runtime_actor_cycle.gd new file mode 100644 index 0000000..ba07925 --- /dev/null +++ b/tests/s3/test_runtime_actor_cycle.gd @@ -0,0 +1,240 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant +var _facility_id: StringName +var _facility_inventory_id: StringName + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S3: integrated Guest and Staff runtime cycle") + _reset_runtime() + _build_cafeteria() + _register_staff_and_run_task() + _run_autonomous_guest_service_with_replay() + _run_long_horizon_replay() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime() -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, 3303) + _clock.set_paused(true) + _expect(report[&"valid"], "runtime initializes S3 actor services") + _expect(_runtime.guest_service.get_definitions().size() == 35, "runtime preserves three legacy archetypes and registers 32 MVP catalog archetypes") + _expect(_runtime.guest_service.current_minute == _clock.absolute_minute, "Guest cadence starts aligned to the kernel") + _expect(_runtime.staff_service.current_minute == _clock.absolute_minute, "Staff cadence starts aligned to the kernel") + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _build_cafeteria() -> void: + var build = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT)}, + &"player", &"", 50, -1, &"s3.runtime.build" + ) + _expect(build.is_success(), "cafeteria construction command succeeds") + _clock.advance_minutes(180) + var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities() + _expect(facilities.size() == 1, "construction completes before actor integration") + _facility_id = StringName(facilities[0][&"instance_id"]) + var facility = _runtime.facility_service.get_instance(_facility_id) + _facility_inventory_id = facility.local_inventory_id + _expect(facility.state == FacilityInstance.State.NO_STAFF, "unassigned S3 facility starts in NO_STAFF") + + +func _register_staff_and_run_task() -> void: + var common_options := { + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"state": StaffRuntime.State.OFF_DUTY, + &"permitted_task_tags": [&"service", &"work"], + &"equipment_tags": [&"uniform"], + } + var cook_options: Dictionary = common_options.duplicate(true) + cook_options[&"role_tags"] = [&"cook"] + cook_options[&"skills"] = {&"cooking": 80, &"operation": 30} + var server_options: Dictionary = common_options.duplicate(true) + server_options[&"role_tags"] = [&"server"] + server_options[&"skills"] = {&"service": 75, &"operation": 20} + var cook = _runtime.submit_command( + &"register_staff", + {&"staff_id": &"cook_aya", &"role_id": &"cook", &"options": cook_options}, + &"manager", &"cook_aya", 20, -1, &"s3.runtime.staff.cook" + ) + var server = _runtime.submit_command( + &"register_staff", + {&"staff_id": &"server_ren", &"role_id": &"server", &"options": server_options}, + &"manager", &"server_ren", 20, -1, &"s3.runtime.staff.server" + ) + _expect(cook.is_success() and server.is_success(), "two StaffRuntime records register through commands") + var start_minute := posmod(_clock.absolute_minute, 1_440) + for staff_id: StringName in [&"cook_aya", &"server_ren"]: + var shift = _runtime.submit_command( + &"create_staff_shift", + { + &"staff_id": staff_id, + &"template_id": &"FLEX", + &"start_day": _clock.absolute_minute / 1_440, + &"start_minute_of_day": start_minute, + &"end_minute_of_day": start_minute + 600, + &"facility_id": _facility_id, + }, + &"manager", staff_id, 20, -1, StringName("s3.runtime.shift.%s" % staff_id) + ) + _expect(shift.is_success(), "Flex Shift registers for %s" % staff_id) + _clock.advance_one_minute() + var facility = _runtime.facility_service.get_instance(_facility_id) + _expect(facility.active_staff_ids == [&"cook_aya", &"server_ren"], "on-duty Staff project into FacilityService in stable ID order") + _expect(facility.state == FacilityInstance.State.OPEN, "required S3 staffing opens the cafeteria") + + var tool = _runtime.submit_command( + &"add_inventory_stack", + { + &"owner_inventory_id": _facility_inventory_id, + &"item_id": &"tool/cleaning_kit", + &"quantity": 1, + }, + &"manager", _facility_id, 10, -1, &"s3.runtime.tool" + ) + _expect(tool.is_success(), "facility receives a reservable Staff task tool") + var task_result = _runtime.submit_command( + &"create_staff_task", + { + &"task_id": &"clean_cafeteria", + &"task_type": &"clean", + &"priority": 70, + &"options": { + &"target_entity_id": _facility_id, + &"target_cell": Vector3i(-1, 0, WorldService.LAYER_FRONT), + &"required_role_tags": [&"server"], + &"required_skill_min": {&"service": 10}, + &"required_items": {&"tool/cleaning_kit": 1}, + &"task_tags": [&"service"], + &"estimated_minutes": 2, + }, + }, + &"manager", _facility_id, 10, -1, &"s3.runtime.task" + ) + _expect(task_result.is_success(), "Staff task enters the runtime dispatcher") + _clock.advance_minutes(8) + var task = _runtime.staff_service.get_task(&"clean_cafeteria") + _expect(task.state == StaffTask.State.COMPLETED, "runtime automatically dispatches, begins and completes the Staff task") + _expect(_runtime.inventory_service.available_quantity(_facility_inventory_id, &"tool/cleaning_kit") == 1, "completed task releases its atomic tool Reservation") + _expect(_runtime.staff_service.validate_invariants()[&"ok"], "integrated Staff task graph preserves bidirectional invariants") + + +func _run_autonomous_guest_service_with_replay() -> void: + var ingredient = _runtime.submit_command( + &"add_inventory_stack", + { + &"owner_inventory_id": _facility_inventory_id, + &"item_id": &"ingredient/basic_meal", + &"quantity": 1, + &"quality": 80, + }, + &"manager", _facility_id, 10, -1, &"s3.runtime.ingredient" + ) + _expect(ingredient.is_success(), "one service recipe is stocked") + var spawn = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": &"guest_mio", + &"guest_type_id": &"traveler", + &"gold": 150, + &"options": { + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"needs": {&"hunger": 90, &"sleep": 20, &"hygiene": 20, &"medical": 0, &"leisure": 20, &"intimacy": 0}, + &"planned_exit_at": _clock.absolute_minute + 240, + }, + }, + &"world", &"guest_mio", 10, -1, &"s3.runtime.guest" + ) + _expect(spawn.is_success(), "spawn_guest atomically creates GuestRuntime and ledger wallet") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_mio") == 150, "Guest wallet is authoritative in EconomyService") + + var active_service: ServiceInstance = null + for _minute in range(10): + _clock.advance_one_minute() + var active: Array = _runtime.facility_service.get_active_services(_facility_id) + if not active.is_empty(): + active_service = active[0] + break + _expect(active_service != null, "autonomous Guest reaches the facility queue and starts service") + if active_service == null: + return + var guest = _runtime.guest_service.get_guest(&"guest_mio") + _expect(guest.state == GuestRuntime.State.RECEIVING_SERVICE, "Facility service start atomically updates Guest state") + _expect(guest.current_service_id == active_service.service_id, "Guest and Facility reference the same active service") + var mid_snapshot: Dictionary = _runtime.create_snapshot() + var mid_digest := SimulationDigest.compute(mid_snapshot) + var duration := active_service.duration_minutes + _clock.advance_minutes(duration) + var completed = _runtime.facility_service.get_service(active_service.service_id) + guest = _runtime.guest_service.get_guest(&"guest_mio") + _expect(completed != null and completed.state == ServiceInstance.State.COMPLETED, "service completes after its immutable duration") + _expect(guest != null and guest.satisfaction_samples == 1, "completion records exactly one Guest satisfaction sample") + _expect(guest != null and int(guest.needs[&"hunger"]) < 40, "cafeteria completion applies its Need relief") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_mio") == 115, "Guest pays the quoted service price once") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4235, "service revenue reaches authoritative player cash") + _expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 0, "service consumes exactly one reserved recipe input") + var final_digest: String = _runtime.latest_digest + + _expect(_runtime.restore_snapshot(mid_snapshot), "mid-service S3 snapshot restores") + _expect(_runtime.latest_digest == mid_digest, "S3 restore reproduces the exact mid-service digest") + _clock.advance_minutes(duration) + _expect(_runtime.latest_digest == final_digest, "replayed Guest, Staff, ledger and event transitions reproduce exactly") + _expect(_runtime.economy_service.get_entries().filter( + func(entry: LedgerEntry) -> bool: return entry.category == &"SERVICE_SALE" + ).size() == 1, "replay records the service sale exactly once") + + +func _run_long_horizon_replay() -> void: + var horizon_snapshot: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(1_440) + var first_digest: String = _runtime.latest_digest + var first_snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.guest_service.validate_invariants()[&"valid"], "24-hour Guest graph has no orphan or illegal state") + _expect(_runtime.staff_service.validate_invariants()[&"ok"], "24-hour Staff graph has no orphan reservation") + _expect(_runtime.inventory_service.validate_invariants()[&"ok"], "24-hour actor simulation preserves Inventory invariants") + _expect(_runtime.economy_service.validate_ledger()[&"ok"], "24-hour actor simulation keeps the ledger balanced") + _expect(_runtime.restore_snapshot(horizon_snapshot), "long-horizon starting point restores") + _clock.advance_minutes(1_440) + _expect(_runtime.latest_digest == first_digest, "24-hour actor simulation is deterministic after restore") + _expect(_runtime.create_snapshot() == first_snapshot, "24-hour replay reproduces the complete authoritative snapshot") + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s3/test_runtime_actor_cycle.gd.uid b/tests/s3/test_runtime_actor_cycle.gd.uid new file mode 100644 index 0000000..753daa2 --- /dev/null +++ b/tests/s3/test_runtime_actor_cycle.gd.uid @@ -0,0 +1 @@ +uid://0sc8xvrpbuoa diff --git a/tests/s3/test_runtime_actor_hardening.gd b/tests/s3/test_runtime_actor_hardening.gd new file mode 100644 index 0000000..5b8eb33 --- /dev/null +++ b/tests/s3/test_runtime_actor_hardening.gd @@ -0,0 +1,415 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S3: runtime actor hardening regressions") + _test_service_affordability_is_atomic() + _test_guest_moves_one_cell_per_minute() + _test_unreachable_staff_task_is_not_assigned() + _test_shift_end_interrupts_and_fails_service() + _test_corrupt_cross_domain_snapshots_are_atomic() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed: int) -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _clock.set_paused(true) + _expect(bool(report.get(&"valid", false)), "runtime initializes for hardening scenario %d" % seed) + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_service_affordability_is_atomic() -> void: + _reset_runtime(3311) + var facility: FacilityInstance = _build_cafeteria() + if facility == null: + return + _expect(_stock(facility.local_inventory_id, &"ingredient/basic_meal", 3, &"hardening.stock.affordability"), "affordability scenario stocks three meals") + _expect(_register_wallet(&"low_funds", 20, &"hardening.wallet.low"), "low-balance wallet registers") + var available_before: int = _runtime.inventory_service.available_quantity( + facility.local_inventory_id, &"ingredient/basic_meal" + ) + var reservations_before: int = _runtime.inventory_service.get_reservations(true).size() + var low_result = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"request_low_funds", + &"facility_id": facility.instance_id, + &"guest_ids": [&"low_funds"], + &"service_type": &"default", + &"options": {&"quoted_price": 35}, + }, + &"low_funds", facility.instance_id, 10, -1, &"hardening.service.low" + ) + _expect(not low_result.is_success(), "service request exceeding the wallet balance is rejected") + _expect(_runtime.facility_service.get_request(&"request_low_funds") == null, "rejected low-balance request leaves no Facility request") + _expect(_runtime.facility_service.get_queue(facility.instance_id).is_empty(), "rejected low-balance request leaves no queue entry") + _expect( + _runtime.inventory_service.available_quantity(facility.local_inventory_id, &"ingredient/basic_meal") == available_before, + "rejected low-balance request does not reserve stock" + ) + _expect(_runtime.inventory_service.get_reservations(true).size() == reservations_before, "rejected low-balance request creates no active Reservation") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:low_funds") == 20, "rejected low-balance request leaves the wallet unchanged") + + _expect(_register_wallet(&"committed_guest", 50, &"hardening.wallet.commitment"), "commitment-test wallet registers") + var first_result = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"request_commitment_a", + &"facility_id": facility.instance_id, + &"guest_ids": [&"committed_guest"], + &"service_type": &"default", + &"options": {&"quoted_price": 30}, + }, + &"committed_guest", facility.instance_id, 10, -1, &"hardening.service.commitment.a" + ) + _expect(first_result.is_success(), "first affordable service commitment is accepted") + var available_after_first: int = _runtime.inventory_service.available_quantity( + facility.local_inventory_id, &"ingredient/basic_meal" + ) + var active_after_first: int = _runtime.inventory_service.get_reservations(true).size() + var second_result = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"request_commitment_b", + &"facility_id": facility.instance_id, + &"guest_ids": [&"committed_guest"], + &"service_type": &"default", + &"options": {&"quoted_price": 25}, + }, + &"committed_guest", facility.instance_id, 10, -1, &"hardening.service.commitment.b" + ) + _expect(not second_result.is_success(), "combined service commitments exceeding the wallet are rejected") + var queue: Array = _runtime.facility_service.get_queue(facility.instance_id) + _expect(queue.size() == 1 and queue[0].request_id == &"request_commitment_a", "over-commit rejection preserves only the first queue entry") + _expect(_runtime.facility_service.get_request(&"request_commitment_b") == null, "over-commit rejection leaves no second request") + _expect( + _runtime.inventory_service.available_quantity(facility.local_inventory_id, &"ingredient/basic_meal") == available_after_first, + "over-commit rejection does not reserve another meal" + ) + _expect(_runtime.inventory_service.get_reservations(true).size() == active_after_first, "over-commit rejection creates no additional Reservation") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:committed_guest") == 50, "queued commitments do not debit the wallet prematurely") + + +func _test_guest_moves_one_cell_per_minute() -> void: + _reset_runtime(3312) + var facility: FacilityInstance = _build_cafeteria() + if facility == null: + return + var opened = _runtime.submit_command( + &"set_facility_conditions", + {&"facility_id": facility.instance_id, &"conditions": {&"active_staff_ids": [&"cook_projection", &"server_projection"]}}, + &"manager", facility.instance_id, 10, -1, &"hardening.guest.open" + ) + _expect(opened.is_success() and facility.state == FacilityInstance.State.OPEN, "projected staffing opens the movement target") + _expect(_stock(facility.local_inventory_id, &"ingredient/basic_meal", 1, &"hardening.stock.path"), "movement target has a service input") + var spawn = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": &"path_guest", + &"guest_type_id": &"traveler", + &"gold": 100, + &"options": { + &"current_cell": Vector3i(8, 0, WorldService.LAYER_FRONT), + &"needs": {&"hunger": 100, &"sleep": 0, &"hygiene": 0, &"medical": 0, &"leisure": 0, &"intimacy": 0}, + &"planned_exit_at": _clock.absolute_minute + 240, + }, + }, + &"world", &"path_guest", 10, -1, &"hardening.guest.spawn" + ) + _expect(spawn.is_success(), "distant Guest spawns for path progression") + var guest: GuestRuntime = null + for _minute in range(8): + _clock.advance_one_minute() + guest = _runtime.guest_service.get_guest(&"path_guest") + if guest != null and guest.state == GuestRuntime.State.MOVING: + break + _expect(guest != null and guest.state == GuestRuntime.State.MOVING, "autonomous Guest selects the reachable cafeteria") + if guest == null or guest.state != GuestRuntime.State.MOVING: + return + var target_cell: Vector3i = guest.path_handle.get(&"target_cell", guest.current_cell) + var planned_path: Array = _runtime.path_service.find_path(guest.current_cell, target_cell) + _expect(planned_path.size() >= 4, "movement regression uses a path longer than one minute") + var first_cell := guest.current_cell + _clock.advance_one_minute() + guest = _runtime.guest_service.get_guest(&"path_guest") + var second_cell := guest.current_cell + _expect(_cell_distance(first_cell, second_cell) == 1, "Guest advances exactly one graph cell in one minute") + _expect(second_cell != target_cell, "Guest does not teleport to a distant target in one minute") + _clock.advance_one_minute() + guest = _runtime.guest_service.get_guest(&"path_guest") + _expect(_cell_distance(second_cell, guest.current_cell) == 1, "Guest continues along the path at one cell per minute") + + +func _test_unreachable_staff_task_is_not_assigned() -> void: + _reset_runtime(3313) + var registered = _runtime.submit_command( + &"register_staff", + { + &"staff_id": &"isolated_worker", + &"role_id": &"worker", + &"options": { + &"state": StaffRuntime.State.AVAILABLE, + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"role_tags": [&"worker"], + &"permitted_task_tags": [&"work"], + &"skills": {&"operation": 50}, + }, + }, + &"manager", &"isolated_worker", 10, -1, &"hardening.staff.unreachable.register" + ) + _expect(registered.is_success(), "available Staff registers for unreachable-task test") + var created = _runtime.submit_command( + &"create_staff_task", + { + &"task_id": &"unreachable_task", + &"task_type": &"inspect", + &"priority": 95, + &"options": { + &"target_cell": Vector3i(0, 5, WorldService.LAYER_FRONT), + &"required_role_tags": [&"worker"], + &"required_skill_min": {&"operation": 10}, + &"task_tags": [&"work"], + &"estimated_minutes": 5, + }, + }, + &"manager", &"unreachable_task", 10, -1, &"hardening.staff.unreachable.task" + ) + _expect(created.is_success(), "unreachable StaffTask registers") + _clock.advance_one_minute() + var task: StaffTask = _runtime.staff_service.get_task(&"unreachable_task") + var staff: StaffRuntime = _runtime.staff_service.get_staff(&"isolated_worker") + _expect(task != null and task.state == StaffTask.State.OPEN, "unreachable StaffTask remains OPEN") + _expect(task != null and task.assigned_staff_id.is_empty(), "unreachable StaffTask has no assigned Staff") + _expect(staff != null and staff.current_task_id.is_empty(), "unreachable assignment does not reserve the Staff member") + var unassigned: Array = _runtime.staff_service.last_dispatch_report.get(&"unassigned", []) + _expect(not unassigned.is_empty() and unassigned[0].get(&"reason", &"") == &"NO_ELIGIBLE_STAFF", "dispatcher reports unreachable work as having no eligible Staff") + _expect(_runtime.inventory_service.get_reservations(true).is_empty(), "unreachable work creates no inventory Reservation") + + +func _test_shift_end_interrupts_and_fails_service() -> void: + _reset_runtime(3314) + var facility: FacilityInstance = _build_cafeteria() + if facility == null: + return + for staff_id: StringName in [&"short_cook", &"short_server"]: + var registered = _runtime.submit_command( + &"register_staff", + { + &"staff_id": staff_id, + &"role_id": &"worker", + &"options": { + &"state": StaffRuntime.State.OFF_DUTY, + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + }, + }, + &"manager", staff_id, 10, -1, StringName("hardening.shift.staff.%s" % staff_id) + ) + _expect(registered.is_success(), "short-shift Staff %s registers" % staff_id) + var start_day: int = _clock.absolute_minute / 1_440 + var start_minute: int = posmod(_clock.absolute_minute, 1_440) + var end_minute: int = start_minute + 5 + for staff_id: StringName in [&"short_cook", &"short_server"]: + var shifted = _runtime.submit_command( + &"create_staff_shift", + { + &"shift_id": StringName("shift_%s" % staff_id), + &"staff_id": staff_id, + &"template_id": &"FLEX", + &"start_day": start_day, + &"start_minute_of_day": start_minute, + &"end_minute_of_day": end_minute, + &"facility_id": facility.instance_id, + }, + &"manager", staff_id, 10, -1, StringName("hardening.shift.create.%s" % staff_id) + ) + _expect(shifted.is_success(), "short Shift for %s registers" % staff_id) + _clock.advance_one_minute() + _expect(facility.active_staff_ids == [&"short_cook", &"short_server"], "active short-shift Staff project into the Facility") + _expect(facility.state == FacilityInstance.State.OPEN, "short-shift staffing opens the Facility") + _expect(_stock(facility.local_inventory_id, &"ingredient/basic_meal", 1, &"hardening.shift.stock"), "interruption scenario stocks one meal") + _expect(_register_wallet(&"shift_guest", 100, &"hardening.shift.wallet"), "service payer registers for Shift interruption") + var requested = _runtime.submit_command( + &"create_service_request", + { + &"request_id": &"shift_service_request", + &"facility_id": facility.instance_id, + &"guest_ids": [&"shift_guest"], + &"service_type": &"default", + &"options": {&"quoted_price": 35}, + }, + &"shift_guest", facility.instance_id, 10, -1, &"hardening.shift.service" + ) + _expect(requested.is_success(), "service queues while short Shift is staffed") + _clock.advance_one_minute() + var active: Array = _runtime.facility_service.get_active_services(facility.instance_id) + _expect(active.size() == 1, "service starts before the short Shift ends") + if active.is_empty(): + return + var service: ServiceInstance = active[0] + var service_id: StringName = service.service_id + var shift_end_absolute: int = start_day * 1_440 + end_minute + _clock.advance_minutes(maxi(shift_end_absolute - _clock.absolute_minute, 0)) + _expect(facility.active_staff_ids.is_empty(), "Shift end removes all projected active Staff") + _expect(facility.state == FacilityInstance.State.NO_STAFF, "Shift end moves the Facility to NO_STAFF") + _clock.advance_one_minute() + service = _runtime.facility_service.get_service(service_id) + _expect(service != null and service.state == ServiceInstance.State.WAITING_FOR_STAFF, "active service waits for replacement Staff after Shift end") + var grace_deadline: int = service.staff_grace_deadline if service != null else _clock.absolute_minute + _expect(grace_deadline == _clock.absolute_minute + FacilityService.STAFF_REPLACEMENT_GRACE_MINUTES, "service records the deterministic Staff replacement grace deadline") + _clock.advance_minutes(maxi(grace_deadline - _clock.absolute_minute, 0)) + service = _runtime.facility_service.get_service(service_id) + _expect(service != null and service.state == ServiceInstance.State.FAILED, "service fails when no replacement arrives before grace expiry") + _expect(service != null and service.failure_reason == FacilityService.INTERRUPTION_STAFF_UNAVAILABLE, "failed service preserves the Staff-unavailable reason") + _expect(_runtime.facility_service.get_active_services(facility.instance_id).is_empty(), "failed service leaves no active Facility slot") + _expect(_runtime.inventory_service.available_quantity(facility.local_inventory_id, &"ingredient/basic_meal") == 1, "failed service releases its meal Reservation") + + +func _test_corrupt_cross_domain_snapshots_are_atomic() -> void: + _reset_runtime(3315) + var registered = _runtime.submit_command( + &"register_staff", + { + &"staff_id": &"snapshot_worker", + &"role_id": &"worker", + &"options": { + &"state": StaffRuntime.State.AVAILABLE, + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"role_tags": [&"worker"], + &"permitted_task_tags": [&"work"], + }, + }, + &"manager", &"snapshot_worker", 10, -1, &"hardening.snapshot.staff" + ) + _expect(registered.is_success(), "snapshot Staff registers") + _expect(_stock(&"staff.supplies", &"tool/snapshot_kit", 1, &"hardening.snapshot.stock"), "snapshot task tool is stocked") + var created = _runtime.submit_command( + &"create_staff_task", + { + &"task_id": &"snapshot_task", + &"task_type": &"inspect", + &"priority": 90, + &"options": { + &"target_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"required_role_tags": [&"worker"], + &"required_items": {&"tool/snapshot_kit": 1}, + &"task_tags": [&"work"], + &"estimated_minutes": 10, + }, + }, + &"manager", &"snapshot_task", 10, -1, &"hardening.snapshot.task" + ) + _expect(created.is_success(), "snapshot StaffTask registers") + _clock.advance_one_minute() + var task: StaffTask = _runtime.staff_service.get_task(&"snapshot_task") + _expect(task != null and task.state == StaffTask.State.WORKING and task.reservation_ids.size() == 1, "snapshot captures a working Task with an active Reservation") + var valid_snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(valid_snapshot), "valid hardening snapshot restores before corruption checks") + + var before_corrupt: Dictionary = _runtime.create_snapshot() + var before_digest: String = SimulationDigest.compute(before_corrupt) + var minute_corrupt: Dictionary = before_corrupt.duplicate(true) + var runtime_data: Dictionary = minute_corrupt[&"systems"][&"runtime"] + runtime_data[&"facilities"][&"current_minute"] = _clock.absolute_minute + 1 + _expect(not _runtime.restore_snapshot(minute_corrupt), "snapshot with FacilityService minute diverging from the clock is rejected") + _expect(_runtime.create_snapshot() == before_corrupt, "minute-mismatch restore rejection is fully atomic") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == before_digest, "minute-mismatch rejection preserves the live digest") + + var reservation_corrupt: Dictionary = before_corrupt.duplicate(true) + var task_entries: Array = reservation_corrupt[&"systems"][&"runtime"][&"staff"][&"tasks"] + for raw_task: Dictionary in task_entries: + if StringName(raw_task.get(&"task_id", "")) == &"snapshot_task": + raw_task[&"reservation_ids"] = ["reservation_missing"] + _expect(not _runtime.restore_snapshot(reservation_corrupt), "snapshot with a dangling StaffTask Reservation reference is rejected") + _expect(_runtime.create_snapshot() == before_corrupt, "dangling-Reservation restore rejection is fully atomic") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == before_digest, "dangling-Reservation rejection preserves the live digest") + + +func _build_cafeteria() -> FacilityInstance: + var build = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT)}, + &"player", &"", 50, -1, StringName("hardening.build.%d" % _clock.absolute_minute) + ) + _expect(build.is_success(), "cafeteria construction starts") + _clock.advance_minutes(180) + var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities() + _expect(facilities.size() == 1, "cafeteria construction completes") + if facilities.size() != 1: + return null + var facility_id := StringName(facilities[0].get(&"instance_id", "")) + var facility: FacilityInstance = _runtime.facility_service.get_instance(facility_id) + _expect(facility != null, "completed cafeteria registers with FacilityService") + return facility + + +func _stock( + owner_inventory_id: StringName, + item_id: StringName, + quantity: int, + idempotency_key: StringName +) -> bool: + var result = _runtime.submit_command( + &"add_inventory_stack", + { + &"owner_inventory_id": owner_inventory_id, + &"item_id": item_id, + &"quantity": quantity, + }, + &"manager", owner_inventory_id, 10, -1, idempotency_key + ) + return result.is_success() + + +func _register_wallet(guest_id: StringName, gold: int, idempotency_key: StringName) -> bool: + var result = _runtime.submit_command( + &"register_guest_wallet", + {&"guest_id": guest_id, &"gold": gold}, + &"manager", guest_id, 10, -1, idempotency_key + ) + return result.is_success() + + +func _cell_distance(a: Vector3i, b: Vector3i) -> int: + var delta := a - b + return absi(delta.x) + absi(delta.y) + absi(delta.z) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s3/test_runtime_actor_hardening.gd.uid b/tests/s3/test_runtime_actor_hardening.gd.uid new file mode 100644 index 0000000..37a2d7e --- /dev/null +++ b/tests/s3/test_runtime_actor_hardening.gd.uid @@ -0,0 +1 @@ +uid://bx62i83nwxfbh diff --git a/tests/s3/test_runtime_path_traffic.gd b/tests/s3/test_runtime_path_traffic.gd new file mode 100644 index 0000000..19e8e7e --- /dev/null +++ b/tests/s3/test_runtime_path_traffic.gd @@ -0,0 +1,619 @@ +extends SceneTree + +const POST_V2_RUNTIME_KEYS := [ + &"movement_credits", + &"catalog_manifest", + &"progression", + &"hired_staff_candidate_ids", + &"operations", + &"excavation", + &"spatial_subslots", + &"route_projection", + &"facility_infrastructure", +] +const POST_V2_EVENT_TYPES := [ + &"PATH_ACTOR_STUCK", + &"WORLD_TREE_RANK_ADVANCED", + &"STAFF_CANDIDATE_HIRED", + &"GUEST_AUTO_ARRIVED", + &"FACILITY_INFRASTRUCTURE_PROJECTED", + &"EXCAVATION_COMPLETED", +] +const POST_V2_COMMAND_TYPES := [ + &"set_world_tree_rank", + &"update_progression_metrics", + &"complete_progression_quest", + &"hire_staff_candidate", + &"acknowledge_operations_pause", + &"start_excavation", + &"pause_excavation", + &"resume_excavation", +] + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S3: Runtime Path traffic integration") + _test_namespaced_lifecycle_and_density() + _test_congestion_credit_and_alternate_route() + _test_occupied_cell_graph_rebuild_is_rejected() + _test_stuck_guest_and_staff_recovery() + _test_event_recovery_path_transaction() + _test_v6_snapshot_crosslinks_and_legacy_v2() + _test_dispatcher_request_ownership_crosslinks() + _test_twenty_four_hour_projection_integrity() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed: int) -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _clock.set_paused(true) + _expect(bool(report.get(&"valid", false)), "Runtime initializes for Path traffic fixture %d" % seed) + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_namespaced_lifecycle_and_density() -> void: + _reset_runtime(8_101) + var cell := Vector3i(-4, 0, WorldService.LAYER_FRONT) + _expect(_spawn_moving_guest(&"shared", cell, Vector3i(4, 0, 0)), "Guest with shared raw id spawns") + _expect(_register_idle_staff(&"shared", cell), "Staff with the same raw id registers") + _expect( + _runtime.path_service.has_actor_projection(&"guest:shared") + and _runtime.path_service.has_actor_projection(&"staff:shared"), + "Guest and Staff use collision-free namespaced Path actor ids" + ) + _expect( + _runtime.path_service.get_current_density(cell) == 2, + "spawn/register projects both actors into the authoritative cell density" + ) + var guest: GuestRuntime = _runtime.guest_service.get_guest(&"shared") + _runtime.guest_service.begin_leaving(&"shared", _clock.absolute_minute, &"TEST_EXIT") + _runtime.guest_service.despawn_guest(&"shared", _clock.absolute_minute) + _expect(guest != null and not _runtime.path_service.has_actor_projection(&"guest:shared"), "Guest despawn removes only its namespaced projection") + _expect( + _runtime.path_service.has_actor_projection(&"staff:shared") + and _runtime.path_service.get_current_density(cell) == 1, + "Guest despawn preserves the colliding raw Staff id and lowers density once" + ) + _expect(_runtime.get_actor_movement_credit(&"guest:shared").is_empty(), "Guest despawn removes movement credit") + _runtime.staff_service.remove_staff(&"shared", &"TEST_REMOVE", true, _clock.absolute_minute) + _expect( + not _runtime.path_service.has_actor_projection(&"staff:shared") + and _runtime.path_service.get_current_density(cell) == 0, + "Staff removal cleans projection and density without going negative" + ) + var invalid_spawn: CommandResult = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": &"invalid_cell", + &"guest_type_id": &"traveler", + &"options": {&"current_cell": Vector3i(0, 2, 0)}, + }, + &"world", &"invalid_cell", 10, -1, &"path.invalid.spawn" + ) + _expect( + not invalid_spawn.is_success() + and not _runtime.guest_service.has_guest(&"invalid_cell") + and not _runtime.path_service.has_actor_projection(&"guest:invalid_cell") + and _runtime.get_actor_movement_credit(&"guest:invalid_cell").is_empty() + and not _has_recent_event(&"GUEST_SPAWNED", &"invalid_cell"), + "failed spawn leaves Actor, Path, credit, and lifecycle events uncommitted" + ) + + +func _test_congestion_credit_and_alternate_route() -> void: + _reset_runtime(8_102) + var origin := Vector3i(-8, 0, WorldService.LAYER_FRONT) + var target := Vector3i(8, 0, WorldService.LAYER_FRONT) + _runtime.path_service.configure_cell_traffic(origin, 1, 0, false) + for index in range(5): + _expect( + _spawn_moving_guest(StringName("slow_%02d" % index), origin, target), + "congestion Guest %d spawns" % index + ) + _expect(_runtime.path_service.get_current_density(origin) == 5, "five same-route actors create over-capacity density") + _clock.advance_one_minute() + var after_one: Array[Vector3i] = _guest_cells_with_prefix("slow_") + _expect(after_one.all(func(cell: Vector3i) -> bool: return cell == origin), "over-capacity speed credit prevents a first-minute burst") + _expect( + int(_runtime.get_actor_movement_credit(&"guest:slow_00").get(&"credit", -1)) == 250, + "Path speed multiplier is stored as deterministic fixed-point credit" + ) + _clock.advance_minutes(3) + var after_four: Array[Vector3i] = _guest_cells_with_prefix("slow_") + _expect(after_four.all(func(cell: Vector3i) -> bool: return cell != origin), "minimum speed guarantees progress by the fourth congested minute") + _expect(_runtime.path_service.get_current_density(origin) == 0, "sequential committed movement lowers source density exactly") + + _reset_runtime(8_103) + for x in range(0, 3): + _runtime.world_service.set_walkable(Vector3i(x, 1, WorldService.LAYER_FRONT), true) + _expect(_runtime.rebuild_path_graph_from_world(), "parallel walkable lane rebuilds into PathService") + var congested := Vector3i(1, 0, WorldService.LAYER_FRONT) + _runtime.path_service.configure_cell_traffic(congested, 1, 0, false) + for blocker_index in range(4): + _expect( + _register_idle_staff(StringName("blocker_%d" % blocker_index), congested), + "alternate-route blocker %d registers" % blocker_index + ) + _expect( + _spawn_moving_guest(&"route_picker", Vector3i(0, 0, 0), Vector3i(2, 0, 0)), + "route-selection Guest spawns" + ) + var planned: Array[Vector3i] = _runtime.path_service.find_path( + Vector3i(0, 0, 0), Vector3i(2, 0, 0) + ) + _expect( + planned.size() > 1 and planned[1] == Vector3i(0, 1, 0), + "congestion-aware A* selects the parallel lane before Runtime movement (%s)" % [planned] + ) + _clock.advance_one_minute() + var picker: GuestRuntime = _runtime.guest_service.get_guest(&"route_picker") + _expect( + picker != null and picker.current_cell == Vector3i(0, 1, 0), + "live congestion makes Runtime choose the cheaper parallel lane (actual %s)" % ( + picker.current_cell if picker != null else "missing" + ) + ) + _expect( + _runtime.path_service.get_actor_projected_cell(&"guest:route_picker") == picker.current_cell, + "alternate-route movement commits Actor and Path projection together" + ) + + +func _test_occupied_cell_graph_rebuild_is_rejected() -> void: + _reset_runtime(8_110) + var occupied := Vector3i(0, 0, WorldService.LAYER_FRONT) + _expect(_register_idle_staff(&"rebuild_guard", occupied), "graph-rebuild guard fixture registers") + var path_before: Dictionary = _runtime.path_service.create_snapshot() + _expect(_runtime.world_service.set_walkable(occupied, false), "fixture removes an occupied World cell") + _expect( + not _runtime.rebuild_path_graph_from_world(), + "Runtime rejects a graph rebuild that would evict a live actor projection" + ) + _expect( + _runtime.path_service.create_snapshot() == path_before + and _runtime.path_service.get_actor_projected_cell(&"staff:rebuild_guard") == occupied + and not _runtime.get_actor_movement_credit(&"staff:rebuild_guard").is_empty(), + "rejected graph rebuild preserves Path projection, progress, density, and credit" + ) + _expect(_runtime.world_service.set_walkable(occupied, true), "fixture restores the occupied World cell") + _expect(_runtime.rebuild_path_graph_from_world(), "safe topology rebuild succeeds after World repair") + + +func _test_stuck_guest_and_staff_recovery() -> void: + _reset_runtime(8_104) + _expect( + _spawn_moving_guest(&"stuck_guest", Vector3i(0, 0, 0), Vector3i(2, 0, 0)), + "stuck Guest fixture spawns" + ) + _runtime.path_service.set_cell_temporary_block(Vector3i(1, 0, 0), true) + _clock.advance_minutes(PathService.STUCK_THRESHOLD_MINUTES - 1) + var guest: GuestRuntime = _runtime.guest_service.get_guest(&"stuck_guest") + _expect(guest != null and guest.state == GuestRuntime.State.MOVING, "Guest remains in movement recovery window before 30 minutes") + _clock.advance_one_minute() + guest = _runtime.guest_service.get_guest(&"stuck_guest") + _expect(guest != null and guest.state == GuestRuntime.State.LEAVING, "30-minute Path report enters existing Guest safe recovery") + _expect( + guest != null and guest.current_cell == Vector3i.ZERO, + "stuck recovery proposes a safe target without implicit teleport" + ) + _expect(_has_recent_event(&"PATH_ACTOR_STUCK", &"guest:stuck_guest"), "Guest stuck recovery emits a structured Path DomainEvent") + + _reset_runtime(8_105) + _expect(_register_idle_staff(&"stuck_staff", Vector3i(0, 0, 0)), "stuck Staff fixture registers") + var created: CommandResult = _runtime.staff_service.create_task( + &"inspect", 90, _clock.absolute_minute, + {&"target_cell": Vector3i(2, 0, 0), &"estimated_minutes": 5}, + &"stuck_task" + ) + var task: StaffTask = _runtime.staff_service.get_task(&"stuck_task") + var member: StaffRuntime = _runtime.staff_service.get_staff(&"stuck_staff") + var reserved: bool = created.is_success() \ + and task.reserve(&"stuck_staff", [], _clock.absolute_minute) \ + and member.reserve_task(&"stuck_task", _clock.absolute_minute) \ + and task.begin_moving(_clock.absolute_minute) + _expect(reserved, "StaffTask enters a consistent MOVING ownership fixture") + _runtime.path_service.set_cell_temporary_block(Vector3i(1, 0, 0), true) + _clock.advance_minutes(PathService.STUCK_THRESHOLD_MINUTES) + task = _runtime.staff_service.get_task(&"stuck_task") + member = _runtime.staff_service.get_staff(&"stuck_staff") + _expect(task != null and task.state == StaffTask.State.CANCELLED, "30-minute Staff stall cancels its owned task safely") + _expect(member != null and member.current_task_id.is_empty(), "Staff stuck recovery releases task ownership") + _expect(_has_recent_event(&"PATH_ACTOR_STUCK", &"staff:stuck_staff"), "Staff stuck cancellation emits a structured Path DomainEvent") + + +func _test_event_recovery_path_transaction() -> void: + _reset_runtime(8_109) + _expect( + _spawn_moving_guest(&"event_mover", Vector3i(-2, 0, 0), Vector3i(2, 0, 0)), + "Event recovery movement fixture spawns" + ) + var actor_before: Dictionary = _runtime.guest_service.create_snapshot() + var path_before: Dictionary = _runtime.path_service.create_snapshot() + var credit_before: Dictionary = _runtime.get_actor_movement_credit(&"guest:event_mover") + var events_before: Dictionary = _runtime.domain_events.create_snapshot() + var action: EventAction = EventAction.new().configure( + &"recovery_move", + EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY, + {&"entity_id": &"event_mover", &"cell": Vector3i(-1, 0, 0)} + ) + var applied: Dictionary = _runtime._execute_event_action_boundary( + action, &"apply", {&"event_instance_id": &"event.fixture"}, null + ) + var moved: GuestRuntime = _runtime.guest_service.get_guest(&"event_mover") + _expect( + bool(applied.get(&"ok", false)) + and moved.current_cell == Vector3i(-1, 0, 0) + and _runtime.path_service.get_actor_projected_cell(&"guest:event_mover") == moved.current_cell + and int(_runtime.get_actor_movement_credit(&"guest:event_mover")[&"credit"]) == 0, + "explicit Event recovery commits Actor, Path, and credit together" + ) + var rolled_back: Dictionary = _runtime._execute_event_action_boundary( + action, &"rollback", {}, applied.get(&"token", null) + ) + _expect( + bool(rolled_back.get(&"ok", false)) + and _runtime.guest_service.create_snapshot() == actor_before + and _runtime.path_service.create_snapshot() == path_before + and _runtime.get_actor_movement_credit(&"guest:event_mover") == credit_before + and _runtime.domain_events.create_snapshot() == events_before, + "Event rollback restores Actor, Path, credit, and DomainEvents atomically" + ) + + +func _test_v6_snapshot_crosslinks_and_legacy_v2() -> void: + _reset_runtime(8_106) + var origin := Vector3i(-3, 0, 0) + _runtime.path_service.configure_cell_traffic(origin, 1, 0, false) + for index in range(2): + _spawn_moving_guest(StringName("save_%d" % index), origin, Vector3i(3, 0, 0)) + _clock.advance_one_minute() + var snapshot: Dictionary = _runtime.create_snapshot() + var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] + _expect(int(runtime_data[&"runtime_schema_version"]) == 6, "Runtime snapshot advertises schema v6") + _expect(not runtime_data[&"movement_credits"].is_empty(), "mid-move snapshot persists fixed-point movement credits") + var digest := SimulationDigest.compute(snapshot) + _expect(_runtime.restore_snapshot(snapshot), "valid mid-move v6 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v6 save/load is exact") + + var corruptions: Array[Dictionary] = [] + var bad_actor := snapshot.duplicate(true) + bad_actor[&"systems"][&"runtime"][&"path"][&"actor_cells"][0][&"cell"] = Vector3i(0, 0, 0) + corruptions.append({&"label": "actor cell", &"snapshot": bad_actor}) + var bad_density := snapshot.duplicate(true) + for cell_entry: Dictionary in bad_density[&"systems"][&"runtime"][&"path"][&"cell_traffic"]: + if cell_entry[&"cell"] == origin: + cell_entry[&"current_density"] = int(cell_entry[&"current_density"]) + 1 + break + corruptions.append({&"label": "density", &"snapshot": bad_density}) + var bad_progress := snapshot.duplicate(true) + bad_progress[&"systems"][&"runtime"][&"path"][&"actor_progress"][0][&"last_cell"] = Vector3i(1, 0, 0) + corruptions.append({&"label": "progress", &"snapshot": bad_progress}) + var bad_credit := snapshot.duplicate(true) + bad_credit[&"systems"][&"runtime"][&"movement_credits"][0][&"credit"] = 1_000 + corruptions.append({&"label": "movement credit range", &"snapshot": bad_credit}) + var bad_credit_time := snapshot.duplicate(true) + bad_credit_time[&"systems"][&"runtime"][&"movement_credits"][0][&"updated_at"] = _clock.absolute_minute + 1 + corruptions.append({&"label": "movement credit time", &"snapshot": bad_credit_time}) + var missing_path_cell := snapshot.duplicate(true) + missing_path_cell[&"systems"][&"runtime"][&"path"][&"cell_traffic"].pop_back() + corruptions.append({&"label": "Path/world cell-set", &"snapshot": missing_path_cell}) + for corruption: Dictionary in corruptions: + var live_before: Dictionary = _runtime.create_snapshot() + _expect( + not _runtime.restore_snapshot(corruption[&"snapshot"]) + and _runtime.create_snapshot() == live_before, + "corrupt %s crosslink is rejected atomically" % corruption[&"label"] + ) + + var legacy_v2 := snapshot.duplicate(true) + var legacy_data: Dictionary = legacy_v2[&"systems"][&"runtime"] + legacy_data[&"runtime_schema_version"] = 2 + _strip_post_v2_runtime_state(legacy_data) + legacy_data[&"path"][&"actor_cells"] = [] + legacy_data[&"path"][&"actor_progress"] = [] + for traffic: Dictionary in legacy_data[&"path"][&"cell_traffic"]: + traffic[&"current_density"] = 0 + if traffic[&"cell"] == origin: + traffic[&"temporary_block"] = true + _expect( + _is_true_runtime_v2_shape(legacy_data), + "legacy fixture removes every v3-v6 aggregate and dedicated history instead of relabelling v6" + ) + _expect(_runtime.restore_snapshot(legacy_v2), "legacy Runtime v2 deterministically rebuilds Path actors and credits") + var migrated: Dictionary = _runtime.create_snapshot() + _expect( + int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6 + and migrated[&"systems"][&"runtime"][&"path"][&"actor_cells"].size() == 2 + and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2 + and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]), + "legacy migration emits canonical v6 ownership while preserving blocked traffic state" + ) + + +func _test_dispatcher_request_ownership_crosslinks() -> void: + _reset_runtime(8_107) + var stock: CommandResult = _runtime.submit_command( + &"add_inventory_stack", + {&"owner_inventory_id": &"supplier", &"item_id": &"item/ore", &"quantity": 2, &"unit_weight": 3}, + &"manager", &"supplier", 10, -1, &"path.runtime.dispatch.stock" + ) + _expect(stock.is_success(), "dispatcher crosslink fixture stocks weighted items") + var requested: CommandResult = _runtime.submit_command( + &"create_logistics_request", + { + &"request_id": &"ownership_request", + &"source_inventory_id": &"supplier", + &"destination_inventory_id": &"destination", + &"item_requirements": {&"item/ore": 2}, + }, + &"manager", &"ownership_request", 10, -1, &"path.runtime.dispatch.request" + ) + _expect(requested.is_success(), "dispatcher crosslink fixture creates a reserved request") + var dispatched: CommandResult = _runtime.submit_command( + &"dispatch_logistics", + {&"carriers": { + &"carrier_a": {&"capacity": 10, &"current_load": 0, &"path_minutes": 1}, + }}, + &"manager", &"ownership_request", 10, -1, &"path.runtime.dispatch.assign" + ) + _expect(dispatched.is_success(), "dispatcher assigns request-ID ownership") + var snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "active dispatcher ownership snapshot restores") + var ownership_tamper: Dictionary = snapshot.duplicate(true) + ownership_tamper[&"systems"][&"runtime"][&"logistics_dispatcher"][&"assignment_ownership"][0][&"carrier_id"] = "carrier_b" + var live_before: Dictionary = _runtime.create_snapshot() + _expect( + not _runtime.restore_snapshot(ownership_tamper) and _runtime.create_snapshot() == live_before, + "request-level carrier tamper is rejected despite unchanged aggregate load" + ) + var coherent_dispatcher_tamper: Dictionary = snapshot.duplicate(true) + var tampered_dispatcher: Dictionary = coherent_dispatcher_tamper[&"systems"][&"runtime"][&"logistics_dispatcher"] + tampered_dispatcher[&"assignment_ownership"][0][&"carrier_id"] = "carrier_b" + tampered_dispatcher[&"carrier_loads"][0][&"carrier_id"] = "carrier_b" + tampered_dispatcher[&"assignment_history"][0][&"carrier_id"] = "carrier_b" + _expect( + not _runtime.restore_snapshot(coherent_dispatcher_tamper) + and _runtime.create_snapshot() == live_before, + "Runtime rejects internally coherent Dispatcher carrier ownership that conflicts with its request" + ) + var state_tamper: Dictionary = snapshot.duplicate(true) + state_tamper[&"systems"][&"runtime"][&"logistics_requests"][0][&"state"] = "COMPLETED" + _expect( + not _runtime.restore_snapshot(state_tamper) and _runtime.create_snapshot() == live_before, + "terminal-state tamper cannot retain active Dispatcher ownership" + ) + var history_tamper: Dictionary = snapshot.duplicate(true) + history_tamper[&"systems"][&"runtime"][&"logistics_dispatcher"][&"assignment_history"][0][&"weight"] = 3 + _expect( + not _runtime.restore_snapshot(history_tamper) and _runtime.create_snapshot() == live_before, + "request-level history weight tamper is rejected atomically" + ) + + +func _test_twenty_four_hour_projection_integrity() -> void: + _reset_runtime(8_108) + _expect(_register_idle_staff(&"day_guard", Vector3i(0, 0, 0)), "24-hour Staff projection registers") + _clock.advance_minutes(1_440) + var snapshot: Dictionary = _runtime.create_snapshot() + _expect( + _runtime.path_service.has_actor_projection(&"staff:day_guard") + and _runtime.path_service.get_actor_projected_cell(&"staff:day_guard") == Vector3i.ZERO, + "24-hour idle processing leaves no Path actor orphan" + ) + _expect(_runtime.restore_snapshot(snapshot), "24-hour Path/credit snapshot passes strict crosslink restore") + _expect(_runtime.create_snapshot() == snapshot, "24-hour Path traffic state restores exactly") + + +func _spawn_moving_guest(guest_id: StringName, cell: Vector3i, target: Vector3i) -> bool: + var result: CommandResult = _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": guest_id, + &"guest_type_id": &"traveler", + &"gold": 100, + &"options": { + &"current_cell": cell, + &"state": GuestRuntime.State.MOVING, + &"state_entered_at": _clock.absolute_minute - 1, + &"planned_exit_at": _clock.absolute_minute + 2_000, + &"path_handle": {&"target_cell": target}, + }, + }, + &"world", guest_id, 10, -1, StringName("path.spawn.%s" % guest_id) + ) + return result.is_success() + + +func _register_idle_staff(staff_id: StringName, cell: Vector3i) -> bool: + var result: CommandResult = _runtime.submit_command( + &"register_staff", + { + &"staff_id": staff_id, + &"role_id": &"worker", + &"options": { + &"state": StaffRuntime.State.AVAILABLE, + &"current_cell": cell, + &"role_tags": [&"worker"], + &"permitted_task_tags": [&"work"], + }, + }, + &"manager", staff_id, 10, -1, StringName("path.staff.%s" % staff_id) + ) + return result.is_success() + + +func _guest_cells_with_prefix(prefix: String) -> Array[Vector3i]: + var cells: Array[Vector3i] = [] + for guest: GuestRuntime in _runtime.guest_service.get_guests(): + if String(guest.guest_id).begins_with(prefix): + cells.append(guest.current_cell) + return cells + + +func _has_recent_event(event_type: StringName, actor_id: StringName) -> bool: + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == event_type and actor_id in event.entity_ids: + return true + return false + + +func _strip_post_v2_runtime_state(runtime_data: Dictionary) -> void: + for key: StringName in POST_V2_RUNTIME_KEYS: + runtime_data.erase(key) + runtime_data.erase(String(key)) + _strip_post_v2_event_history(runtime_data) + _strip_post_v2_pending_commands(runtime_data) + + +func _strip_post_v2_event_history(runtime_data: Dictionary) -> void: + var stream_value: Variant = runtime_data.get(&"domain_events", null) + if not stream_value is Dictionary: + return + var stream: Dictionary = stream_value + var recent_value: Variant = stream.get("recent_events", null) + var published_value: Variant = stream.get("published_ids", null) + if not recent_value is Array or not published_value is Array: + return + var removed_event_ids: Dictionary = {} + var retained_events: Array = [] + for raw_event: Variant in recent_value: + if raw_event is Dictionary and StringName( + (raw_event as Dictionary).get("event_type", "") + ) in POST_V2_EVENT_TYPES: + removed_event_ids[StringName((raw_event as Dictionary).get("event_id", ""))] = true + continue + retained_events.append(raw_event) + var retained_published_ids: Array = [] + for raw_event_id: Variant in published_value: + if not removed_event_ids.has(StringName(raw_event_id)): + retained_published_ids.append(raw_event_id) + stream["recent_events"] = retained_events + stream["published_ids"] = retained_published_ids + + +func _strip_post_v2_pending_commands(runtime_data: Dictionary) -> void: + var queue_value: Variant = runtime_data.get(&"command_queue", null) + if not queue_value is Dictionary: + return + var queue: Dictionary = queue_value + var removed_command_ids: Dictionary = {} + for queue_key: String in ["pending", "in_flight"]: + var commands_value: Variant = queue.get(queue_key, null) + if not commands_value is Array: + continue + var retained_commands: Array = [] + for raw_command: Variant in commands_value: + if raw_command is Dictionary and StringName( + (raw_command as Dictionary).get("command_type", "") + ) in POST_V2_COMMAND_TYPES: + removed_command_ids[int((raw_command as Dictionary).get("command_id", -1))] = true + continue + retained_commands.append(raw_command) + queue[queue_key] = retained_commands + if removed_command_ids.is_empty(): + return + var accepted_value: Variant = queue.get("accepted", null) + if accepted_value is Array: + var retained_accepted: Array = [] + for raw_metadata: Variant in accepted_value: + if not raw_metadata is Dictionary or not removed_command_ids.has( + int((raw_metadata as Dictionary).get("command_id", -1)) + ): + retained_accepted.append(raw_metadata) + queue["accepted"] = retained_accepted + var idempotency_value: Variant = queue.get("idempotency", null) + if idempotency_value is Dictionary: + var idempotency: Dictionary = idempotency_value + for key: Variant in idempotency.keys(): + if removed_command_ids.has(int(idempotency[key])): + idempotency.erase(key) + + +func _is_true_runtime_v2_shape(runtime_data: Dictionary) -> bool: + if typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \ + or int(runtime_data[&"runtime_schema_version"]) != 2: + return false + for key: StringName in POST_V2_RUNTIME_KEYS: + if runtime_data.has(key) or runtime_data.has(String(key)): + return false + var stream_value: Variant = runtime_data.get(&"domain_events", null) + if not stream_value is Dictionary: + return false + var recent_value: Variant = (stream_value as Dictionary).get("recent_events", null) + if not recent_value is Array: + return false + for raw_event: Variant in recent_value: + if raw_event is Dictionary and StringName( + (raw_event as Dictionary).get("event_type", "") + ) in POST_V2_EVENT_TYPES: + return false + var queue_value: Variant = runtime_data.get(&"command_queue", null) + if not queue_value is Dictionary: + return false + for queue_key: String in ["pending", "in_flight"]: + var commands_value: Variant = (queue_value as Dictionary).get(queue_key, null) + if not commands_value is Array: + return false + for raw_command: Variant in commands_value: + if raw_command is Dictionary and StringName( + (raw_command as Dictionary).get("command_type", "") + ) in POST_V2_COMMAND_TYPES: + return false + var staff_value: Variant = runtime_data.get(&"staff", null) + if staff_value is Dictionary: + var payroll_value: Variant = (staff_value as Dictionary).get( + &"payroll_settlements", [] + ) + if payroll_value is Array and not payroll_value.is_empty(): + return false + var economy_value: Variant = runtime_data.get(&"economy", null) + if economy_value is Dictionary: + var entries_value: Variant = (economy_value as Dictionary).get(&"entries", []) + if entries_value is Array: + for raw_entry: Variant in entries_value: + if raw_entry is Dictionary and StringName( + (raw_entry as Dictionary).get(&"category", "") + ) == &"WAGE": + return false + return true + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s3/test_runtime_path_traffic.gd.uid b/tests/s3/test_runtime_path_traffic.gd.uid new file mode 100644 index 0000000..44197b3 --- /dev/null +++ b/tests/s3/test_runtime_path_traffic.gd.uid @@ -0,0 +1 @@ +uid://bgrelqd3fwfkc diff --git a/tests/s3/test_staff_payroll_operations.gd b/tests/s3/test_staff_payroll_operations.gd new file mode 100644 index 0000000..1eda946 --- /dev/null +++ b/tests/s3/test_staff_payroll_operations.gd @@ -0,0 +1,283 @@ +extends SceneTree + +const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd") +const StaffTaskType = preload("res://scripts/staff/staff_task.gd") +const StaffServiceType = preload("res://scripts/staff/staff_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S3: Staff payroll Operations boundary") + _test_preview_and_settlement_contract() + _test_employment_effective_minute_boundary() + _test_replay_conflict_and_atomic_validation() + _test_snapshot_contract() + _test_service_owned_task_cadence() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_preview_and_settlement_contract() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"z_active", 40, 0)) + service.register_staff(_staff(&"a_active", 100, 20)) + service.register_staff(_staff(&"m_departed", 70, 90)) + service.remove_staff(&"m_departed", &"LEFT", true, 0) + var preview := service.preview_daily_payroll(1) + _expect(preview[&"ok"], "positive integer day previews") + _expect( + preview[&"rows"].map(func(row: Dictionary) -> StringName: return row[&"staff_id"]) \ + == [&"a_active", &"m_departed", &"z_active"], + "preview rows are globally lexical" + ) + _expect(preview[&"rows"][0][&"prior_arrears"] == 20 and preview[&"rows"][0][&"new_wages"] == 100, "active row separates arrears from today's wage") + _expect(preview[&"rows"][1][&"new_wages"] == 0 and preview[&"rows"][1][&"total_due"] == 90, "departed row carries debt without new wage") + _expect(preview[&"new_wages_total"] == 140, "Operations receives exact new-wage expense") + _expect(preview[&"prior_arrears_total"] == 110, "Operations receives exact carried arrears") + _expect(preview[&"total_due"] == 250, "Operations receives exact total obligation") + + var applied = service.apply_daily_payroll_settlement( + &"operations.payroll.1", 1, {&"a_active": 120, &"m_departed": 30, &"z_active": 0} + ) + _expect(applied.is_success() and not applied.details[&"replayed"], "settlement commits") + _expect(applied.details[&"paid_total"] == 150, "settlement reports actual cash allocation independently") + _expect(applied.details[&"unpaid_after_total"] == 100, "settlement reports remaining liability") + _expect(service.get_staff(&"a_active").unpaid_wages == 0, "full active payment clears balance") + _expect(service.get_staff(&"z_active").unpaid_wages == 40, "zero active payment becomes arrears") + _expect(service.get_departed_unpaid_wages(&"m_departed") == 60, "partial departed payment preserves remainder") + var settlement_copy := service.get_payroll_settlement(&"operations.payroll.1") + settlement_copy[&"paid_total"] = 999 + _expect(service.get_payroll_settlement(&"operations.payroll.1")[&"paid_total"] == 150, "settlement query returns a defensive deep copy") + _expect(service.validate_invariants()[&"ok"], "committed settlement history satisfies Staff invariants") + + var day_two := service.preview_daily_payroll(2) + _expect(day_two[&"new_wages_total"] == 140 and day_two[&"prior_arrears_total"] == 100, "next day accrues once on top of exact arrears") + service.apply_daily_payroll_settlement( + &"operations.payroll.2", 2, {&"a_active": 50, &"m_departed": 60, &"z_active": 80} + ) + _expect(service.get_departed_staff_ids().is_empty(), "departed liability disappears after full paydown") + _expect(service.get_staff(&"a_active").unpaid_wages == 50, "partial next-day active payment is retained") + service.remove_staff(&"a_active", &"LEFT", true, 2) + _expect(service.get_departed_unpaid_wages(&"a_active") == 50, "departure transfers active arrears") + _expect(service.preview_daily_payroll(3)[&"rows"][0][&"new_wages"] == 0, "transferred departed debt never accrues wage") + + +func _test_employment_effective_minute_boundary() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"before_boundary", 100, 0, 1_439)) + service.register_staff(_staff(&"exact_boundary", 80, 35, 1_440)) + service.register_staff(_staff(&"after_boundary", 60, 0, 1_441)) + + var day_one := service.preview_daily_payroll(1) + var rows_by_id: Dictionary = {} + for row: Dictionary in day_one[&"rows"]: + rows_by_id[row[&"staff_id"]] = row + _expect(day_one[&"ok"] and day_one[&"new_wages_total"] == 100, "day close accrues only hires strictly before its end") + _expect(rows_by_id[&"before_boundary"][&"new_wages"] == 100, "minute before boundary accrues on that day") + _expect(rows_by_id[&"exact_boundary"][&"new_wages"] == 0, "exact-boundary hire starts accruing next day") + _expect(rows_by_id[&"exact_boundary"][&"total_due"] == 35, "exact-boundary exclusion preserves prior arrears") + _expect(rows_by_id[&"after_boundary"][&"new_wages"] == 0, "post-boundary hire does not accrue early") + + var settled = service.apply_daily_payroll_settlement( + &"effective.day.1", 1, {&"before_boundary": 100, &"exact_boundary": 10} + ) + _expect(settled.is_success() and settled.details[&"new_wages_total"] == 100, "settlement uses the same effective-time boundary as preview") + _expect(service.get_staff(&"exact_boundary").unpaid_wages == 25, "settlement pays carried arrears without inventing a boundary-day wage") + var day_two := service.preview_daily_payroll(2) + _expect(day_two[&"new_wages_total"] == 240, "all three hires accrue at the following day close") + + var invalid_service := StaffServiceType.new() + var string_minute := StaffRuntimeType.new().configure(&"string_minute", &"worker", { + &"daily_wage": 10, + &"employment_effective_minute": "1440", + }) + var float_minute := StaffRuntimeType.new().configure(&"float_minute", &"worker", { + &"daily_wage": 10, + &"employment_effective_minute": 1_440.0, + }) + var negative_minute := StaffRuntimeType.new().configure(&"negative_minute", &"worker", { + &"daily_wage": 10, + &"employment_effective_minute": -1, + }) + _expect(not invalid_service.register_staff(string_minute).is_success(), "string employment effective option is rejected") + _expect(not invalid_service.register_staff(float_minute).is_success(), "floating employment effective option is rejected") + _expect(not invalid_service.register_staff(negative_minute).is_success(), "negative employment effective option is rejected") + _expect(invalid_service.get_staff_members().is_empty(), "invalid effective-time options never partially register") + + +func _test_replay_conflict_and_atomic_validation() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"worker", 100, 0)) + var before := service.create_snapshot() + _expect(not service.preview_daily_payroll(0)[&"ok"], "zero day fails closed") + _expect(not service.preview_daily_payroll("1")[&"ok"], "string day fails closed") + _expect(not service.apply_daily_payroll_settlement(&"bad id", 1, {}).is_success(), "unstable settlement ID fails closed") + _expect(not service.apply_daily_payroll_settlement(&"not_map", 1, []).is_success(), "non-map allocation fails closed") + _expect(not service.apply_daily_payroll_settlement(&"string", 1, {&"worker": "1"}).is_success(), "string payment is not coerced") + _expect(not service.apply_daily_payroll_settlement(&"float", 1, {&"worker": 1.0}).is_success(), "float payment is not coerced") + _expect(not service.apply_daily_payroll_settlement(&"negative", 1, {&"worker": -1}).is_success(), "negative payment fails closed") + _expect(not service.apply_daily_payroll_settlement(&"over", 1, {&"worker": 101}).is_success(), "overpayment fails closed") + _expect(not service.apply_daily_payroll_settlement(&"unknown", 1, {&"ghost": 0}).is_success(), "unknown Staff ID fails closed") + _expect(service.create_snapshot() == before, "all rejected allocations preserve state atomically") + + var first = service.apply_daily_payroll_settlement(&"stable.1", 1, {&"worker": 25}) + var committed := service.create_snapshot() + _expect(first.is_success() and service.get_staff(&"worker").unpaid_wages == 75, "valid allocation commits once") + var replay = service.apply_daily_payroll_settlement(&"stable.1", 1, {&"worker": 25}) + _expect(replay.is_success() and replay.details[&"replayed"], "same ID and fingerprint replays") + _expect(service.create_snapshot() == committed, "replay never applies wages twice") + _expect(not service.apply_daily_payroll_settlement(&"stable.1", 1, {&"worker": 24}).is_success(), "same ID rejects conflicting payment") + _expect(not service.apply_daily_payroll_settlement(&"stable.1", 2, {&"worker": 25}).is_success(), "same ID rejects conflicting day") + _expect(not service.apply_daily_payroll_settlement(&"different.1", 1, {}).is_success(), "same day rejects a second ID") + _expect(service.create_snapshot() == committed, "all settlement conflicts preserve committed state") + + +func _test_snapshot_contract() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"a", 100, 20)) + service.register_staff(_staff(&"b", 50, 0)) + service.apply_daily_payroll_settlement(&"snapshot.1", 1, {&"a": 30, &"b": 0}) + service.apply_daily_payroll_settlement(&"snapshot.2", 2, {&"a": 50, &"b": 100}) + var snapshot := service.create_snapshot() + var restored := StaffServiceType.new() + _expect(restored.restore_snapshot(snapshot), "v3 snapshot restores") + _expect(restored.create_snapshot() == snapshot, "v3 snapshot round trip is exact") + var before := restored.create_snapshot() + + var fingerprint := snapshot.duplicate(true) + fingerprint[&"payroll_settlements"][0][&"fingerprint"] = "corrupt" + _expect(not restored.restore_snapshot(fingerprint), "derived fingerprint tamper is rejected") + _expect(restored.create_snapshot() == before, "fingerprint rejection is atomic") + var total := snapshot.duplicate(true) + total[&"payroll_settlements"][0][&"paid_total"] += 1 + _expect(not restored.restore_snapshot(total), "derived total tamper is rejected") + _expect(restored.create_snapshot() == before, "total rejection is atomic") + var order := snapshot.duplicate(true) + order[&"payroll_settlements"].reverse() + _expect(not restored.restore_snapshot(order), "non-canonical settlement order is rejected") + _expect(restored.create_snapshot() == before, "order rejection is atomic") + var duplicate_day := snapshot.duplicate(true) + duplicate_day[&"payroll_settlements"][1][&"day"] = 1 + _expect(not restored.restore_snapshot(duplicate_day), "duplicate settled day is rejected") + _expect(restored.create_snapshot() == before, "day uniqueness rejection is atomic") + var negative := snapshot.duplicate(true) + negative[&"payroll_settlements"][0][&"rows"][0][&"payment"] = -1 + _expect(not restored.restore_snapshot(negative), "negative historical amount is rejected") + _expect(restored.create_snapshot() == before, "negative history rejection is atomic") + var unknown_field := snapshot.duplicate(true) + unknown_field[&"payroll_settlements"][0][&"extra"] = 1 + _expect(not restored.restore_snapshot(unknown_field), "unknown current history field is rejected") + _expect(restored.create_snapshot() == before, "unknown field rejection is atomic") + var effective_type := snapshot.duplicate(true) + effective_type[&"staff"][0][&"employment_effective_minute"] = 1_440.0 + _expect(not restored.restore_snapshot(effective_type), "floating employment effective minute is rejected") + _expect(restored.create_snapshot() == before, "effective-minute type rejection is atomic") + var effective_negative := snapshot.duplicate(true) + effective_negative[&"staff"][0][&"employment_effective_minute"] = -1 + _expect(not restored.restore_snapshot(effective_negative), "negative employment effective minute is rejected") + _expect(restored.create_snapshot() == before, "negative effective-minute rejection is atomic") + var effective_missing := snapshot.duplicate(true) + effective_missing[&"staff"][0].erase(&"employment_effective_minute") + _expect(not restored.restore_snapshot(effective_missing), "current Staff snapshot rejects a missing employment effective minute") + _expect(restored.create_snapshot() == before, "missing effective-minute rejection is atomic") + var unversioned_current_row: Dictionary = snapshot[&"staff"][0].duplicate(true) + unversioned_current_row.erase(&"snapshot_version") + _expect(StaffRuntimeType.from_snapshot(unversioned_current_row) == null, "StaffRuntime rejects an effective minute without its row version") + var erased_current_row_schema := snapshot.duplicate(true) + erased_current_row_schema[&"staff"][0].erase(&"snapshot_version") + erased_current_row_schema[&"staff"][0].erase(&"employment_effective_minute") + _expect(not restored.restore_snapshot(erased_current_row_schema), "v3 rejects a Staff row disguised as pre-effective schema") + _expect(restored.create_snapshot() == before, "row-schema erasure rejection is atomic") + var effective_version := snapshot.duplicate(true) + effective_version[&"staff"][0][&"snapshot_version"] = 2 + _expect(not restored.restore_snapshot(effective_version), "unknown Staff row snapshot version is rejected") + _expect(restored.create_snapshot() == before, "Staff row version rejection is atomic") + var outer_downgrade := snapshot.duplicate(true) + outer_downgrade[&"version"] = 2 + _expect(not restored.restore_snapshot(outer_downgrade), "changing only the outer version cannot downgrade a v3 Staff snapshot") + _expect(restored.create_snapshot() == before, "outer version downgrade rejection is atomic") + + var pre_effective_v2 := snapshot.duplicate(true) + pre_effective_v2[&"version"] = 2 + for staff_snapshot: Dictionary in pre_effective_v2[&"staff"]: + staff_snapshot.erase(&"snapshot_version") + staff_snapshot.erase(&"employment_effective_minute") + var migrated_v2 := StaffServiceType.new() + _expect(migrated_v2.restore_snapshot(pre_effective_v2), "pre-effective-minute v2 Staff snapshot migrates") + _expect(migrated_v2.get_staff(&"a").employment_effective_minute == 0, "v2 legacy Staff employment starts at minute zero") + _expect( + migrated_v2.create_snapshot()[&"version"] == 3 + and migrated_v2.create_snapshot()[&"staff"][0][&"snapshot_version"] == 1, + "v2 legacy Staff row is re-emitted in the current service and row schemas" + ) + + var legacy := snapshot.duplicate(true) + legacy[&"version"] = 1 + legacy.erase(&"last_advanced_minute") + legacy.erase(&"payroll_settlements") + for staff_snapshot: Dictionary in legacy[&"staff"]: + staff_snapshot.erase(&"snapshot_version") + staff_snapshot.erase(&"employment_effective_minute") + var migrated := StaffServiceType.new() + _expect(migrated.restore_snapshot(legacy), "v1 snapshot migrates") + _expect(migrated.get_staff(&"a").unpaid_wages == 140, "v1 migration preserves active unpaid wages") + _expect(migrated.get_staff(&"a").employment_effective_minute == 0, "legacy Staff employment starts deterministically at minute zero") + _expect(migrated.create_snapshot()[&"payroll_settlements"].is_empty(), "v1 migration starts with empty settlement history") + + +func _test_service_owned_task_cadence() -> void: + var service := StaffServiceType.new() + var staff := _staff(&"task_worker", 0, 0) + var task := StaffTaskType.new().configure(&"task", &"work", 50, 0, { + &"required_role_tags": [&"worker"], + &"required_skill_min": {&"operation": 1}, + &"task_tags": [&"work"], + &"estimated_minutes": 2, + &"target_cell": Vector3i.ZERO, + }) + service.register_staff(staff) + service.register_task(task) + service.dispatch_now(0) + service.advance_one_minute(1) + _expect(task.state == StaffTaskType.State.WORKING and task.progress_minutes == 0, "RESERVED begins without same-tick work") + _expect(not service.progress_task(task.task_id, 1, 1).is_success(), "same-minute explicit progress is blocked") + service.advance_one_minute(2) + _expect(task.progress_minutes == 1, "WORKING advances exactly one minute per tick") + _expect(not service.advance_one_minute(2)[&"ok"] and task.progress_minutes == 1, "duplicate service tick is idempotently rejected") + service.advance_one_minute(3) + _expect(task.state == StaffTaskType.State.COMPLETED, "automatic work completes exactly") + + +func _staff( + staff_id: StringName, + daily_wage: int, + unpaid_wages: int, + employment_effective_minute: int = 0 +) -> StaffRuntime: + return StaffRuntimeType.new().configure(staff_id, &"worker", { + &"state": StaffRuntimeType.State.AVAILABLE, + &"role_tags": [&"worker"], + &"permitted_task_tags": [&"work"], + &"skills": {&"operation": 10}, + &"daily_wage": daily_wage, + &"employment_effective_minute": employment_effective_minute, + &"unpaid_wages": unpaid_wages, + }) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s3/test_staff_payroll_operations.gd.uid b/tests/s3/test_staff_payroll_operations.gd.uid new file mode 100644 index 0000000..2d2aa66 --- /dev/null +++ b/tests/s3/test_staff_payroll_operations.gd.uid @@ -0,0 +1 @@ +uid://bnqa31rwr3wx7 diff --git a/tests/s3/test_staff_service.gd b/tests/s3/test_staff_service.gd new file mode 100644 index 0000000..7d6c571 --- /dev/null +++ b/tests/s3/test_staff_service.gd @@ -0,0 +1,828 @@ +extends SceneTree + +const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd") +const ShiftInstanceType = preload("res://scripts/staff/shift_instance.gd") +const StaffTaskType = preload("res://scripts/staff/staff_task.gd") +const TaskDispatcherType = preload("res://scripts/staff/task_dispatcher.gd") +const StaffServiceType = preload("res://scripts/staff/staff_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S3: Staff Shift and Task Dispatcher") + _test_night_shift_crosses_midnight() + _test_shift_state_machine_and_override() + _test_multiple_future_shifts_and_strict_overlap() + _test_deterministic_dispatch_and_pair_reservation() + _test_skill_score_and_tie_break() + _test_item_reservation_failure_is_atomic() + _test_interrupt_levels() + _test_fatigue_blocks_dangerous_tasks() + _test_missing_staff_reopens_task() + _test_task_progression() + _test_automatic_task_progression_once_per_tick() + _test_employment_and_daily_payroll_contract() + _test_retirement_preserves_unpaid_wages() + _test_daily_payroll_settlement_lifecycle() + _test_payroll_validation_and_idempotency() + _test_payroll_snapshot_migration_and_tamper() + _test_remove_staff_cleans_schedule_graph() + _test_snapshot_round_trip_and_atomic_restore() + _test_orphan_recovery_is_atomic() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_night_shift_crosses_midnight() -> void: + var shift := ShiftInstanceType.create_standard( + &"night_d2", + &"staff_night", + &"NIGHT", + 2, + &"warehouse" + ) + _expect(shift != null and shift.is_valid(), "standard NIGHT Shift is valid") + _expect(shift.start_day == 2 and shift.shift_instance_id == &"night_d2", "overnight Shift keeps its start-day identity") + _expect(shift.start_at == 3_960 and shift.end_at == 4_680, "NIGHT uses exact absolute-minute bounds") + _expect(shift.crosses_midnight(), "NIGHT is marked as crossing midnight") + _expect(shift.contains_minute(4_379), "same Shift contains time before calendar midnight") + _expect(shift.contains_minute(4_380), "same Shift contains time after calendar midnight") + _expect(not shift.contains_minute(4_680), "Shift end minute is exclusive") + shift.record_duty_minute(4_379) + shift.record_duty_minute(4_380) + _expect(shift.worked_minutes == 2, "work on both calendar dates aggregates into one Shift") + var restored := ShiftInstanceType.from_snapshot(shift.create_snapshot()) + _expect(restored != null and restored.create_snapshot() == shift.create_snapshot(), "overnight Shift snapshot round trip is exact") + + +func _test_shift_state_machine_and_override() -> void: + var service := StaffServiceType.new() + var staff := _staff( + &"shift_worker", + 0, + {&"state": StaffRuntimeType.State.OFF_DUTY} + ) + _expect(service.register_staff(staff).is_success(), "Staff registers before scheduling") + _expect( + service.set_schedule_override( + staff.staff_id, + 1, + {&"start_minute_of_day": 420, &"end_minute_of_day": 900} + ).is_success(), + "one-day ScheduleOverride is stored separately" + ) + _expect( + service.create_standard_shift(staff.staff_id, &"DAY", 1, &"reception", &"shift_override").is_success(), + "standard Shift instance accepts a one-day override" + ) + var shift := service.get_shift(&"shift_override") + _expect(shift.start_minute_of_day == 420 and shift.end_minute_of_day == 900, "override changes only the concrete Shift times") + _expect(shift.template_id == &"DAY", "ScheduleOverride does not rewrite the base template ID") + service.advance_one_minute(1_830) + _expect(staff.state == StaffRuntimeType.State.COMMUTING, "Staff starts commuting exactly 30 minutes before Shift") + service.advance_one_minute(1_860) + _expect(staff.state == StaffRuntimeType.State.AVAILABLE, "Staff becomes available at Shift start") + _expect(staff.assigned_facility_id == &"reception", "Shift assigns the work facility") + service.advance_one_minute(2_340) + _expect(staff.state == StaffRuntimeType.State.OFF_DUTY and staff.shift_id.is_empty(), "Staff goes off duty at Shift end") + _expect(shift.actual_ended_at == 2_340, "Shift records its exact end transition") + + +func _test_multiple_future_shifts_and_strict_overlap() -> void: + var service := StaffServiceType.new() + var staff := _staff(&"multi_shift_worker", 10, {&"state": StaffRuntimeType.State.OFF_DUTY}) + _expect(service.register_staff(staff).is_success(), "Staff registers for multiple future Shifts") + _expect( + service.create_flex_shift(staff.staff_id, 1, 540, 600, &"workshop", &"shift_second").is_success(), + "later future Shift can be registered first" + ) + _expect( + service.create_flex_shift(staff.staff_id, 1, 420, 480, &"reception", &"shift_first").is_success(), + "earlier non-overlapping Shift can be registered second" + ) + _expect(staff.shift_id.is_empty(), "future Shift registration does not overwrite the active Shift pointer") + + service.advance_one_minute(1_830) + _expect( + staff.shift_id == &"shift_first" and staff.state == StaffRuntimeType.State.COMMUTING, + "chronologically first Shift enters COMMUTING at its commute boundary" + ) + service.advance_one_minute(1_860) + var first := service.get_shift(&"shift_first") + _expect( + staff.shift_id == first.shift_instance_id and staff.state == StaffRuntimeType.State.AVAILABLE, + "chronologically first Shift becomes ACTIVE at start" + ) + _expect(first.actual_started_at == 1_860 and staff.assigned_facility_id == &"reception", "first Shift records start and facility") + service.advance_one_minute(1_920) + _expect( + first.actual_ended_at == 1_920 and staff.shift_id.is_empty() and staff.state == StaffRuntimeType.State.OFF_DUTY, + "first Shift becomes COMPLETED and releases its active pointer" + ) + service.advance_one_minute(1_950) + _expect( + staff.shift_id == &"shift_second" and staff.state == StaffRuntimeType.State.COMMUTING, + "second future Shift becomes the active commute after the first completes" + ) + service.advance_one_minute(1_980) + var second := service.get_shift(&"shift_second") + _expect( + staff.shift_id == second.shift_instance_id and staff.state == StaffRuntimeType.State.AVAILABLE, + "second Shift becomes ACTIVE in sequence" + ) + service.advance_one_minute(2_040) + _expect( + second.actual_ended_at == 2_040 and staff.shift_id.is_empty() and staff.state == StaffRuntimeType.State.OFF_DUTY, + "second Shift becomes COMPLETED in sequence" + ) + _expect(service.validate_invariants()[&"ok"], "multiple completed Shift records preserve invariants") + + var overlap_service := StaffServiceType.new() + var overlap_staff := _staff(&"overlap_worker", 10, {&"state": StaffRuntimeType.State.OFF_DUTY}) + overlap_service.register_staff(overlap_staff) + overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 420, 480, &"a", &"base_shift") + _expect( + not overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 400, 430, &"b", &"overlap_left").is_success(), + "overlap is rejected when the proposed Shift starts before the existing Shift" + ) + _expect( + not overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 470, 500, &"b", &"overlap_right").is_success(), + "overlap is rejected when the proposed Shift starts inside the existing Shift" + ) + _expect( + not overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 400, 500, &"b", &"overlap_enclosing").is_success(), + "overlap is rejected when the proposed Shift encloses the existing Shift" + ) + _expect( + overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 480, 510, &"b", &"adjacent_shift").is_success(), + "exactly adjacent half-open Shift intervals are accepted" + ) + + +func _test_deterministic_dispatch_and_pair_reservation() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"staff_b", 10)) + service.register_staff(_staff(&"staff_a", 10)) + service.register_task(_task(&"task_b", 50, 100)) + service.register_task(_task(&"task_a", 50, 100)) + var report: Dictionary = service.dispatch_now(100) + _expect(report[&"assigned"].size() == 2, "dispatcher fills two eligible Staff reservations") + _expect(report[&"assigned"][0][&"task_id"] == &"task_a", "Task tie breaks lexically after priority and deadline") + _expect(report[&"assigned"][0][&"staff_id"] == &"staff_a", "Staff score tie breaks lexically") + _expect(report[&"assigned"][1][&"task_id"] == &"task_b", "second Task remains in deterministic order") + _expect(report[&"assigned"][1][&"staff_id"] == &"staff_b", "one Staff is not assigned twice in one dispatch pass") + var task_a := service.get_task(&"task_a") + var staff_a := service.get_staff(&"staff_a") + _expect(task_a.assigned_staff_id == staff_a.staff_id, "Task points to its reserved Staff") + _expect(staff_a.current_task_id == task_a.task_id, "Staff points back to its reserved Task") + _expect(service.validate_invariants()[&"ok"], "dispatcher preserves Task/Staff bidirectional invariant") + + +func _test_skill_score_and_tie_break() -> void: + var dispatcher := TaskDispatcherType.new() + var task := _task(&"skill_task", 40, 0, { + &"required_skill_min": {&"operation": 5}, + }) + var novice := _staff(&"novice", 5) + var expert := _staff(&"expert", 30) + var novice_score := dispatcher.score_candidate(task, novice, 2) + var expert_score := dispatcher.score_candidate(task, expert, 2) + _expect(expert_score > novice_score, "higher matching Skill raises StaffTaskScore") + _expect( + dispatcher.score_candidate(task, expert, 5) < dispatcher.score_candidate(task, expert, 2), + "longer travel lowers StaffTaskScore with integer-minute penalty" + ) + expert.is_overtime = true + _expect(dispatcher.score_candidate(task, expert, 2) < expert_score, "overtime applies a deterministic score penalty") + + +func _test_item_reservation_failure_is_atomic() -> void: + var service := StaffServiceType.new() + var reserve_calls := [0] + service.configure_callbacks( + func(_task_value: StaffTask, _minute: int) -> Dictionary: + reserve_calls[0] += 1 + return {&"ok": false, &"reason": &"NO_STOCK"}, + func(_ids: Array[StringName], _task_value: StaffTask, _minute: int) -> bool: + return true + ) + var staff := _staff(&"stock_worker", 10) + service.register_staff(staff) + var task := _task(&"stock_task", 80, 0, {&"required_items": {&"meal": 2}}) + service.register_task(task) + var report: Dictionary = service.dispatch_now(0) + _expect(reserve_calls[0] == 1 and report[&"blocked"].size() == 1, "required items are reserved before Staff mutation") + _expect(task.state == StaffTaskType.State.BLOCKED and task.assigned_staff_id.is_empty(), "stock failure sends Task to BLOCKED without half reservation") + _expect(staff.state == StaffRuntimeType.State.AVAILABLE and staff.current_task_id.is_empty(), "stock failure leaves Staff fully available") + _expect(service.validate_invariants()[&"ok"], "failed item transaction preserves Staff invariants") + + +func _test_interrupt_levels() -> void: + var protected_service := StaffServiceType.new() + var protected_staff := _staff(&"protected_worker", 10) + protected_service.register_staff(protected_staff) + var protected_task := _task(&"protected_task", 20, 0, {&"interrupt_level": 0}) + protected_service.register_task(protected_task) + protected_service.dispatch_now(0) + protected_service.begin_task(protected_task.task_id, 0) + var replacement := _task(&"replacement", 100, 1, {&"emergency": true}) + protected_service.register_task(replacement) + var protected_report: Dictionary = protected_service.dispatch_now(1) + _expect(protected_report[&"assigned"].is_empty(), "interrupt level 0 rejects even emergency replacement") + _expect(protected_task.state == StaffTaskType.State.WORKING and protected_staff.current_task_id == protected_task.task_id, "non-interruptible Task/Staff pair remains untouched") + + var emergency_service := StaffServiceType.new() + var emergency_staff := _staff(&"response_worker", 10) + emergency_service.register_staff(emergency_staff) + var level_one := _task(&"level_one", 20, 0, {&"interrupt_level": 1}) + emergency_service.register_task(level_one) + emergency_service.dispatch_now(0) + emergency_service.begin_task(level_one.task_id, 0) + var normal := _task(&"normal_high", 100, 1, {&"emergency": false}) + var emergency := _task(&"emergency", 99, 1, {&"emergency": true}) + emergency_service.register_task(normal) + emergency_service.register_task(emergency) + var emergency_report: Dictionary = emergency_service.dispatch_now(1) + _expect(emergency_report[&"assigned"].size() == 1 and emergency_report[&"assigned"][0][&"task_id"] == &"emergency", "interrupt level 1 accepts only emergency Task") + _expect(level_one.state == StaffTaskType.State.OPEN and level_one.assigned_staff_id.is_empty(), "interrupted Task returns atomically to OPEN") + _expect(emergency_staff.current_task_id == emergency.task_id and emergency.assigned_staff_id == emergency_staff.staff_id, "replacement Task and Staff reserve together") + _expect(emergency_service.validate_invariants()[&"ok"], "interrupt transaction preserves pair invariants") + + +func _test_fatigue_blocks_dangerous_tasks() -> void: + var service := StaffServiceType.new() + var exhausted := _staff(&"exhausted", 20, {&"fatigue": 90}) + service.register_staff(exhausted) + var dangerous := _task(&"dangerous", 100, 0, {&"dangerous": true}) + var safe := _task(&"rest_break", 90, 0, {&"dangerous": false, &"task_tags": [&"rest"]}) + service.register_task(dangerous) + service.register_task(safe) + var report: Dictionary = service.dispatch_now(0) + _expect(dangerous.state == StaffTaskType.State.OPEN and dangerous.assigned_staff_id.is_empty(), "fatigue 90 forbids dangerous Task assignment") + _expect(report[&"assigned"].size() == 1 and safe.assigned_staff_id == exhausted.staff_id, "fatigued Staff can still receive a safe rest Task") + _expect(exhausted.current_task_id == safe.task_id, "safe Task reserves the fatigued Staff atomically") + + +func _test_missing_staff_reopens_task() -> void: + var service := StaffServiceType.new() + var released_ids: Array[StringName] = [] + service.configure_callbacks( + func(task: StaffTask, _minute: int) -> Dictionary: + return {&"ok": true, &"reservation_ids": [StringName("reserve_%s" % task.task_id)]}, + func(ids: Array[StringName], _task_value: StaffTask, _minute: int) -> bool: + for reservation_id: StringName in ids: + released_ids.append(reservation_id) + return true + ) + var staff := _staff(&"vanishing_staff", 10) + var task := _task(&"orphan_task", 60, 0, {&"required_items": {&"tool": 1}}) + service.register_staff(staff) + service.register_task(task) + service.dispatch_now(0) + _expect(task.state == StaffTaskType.State.RESERVED and task.reservation_ids.size() == 1, "Task owns item Reservation after successful dispatch") + service._staff.erase(staff.staff_id) + var audit: Dictionary = service.audit_orphan_tasks(1) + _expect(audit[&"reopened_task_ids"] == [&"orphan_task"], "orphan audit reports vanished Staff Task") + _expect(task.state == StaffTaskType.State.OPEN and task.assigned_staff_id.is_empty(), "vanished Staff Task returns to OPEN") + _expect(task.reservation_ids.is_empty() and released_ids == [&"reserve_orphan_task"], "orphan recovery releases every item Reservation") + _expect(service.validate_invariants()[&"ok"], "orphan recovery restores all Staff invariants") + + +func _test_task_progression() -> void: + var service := StaffServiceType.new() + var staff := _staff(&"progress_worker", 10) + var task := _task(&"progress_task", 50, 0, {&"estimated_minutes": 2}) + service.register_staff(staff) + service.register_task(task) + service.dispatch_now(0) + _expect(service.begin_task(task.task_id, 1).is_success(), "reserved Task enters WORKING with its Staff") + var first = service.progress_task(task.task_id, 1, 2) + _expect(first.is_success() and not first.details[&"completed"], "Task remains WORKING before estimated duration") + var second = service.progress_task(task.task_id, 1, 3) + _expect(second.is_success() and second.details[&"completed"], "Task completes exactly at estimated duration") + _expect(task.state == StaffTaskType.State.COMPLETED and staff.current_task_id.is_empty(), "completion releases Staff while Task becomes terminal") + _expect(staff.state == StaffRuntimeType.State.AVAILABLE, "completed Task returns on-duty Staff to AVAILABLE") + + +func _test_automatic_task_progression_once_per_tick() -> void: + var service := StaffServiceType.new() + var staff := _staff(&"auto_worker", 10) + var task := _task(&"auto_task", 50, 0, {&"estimated_minutes": 2}) + service.register_staff(staff) + service.register_task(task) + service.dispatch_now(0) + var begin_tick: Dictionary = service.advance_one_minute(1) + _expect( + begin_tick[&"task_actions"].size() == 1 + and begin_tick[&"task_actions"][0][&"action"] == &"BEGAN_WORK", + "automatic cadence begins a RESERVED Task after its assignment minute" + ) + _expect(task.state == StaffTaskType.State.WORKING and task.progress_minutes == 0, "begin tick does not also consume a work minute") + _expect(not service.progress_task(task.task_id, 1, 1).is_success(), "explicit same-minute progress cannot double apply after automatic begin") + _expect(not service.advance_one_minute(1)[&"ok"], "the same StaffService minute cannot run twice") + _expect(task.progress_minutes == 0, "duplicate tick rejection preserves Task progress") + var progress_tick: Dictionary = service.advance_one_minute(2) + _expect( + progress_tick[&"task_actions"][0][&"action"] == &"PROGRESSED" + and task.progress_minutes == 1, + "the following tick applies exactly one work minute" + ) + _expect(not service.progress_task(task.task_id, 1, 2).is_success(), "automatic progress blocks a second same-minute write") + service.advance_one_minute(3) + _expect(task.state == StaffTaskType.State.COMPLETED and task.progress_minutes == 2, "automatic cadence completes at the exact estimated duration") + _expect(staff.state == StaffRuntimeType.State.AVAILABLE and staff.current_task_id.is_empty(), "automatic completion releases Staff exactly once") + + var travel_service := StaffServiceType.new() + var traveler := _staff(&"travel_worker", 10, {&"current_cell": Vector3i.ZERO}) + var remote := _task(&"remote_task", 50, 0, { + &"target_cell": Vector3i(2, 0, 0), + &"estimated_minutes": 1, + }) + travel_service.register_staff(traveler) + travel_service.register_task(remote) + travel_service.dispatch_now(0) + travel_service.advance_one_minute(1) + _expect(remote.state == StaffTaskType.State.MOVING and remote.progress_minutes == 0, "non-arrived Staff enters MOVING without phantom work") + traveler.current_cell = remote.target_cell + travel_service.advance_one_minute(2) + _expect(remote.state == StaffTaskType.State.WORKING and remote.progress_minutes == 0, "arrival begins work without same-tick progress") + travel_service.advance_one_minute(3) + _expect(remote.state == StaffTaskType.State.COMPLETED, "arrived Staff completes through the same deterministic cadence") + + +func _test_employment_and_daily_payroll_contract() -> void: + var ordinary := _staff(&"staff_z", 10) + _expect( + ordinary.daily_wage == 0 + and ordinary.employment_kind == StaffRuntimeType.EmploymentKind.FREE_HIRE, + "legacy generic Staff defaults to a zero-wage FREE_HIRE employment row" + ) + var service := StaffServiceType.new() + _expect(service.register_staff(ordinary).is_success(), "generic Staff remains registrable without adult-content fields") + _expect( + service.register_staff(_staff(&"staff_a", 10, { + &"daily_wage": 240, + &"employment_kind": &"ADULT_CONTRACT", + })).is_success(), + "ADULT_CONTRACT is accepted as a commercial kind without duplicating the catalog gate" + ) + _expect( + service.register_staff(_staff(&"staff_m", 10, { + &"daily_wage": 75, + &"employment_kind": StaffRuntimeType.EmploymentKind.UNIQUE, + })).is_success(), + "UNIQUE employment accepts the strict enum value" + ) + var expected_rows: Array[Dictionary] = [ + {&"staff_id": &"staff_a", &"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 240}, + {&"staff_id": &"staff_m", &"employment_kind": &"UNIQUE", &"daily_wage": 75}, + {&"staff_id": &"staff_z", &"employment_kind": &"FREE_HIRE", &"daily_wage": 0}, + ] + _expect(service.get_daily_payroll_rows() == expected_rows, "daily payroll rows have an exact shape and lexical Staff order") + _expect(service.get_daily_payroll_total() == 315, "daily payroll total is the exact sum of active Staff wages") + + var invalid_service := StaffServiceType.new() + _expect( + not invalid_service.register_staff(_staff(&"string_wage", 10, {&"daily_wage": "100"})).is_success(), + "string daily wage is rejected rather than coerced" + ) + _expect( + not invalid_service.register_staff(_staff(&"float_wage", 10, {&"daily_wage": 10.0})).is_success(), + "floating-point daily wage is rejected rather than truncated" + ) + _expect( + not invalid_service.register_staff(_staff(&"negative_wage", 10, {&"daily_wage": -1})).is_success(), + "negative daily wage is rejected" + ) + _expect( + not invalid_service.register_staff(_staff(&"unknown_kind", 10, {&"employment_kind": &"CONTRACT"})).is_success(), + "unknown employment kind is rejected" + ) + _expect(invalid_service.get_staff_members().is_empty(), "invalid employment rows never partially register") + + var current_snapshot := service.create_snapshot() + var exact := StaffServiceType.new() + _expect(exact.restore_snapshot(current_snapshot), "current payroll snapshot restores") + _expect(exact.create_snapshot() == current_snapshot, "current payroll snapshot round trip is exact") + var legacy_snapshot: Dictionary = current_snapshot.duplicate(true) + for staff_snapshot: Dictionary in legacy_snapshot[&"staff"]: + staff_snapshot.erase(&"daily_wage") + staff_snapshot.erase(&"employment_kind") + var migrated := StaffServiceType.new() + _expect(migrated.restore_snapshot(legacy_snapshot), "pre-payroll Staff snapshot restores through deterministic defaults") + _expect( + migrated.get_daily_payroll_total() == 0 + and migrated.get_staff(&"staff_a").employment_kind == StaffRuntimeType.EmploymentKind.FREE_HIRE, + "legacy payroll migration defaults every existing Staff to zero-wage FREE_HIRE" + ) + + var atomic_before := exact.create_snapshot() + var negative_snapshot: Dictionary = current_snapshot.duplicate(true) + negative_snapshot[&"staff"][0][&"daily_wage"] = -1 + _expect(not exact.restore_snapshot(negative_snapshot), "negative wage snapshot corruption is rejected") + _expect(exact.create_snapshot() == atomic_before, "negative wage restore failure is atomic") + var typed_snapshot: Dictionary = current_snapshot.duplicate(true) + typed_snapshot[&"staff"][0][&"daily_wage"] = "240" + _expect(not exact.restore_snapshot(typed_snapshot), "malformed wage snapshot type is rejected") + _expect(exact.create_snapshot() == atomic_before, "malformed wage restore failure is atomic") + var kind_snapshot: Dictionary = current_snapshot.duplicate(true) + kind_snapshot[&"staff"][0][&"employment_kind"] = "UNKNOWN" + _expect(not exact.restore_snapshot(kind_snapshot), "unknown employment snapshot kind is rejected") + _expect(exact.create_snapshot() == atomic_before, "unknown kind restore failure is atomic") + var unpaid_type_snapshot: Dictionary = current_snapshot.duplicate(true) + unpaid_type_snapshot[&"staff"][0][&"unpaid_wages"] = "0" + _expect(not exact.restore_snapshot(unpaid_type_snapshot), "malformed active unpaid-wage type is rejected") + _expect(exact.create_snapshot() == atomic_before, "malformed active liability restore failure is atomic") + + var invalid_runtime := service.get_staff(&"staff_a") + invalid_runtime.daily_wage = -1 + _expect(not service.validate_invariants()[&"ok"], "Staff invariants detect a corrupted live daily wage") + invalid_runtime.daily_wage = 240 + service._departed_payroll[&"former_staff"] = -5 + _expect(not service.validate_invariants()[&"ok"], "Staff invariants detect a corrupted departed wage liability") + service._departed_payroll.erase(&"former_staff") + _expect(service.validate_invariants()[&"ok"], "valid employment and payroll state satisfies all Staff invariants") + + +func _test_retirement_preserves_unpaid_wages() -> void: + var service := StaffServiceType.new() + var staff := _staff(&"retiring_staff", 10, { + &"daily_wage": 125, + &"employment_kind": &"ADULT_CONTRACT", + &"unpaid_wages": 375, + }) + service.register_staff(staff) + var result = service.retire_staff(staff.staff_id, 50) + _expect(result.is_success() and not service.has_staff(staff.staff_id), "retirement removes active Staff after cleanup") + _expect(service.get_departed_unpaid_wages(staff.staff_id) == 375, "retirement preserves unpaid wages by former Staff ID") + _expect(service.get_daily_payroll_rows().is_empty() and service.get_daily_payroll_total() == 0, "departed Staff is removed from future daily wage expense") + var snapshot: Dictionary = service.create_snapshot() + _expect(snapshot[&"departed_payroll"][0][&"unpaid_wages"] == 375, "departed payroll liability is included in Save snapshot") + var restored := StaffServiceType.new() + _expect(restored.restore_snapshot(snapshot), "departed payroll snapshot restores exactly") + var before_corruption := restored.create_snapshot() + var malformed: Dictionary = snapshot.duplicate(true) + malformed[&"departed_payroll"][0][&"unpaid_wages"] = "375" + _expect(not restored.restore_snapshot(malformed), "malformed departed payroll amount is rejected") + _expect(restored.create_snapshot() == before_corruption, "departed payroll corruption rejection is atomic") + + +func _test_daily_payroll_settlement_lifecycle() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"staff_z", 10, {&"daily_wage": 40})) + service.register_staff(_staff(&"staff_a", 10, { + &"daily_wage": 100, + &"employment_kind": &"ADULT_CONTRACT", + &"unpaid_wages": 50, + })) + service.register_staff(_staff(&"former", 10, { + &"daily_wage": 80, + &"unpaid_wages": 90, + })) + service.remove_staff(&"former", &"CONTRACT_ENDED", true, 0) + + var preview: Dictionary = service.preview_daily_payroll(1) + var expected_rows: Array[Dictionary] = [ + { + &"staff_id": &"former", &"payroll_status": &"DEPARTED", &"employment_kind": &"", + &"daily_wage": 0, &"prior_arrears": 90, &"new_wages": 0, &"total_due": 90, + }, + { + &"staff_id": &"staff_a", &"payroll_status": &"ACTIVE", &"employment_kind": &"ADULT_CONTRACT", + &"daily_wage": 100, &"prior_arrears": 50, &"new_wages": 100, &"total_due": 150, + }, + { + &"staff_id": &"staff_z", &"payroll_status": &"ACTIVE", &"employment_kind": &"FREE_HIRE", + &"daily_wage": 40, &"prior_arrears": 0, &"new_wages": 40, &"total_due": 40, + }, + ] + _expect(preview[&"ok"] and preview[&"rows"] == expected_rows, "payroll preview has an exact uniform shape and lexical Staff order") + _expect( + preview[&"new_wages_total"] == 140 + and preview[&"prior_arrears_total"] == 140 + and preview[&"total_due"] == 280, + "payroll preview strictly separates new wages, prior arrears and total due" + ) + preview[&"rows"][0][&"total_due"] = 999 + _expect(service.get_departed_unpaid_wages(&"former") == 90, "payroll preview is a defensive copy") + + var first = service.apply_daily_payroll_settlement( + &"payroll.day.1", + 1, + {&"staff_a": 150, &"former": 30, &"staff_z": 0} + ) + _expect(first.is_success() and not first.details[&"replayed"], "first payroll settlement commits once") + _expect( + first.details[&"paid_total"] == 180 + and first.details[&"unpaid_after_total"] == 100, + "full, partial and zero payments settle in one atomic batch" + ) + _expect(service.get_staff(&"staff_a").unpaid_wages == 0, "fully paid active Staff has no arrears") + _expect(service.get_staff(&"staff_z").unpaid_wages == 40, "zero payment carries the new active wage forward") + _expect(service.get_departed_unpaid_wages(&"former") == 60, "partial departed payment reduces only preserved debt") + _expect(not service.preview_daily_payroll(1)[&"ok"], "settled day cannot be previewed as a second accrual") + + var obligations := service.get_payroll_obligations() + _expect( + obligations.map(func(row: Dictionary) -> StringName: return row[&"staff_id"]) == [&"former", &"staff_a", &"staff_z"], + "Operations obligation query is ordered across active and departed Staff" + ) + obligations[0][&"unpaid_wages"] = 999 + _expect(service.get_departed_unpaid_wages(&"former") == 60, "obligation query returns defensive row copies") + + var second_preview := service.preview_daily_payroll(2) + _expect( + second_preview[&"new_wages_total"] == 140 + and second_preview[&"prior_arrears_total"] == 100 + and second_preview[&"total_due"] == 240, + "next day accrues active wages once while retaining all arrears" + ) + var second = service.apply_daily_payroll_settlement( + &"payroll.day.2", + 2, + {&"former": 60, &"staff_a": 50, &"staff_z": 80} + ) + _expect(second.is_success() and second.details[&"paid_total"] == 190, "next-day partial allocation commits") + _expect(service.get_departed_staff_ids().is_empty(), "full departed paydown removes the departed liability row") + _expect( + service.get_staff(&"staff_a").unpaid_wages == 50 + and service.get_staff(&"staff_z").unpaid_wages == 0, + "active Staff carry only their exact post-payment balances" + ) + service.remove_staff(&"staff_a", &"CONTRACT_ENDED", true, 10) + _expect(service.get_departed_unpaid_wages(&"staff_a") == 50, "removal after settlement preserves remaining active arrears") + var third_preview := service.preview_daily_payroll(3) + _expect( + third_preview[&"rows"][0][&"staff_id"] == &"staff_a" + and third_preview[&"rows"][0][&"new_wages"] == 0 + and third_preview[&"rows"][0][&"total_due"] == 50, + "departed Staff never accrue another daily wage" + ) + _expect( + service.apply_daily_payroll_settlement(&"payroll.day.3", 3, {&"staff_a": 50, &"staff_z": 40}).is_success(), + "later settlement can fully clear active and departed obligations" + ) + _expect(service.get_departed_staff_ids().is_empty() and service.get_staff(&"staff_z").unpaid_wages == 0, "full settlement clears every liability") + + +func _test_payroll_validation_and_idempotency() -> void: + var service := StaffServiceType.new() + service.register_staff(_staff(&"paid_staff", 10, {&"daily_wage": 100})) + var pristine := service.create_snapshot() + _expect(not service.preview_daily_payroll(0)[&"ok"], "zero payroll day is rejected") + _expect(not service.preview_daily_payroll("1")[&"ok"], "string payroll day is rejected rather than coerced") + _expect(not service.apply_daily_payroll_settlement(&"bad id", 1, {}).is_success(), "unstable settlement ID is rejected") + _expect(not service.apply_daily_payroll_settlement(&"bad_payments", 1, []).is_success(), "non-Dictionary payments are rejected") + _expect(not service.apply_daily_payroll_settlement(&"typed", 1, {&"paid_staff": "100"}).is_success(), "string payment is rejected") + _expect(not service.apply_daily_payroll_settlement(&"float", 1, {&"paid_staff": 100.0}).is_success(), "floating payment is rejected") + _expect(not service.apply_daily_payroll_settlement(&"negative", 1, {&"paid_staff": -1}).is_success(), "negative payment is rejected") + _expect(not service.apply_daily_payroll_settlement(&"unknown", 1, {&"missing": 0}).is_success(), "unknown Staff payment is rejected even when zero") + _expect(not service.apply_daily_payroll_settlement(&"overpay", 1, {&"paid_staff": 101}).is_success(), "payment above exact obligation is rejected") + _expect(service.create_snapshot() == pristine, "all malformed and overpayment requests are atomic") + + var committed = service.apply_daily_payroll_settlement(&"stable.day.1", 1, {&"paid_staff": 25}) + _expect(committed.is_success() and service.get_staff(&"paid_staff").unpaid_wages == 75, "valid partial settlement commits exact arrears") + var committed_snapshot := service.create_snapshot() + var replay = service.apply_daily_payroll_settlement(&"stable.day.1", 1, {&"paid_staff": 25}) + _expect(replay.is_success() and replay.details[&"replayed"], "same settlement ID and fingerprint replays successfully") + _expect(service.create_snapshot() == committed_snapshot, "idempotent replay performs no second wage accrual or payment") + _expect(not service.apply_daily_payroll_settlement(&"stable.day.1", 1, {&"paid_staff": 24}).is_success(), "same settlement ID rejects a conflicting payment fingerprint") + _expect(not service.apply_daily_payroll_settlement(&"stable.day.1", 2, {&"paid_staff": 25}).is_success(), "same settlement ID rejects a conflicting day") + _expect(not service.apply_daily_payroll_settlement(&"other.day.1", 1, {}).is_success(), "different settlement ID cannot accrue the same day twice") + _expect(service.create_snapshot() == committed_snapshot, "all idempotency conflicts preserve committed state") + + +func _test_payroll_snapshot_migration_and_tamper() -> void: + var legacy_source := StaffServiceType.new() + legacy_source.register_staff(_staff(&"active_debt", 10, {&"daily_wage": 75, &"unpaid_wages": 125})) + legacy_source.register_staff(_staff(&"departed_debt", 10, {&"daily_wage": 50, &"unpaid_wages": 200})) + legacy_source.remove_staff(&"departed_debt", &"LEFT", true, 0) + var legacy: Dictionary = legacy_source.create_snapshot() + legacy[&"version"] = 1 + legacy.erase(&"last_advanced_minute") + legacy.erase(&"payroll_settlements") + for staff_snapshot: Dictionary in legacy[&"staff"]: + staff_snapshot.erase(&"snapshot_version") + staff_snapshot.erase(&"employment_effective_minute") + var migrated := StaffServiceType.new() + _expect(migrated.restore_snapshot(legacy), "v1 StaffService snapshot migrates to empty settlement history") + _expect( + migrated.get_staff(&"active_debt").unpaid_wages == 125 + and migrated.get_departed_unpaid_wages(&"departed_debt") == 200, + "v1 migration preserves active and departed unpaid wages" + ) + var migrated_snapshot := migrated.create_snapshot() + _expect(migrated_snapshot[&"version"] == 3 and migrated_snapshot[&"payroll_settlements"].is_empty(), "v1 migration emits canonical v3 with empty history") + + var service := StaffServiceType.new() + service.register_staff(_staff(&"staff_a", 10, {&"daily_wage": 100, &"unpaid_wages": 20})) + service.register_staff(_staff(&"staff_b", 10, {&"daily_wage": 50})) + service.apply_daily_payroll_settlement(&"snapshot.day.1", 1, {&"staff_a": 30, &"staff_b": 0}) + service.apply_daily_payroll_settlement(&"snapshot.day.2", 2, {&"staff_a": 50, &"staff_b": 100}) + var snapshot: Dictionary = service.create_snapshot() + var restored := StaffServiceType.new() + _expect(restored.restore_snapshot(snapshot), "canonical v3 payroll history restores") + _expect(restored.create_snapshot() == snapshot, "v3 payroll settlement history round trip is exact") + var v2_snapshot: Dictionary = snapshot.duplicate(true) + v2_snapshot[&"version"] = 2 + for staff_snapshot: Dictionary in v2_snapshot[&"staff"]: + staff_snapshot.erase(&"snapshot_version") + staff_snapshot.erase(&"employment_effective_minute") + var v2_migrated := StaffServiceType.new() + _expect(v2_migrated.restore_snapshot(v2_snapshot), "pre-effective-minute v2 payroll history migrates") + _expect( + v2_migrated.create_snapshot()[&"version"] == 3 + and v2_migrated.get_staff(&"staff_a").employment_effective_minute == 0, + "v2 migration emits canonical v3 Staff rows at effective minute zero" + ) + var before_tamper := restored.create_snapshot() + + var fingerprint_tamper: Dictionary = snapshot.duplicate(true) + fingerprint_tamper[&"payroll_settlements"][0][&"fingerprint"] = "0".repeat(64) + _expect(not restored.restore_snapshot(fingerprint_tamper), "tampered payroll fingerprint is rejected") + _expect(restored.create_snapshot() == before_tamper, "fingerprint rejection is atomic") + var total_tamper: Dictionary = snapshot.duplicate(true) + total_tamper[&"payroll_settlements"][0][&"total_due"] += 1 + _expect(not restored.restore_snapshot(total_tamper), "tampered payroll derived total is rejected") + _expect(restored.create_snapshot() == before_tamper, "derived total rejection is atomic") + var amount_tamper: Dictionary = snapshot.duplicate(true) + amount_tamper[&"payroll_settlements"][0][&"rows"][0][&"payment"] = -1 + _expect(not restored.restore_snapshot(amount_tamper), "negative historical payment is rejected") + _expect(restored.create_snapshot() == before_tamper, "negative history rejection is atomic") + var order_tamper: Dictionary = snapshot.duplicate(true) + order_tamper[&"payroll_settlements"].reverse() + _expect(not restored.restore_snapshot(order_tamper), "unordered settlement days are rejected") + _expect(restored.create_snapshot() == before_tamper, "history order rejection is atomic") + var duplicate_day: Dictionary = snapshot.duplicate(true) + duplicate_day[&"payroll_settlements"][1][&"day"] = 1 + _expect(not restored.restore_snapshot(duplicate_day), "duplicate settlement day is rejected") + _expect(restored.create_snapshot() == before_tamper, "duplicate day rejection is atomic") + var extra_field: Dictionary = snapshot.duplicate(true) + extra_field[&"payroll_settlements"][0][&"unexpected"] = true + _expect(not restored.restore_snapshot(extra_field), "unknown current payroll history field is rejected") + _expect(restored.create_snapshot() == before_tamper, "unknown field rejection is atomic") + var typed_day: Dictionary = snapshot.duplicate(true) + typed_day[&"payroll_settlements"][0][&"day"] = "1" + _expect(not restored.restore_snapshot(typed_day), "string historical day is rejected rather than coerced") + _expect(restored.create_snapshot() == before_tamper, "historical type rejection is atomic") + + +func _test_remove_staff_cleans_schedule_graph() -> void: + var service := StaffServiceType.new() + var staff := _staff(&"scheduled_departure", 10, {&"state": StaffRuntimeType.State.OFF_DUTY}) + service.register_staff(staff) + service.set_schedule_override(staff.staff_id, 3, {&"start_minute_of_day": 420}) + service.set_schedule_override(staff.staff_id, 4, {&"assigned_facility_id": &"remote"}) + service.create_standard_shift(staff.staff_id, &"DAY", 3, &"reception", &"departure_day_3") + service.create_standard_shift(staff.staff_id, &"DAY", 4, &"warehouse", &"departure_day_4") + var removal = service.remove_staff(staff.staff_id, &"CONTRACT_ENDED", true, 100) + _expect(removal.is_success() and not service.has_staff(staff.staff_id), "Staff removal succeeds with future schedule records") + _expect(service.get_shifts().is_empty(), "Staff removal deletes every related Shift instance") + _expect( + service.get_schedule_override(staff.staff_id, 3).is_empty() + and service.get_schedule_override(staff.staff_id, 4).is_empty(), + "Staff removal deletes every related ScheduleOverride" + ) + _expect( + removal.details[&"removed_shift_ids"] == [&"departure_day_3", &"departure_day_4"] + and removal.details[&"removed_override_days"] == [3, 4], + "Staff removal reports deterministic schedule cleanup" + ) + _expect(service.validate_invariants()[&"ok"], "Staff removal leaves no orphan schedule graph") + var snapshot := service.create_snapshot() + var restored := StaffServiceType.new() + _expect(restored.restore_snapshot(snapshot), "post-removal StaffService snapshot restores") + _expect(restored.create_snapshot() == snapshot, "post-removal snapshot round trip is exact") + + +func _test_snapshot_round_trip_and_atomic_restore() -> void: + var original := StaffServiceType.new() + var staff := _staff(&"snapshot_staff", 20) + original.register_staff(staff) + original.create_standard_shift(staff.staff_id, &"NIGHT", 0, &"warehouse", &"snapshot_shift") + original.advance_one_minute(1_080) + var task := _task(&"snapshot_task", 70, 1_080) + original.register_task(task) + original.dispatch_now(1_080) + var snapshot: Dictionary = original.create_snapshot() + var restored := StaffServiceType.new() + _expect(restored.restore_snapshot(snapshot), "valid StaffService snapshot restores") + _expect(restored.create_snapshot() == snapshot, "Staff, Shift, Task and Dispatcher round trip exactly") + _expect(restored.validate_invariants()[&"ok"], "restored Staff graph satisfies pair invariants") + + var before_invalid: Dictionary = restored.create_snapshot() + var invalid: Dictionary = snapshot.duplicate(true) + invalid[&"tasks"][0][&"assigned_staff_id"] = "missing_staff" + _expect(not restored.restore_snapshot(invalid), "snapshot with orphan Task is rejected") + _expect(restored.create_snapshot() == before_invalid, "failed restore leaves live Staff state untouched") + + +func _test_orphan_recovery_is_atomic() -> void: + var release_outcome := {&"ok": false} + var release_calls: Array = [] + var service := StaffServiceType.new() + service.configure_callbacks( + Callable(), + func(ids: Array[StringName], _task_value: StaffTask, _minute: int) -> Dictionary: + release_calls.append(ids.duplicate()) + return {&"ok": bool(release_outcome[&"ok"])} + ) + service.register_staff(_staff(&"live_staff", 10)) + var live_before: Dictionary = service.create_snapshot() + var reopened_signals: Array[StringName] = [] + service.task_reopened.connect( + func(task_id: StringName, _reason: StringName) -> void: + reopened_signals.append(task_id) + ) + + var candidate := StaffServiceType.new().create_snapshot() + var orphan_a := _task(&"orphan_a", 50, 0, { + &"required_items": {&"tool": 1}, + &"state": StaffTaskType.State.RESERVED, + &"assigned_staff_id": &"missing_a", + &"reservation_ids": [&"reservation_a"], + }) + var orphan_b := _task(&"orphan_b", 40, 0, { + &"required_items": {&"tool": 1}, + &"state": StaffTaskType.State.RESERVED, + &"assigned_staff_id": &"missing_b", + &"reservation_ids": [&"reservation_b"], + }) + candidate[&"tasks"] = [orphan_a.create_snapshot(), orphan_b.create_snapshot()] + _expect(not service.restore_snapshot_with_orphan_recovery(candidate), "failed external Reservation release rejects orphan recovery") + _expect(service.create_snapshot() == live_before and reopened_signals.is_empty(), "failed orphan recovery preserves live Staff snapshot and emits no repair signals") + _expect(release_calls == [[&"reservation_a", &"reservation_b"]], "all orphan Reservations use one external release transaction") + _expect(service.last_recovery_repair_report[&"release_failed_task_ids"] == [&"orphan_a", &"orphan_b"], "failed transaction reports every unrepaired Task exactly") + + release_outcome[&"ok"] = true + release_calls.clear() + _expect(service.restore_snapshot_with_orphan_recovery(candidate), "successful external release commits the isolated repaired candidate") + _expect(release_calls == [[&"reservation_a", &"reservation_b"]], "successful recovery still performs exactly one Reservation transaction") + _expect(service.get_task(&"orphan_a").state == StaffTaskType.State.OPEN and service.get_task(&"orphan_b").state == StaffTaskType.State.OPEN, "committed recovery reopens only the two orphan Tasks") + _expect(reopened_signals == [&"orphan_a", &"orphan_b"] and service.validate_invariants()[&"ok"], "repair signals publish only after the valid candidate commits") + + var committed_before: Dictionary = service.create_snapshot() + var signal_count_before := reopened_signals.size() + var unrelated_damage: Dictionary = candidate.duplicate(true) + var missing_staff_shift := ShiftInstanceType.create_standard(&"invalid_shift", &"missing_shift_staff", &"DAY", 0) + unrelated_damage[&"shifts"] = [missing_staff_shift.create_snapshot()] + release_calls.clear() + _expect(not service.restore_snapshot_with_orphan_recovery(unrelated_damage), "recovery rejects non-orphan Shift corruption mixed with repairable Tasks") + _expect(release_calls.is_empty(), "non-repairable damage is rejected before any external Reservation release") + _expect(service.create_snapshot() == committed_before and reopened_signals.size() == signal_count_before, "non-repairable recovery failure preserves live snapshot and signals atomically") + + var exact := StaffServiceType.new() + _expect(exact.restore_snapshot_with_orphan_recovery(committed_before), "ordinary valid snapshot also restores through the recovery boundary") + _expect(exact.create_snapshot() == committed_before, "ordinary recovery-boundary round trip remains exact") + + +func _staff( + staff_id: StringName, + operation_skill: int, + options: Dictionary = {} +) -> StaffRuntime: + var configuration := { + &"state": StaffRuntimeType.State.AVAILABLE, + &"role_tags": [&"worker"], + &"permitted_task_tags": [&"work", &"rest"], + &"skills": {&"operation": operation_skill}, + } + for key: Variant in options: + configuration[key] = options[key] + return StaffRuntimeType.new().configure(staff_id, &"worker", configuration) + + +func _task( + task_id: StringName, + priority: int, + created_at: int, + options: Dictionary = {} +) -> StaffTask: + var configuration := { + &"required_role_tags": [&"worker"], + &"required_skill_min": {&"operation": 1}, + &"task_tags": [&"work"], + &"estimated_minutes": 5, + &"interrupt_level": 3, + } + for key: Variant in options: + configuration[key] = options[key] + return StaffTaskType.new().configure(task_id, &"work", priority, created_at, configuration) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s3/test_staff_service.gd.uid b/tests/s3/test_staff_service.gd.uid new file mode 100644 index 0000000..7d83dda --- /dev/null +++ b/tests/s3/test_staff_service.gd.uid @@ -0,0 +1 @@ +uid://djxblic7ohru8 diff --git a/tests/s4/test_placement_risk_preview_service.gd b/tests/s4/test_placement_risk_preview_service.gd new file mode 100644 index 0000000..1ea6837 --- /dev/null +++ b/tests/s4/test_placement_risk_preview_service.gd @@ -0,0 +1,473 @@ +extends SceneTree + +const PreviewServiceType = preload("res://scripts/relationship/placement_risk_preview_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S4: Placement Risk Preview Service") + _test_half_open_multiday_contact_and_replacement() + _test_deterministic_order_across_facilities() + _test_high_contact_branch_and_route_confirmation() + _test_content_filter_changes_presentation_only() + _test_determinism_purity_and_statelessness() + _test_avoidance_suggestions_reduce_contact_and_risk() + _test_strict_fail_closed_schema() + _test_collection_limits_and_derived_values() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_half_open_multiday_contact_and_replacement() -> void: + var service := PreviewServiceType.new() + var adjacent := _base_request(false) + adjacent[&"period"] = {&"start_at": 0, &"end_at": 120} + adjacent[&"actors"][0][&"existing_shifts"] = [ + _shift("hero_edge", "shared", 0, 60), + ] + adjacent[&"actors"][1][&"existing_shifts"] = [ + _shift("rival_edge", "shared", 60, 120), + ] + var adjacent_result: Dictionary = service.preview(adjacent) + _expect(adjacent_result[&"ok"], "adjacent half-open fixture previews") + _expect(adjacent_result[&"totals"][&"contact_minutes"] == 0, "adjacent half-open Shifts add no phantom contact") + _expect(adjacent_result[&"totals"][&"shield_attack_candidates"] == 0, "adjacent half-open Shifts add no attack candidate") + + var one_minute := adjacent.duplicate(true) + one_minute[&"actors"][1][&"existing_shifts"][0][&"start_at"] = 59 + var one_minute_result: Dictionary = service.preview(one_minute) + _expect(one_minute_result[&"ok"], "one-minute overlap fixture previews") + _expect(one_minute_result[&"totals"][&"contact_minutes"] == 1, "half-open overlap counts the exact minute") + var edge_interval: Dictionary = one_minute_result[&"relationships"][0][&"contacts_by_facility"][0][&"intervals"][0] + _expect(edge_interval[&"start_at"] == 59 and edge_interval[&"end_at"] == 60, "half-open interval bounds remain exact") + + var high_result: Dictionary = service.preview(_base_request(true)) + _expect(high_result[&"ok"], "multi-day proposal fixture previews") + var relationship: Dictionary = high_result[&"relationships"][0] + _expect(relationship[&"total_contact_minutes"] == 240, "same-ID proposals replace existing rival Shifts instead of appending them") + _expect(relationship[&"contacts_by_facility"].size() == 1, "shared contacts group by facility") + var intervals: Array = relationship[&"contacts_by_facility"][0][&"intervals"] + _expect(intervals.size() == 2, "separate days retain separate overlap intervals") + _expect( + intervals[0][&"start_at"] == 90 + and intervals[0][&"end_at"] == 240 + and intervals[0][&"minutes"] == 150, + "adjacent heroine Shifts merge without duplicate minutes" + ) + _expect(intervals[0][&"heroine_shift_ids"] == ["hero_day_a", "hero_day_b"], "merged interval preserves deterministic source Shift IDs") + _expect( + intervals[1][&"start_at"] == 1_500 + and intervals[1][&"end_at"] == 1_590 + and intervals[1][&"minutes"] == 90, + "absolute-minute contact works across the day boundary" + ) + + +func _test_high_contact_branch_and_route_confirmation() -> void: + var service := PreviewServiceType.new() + var low: Dictionary = service.preview(_base_request(false)) + var high: Dictionary = service.preview(_base_request(true)) + _expect(low[&"ok"] and high[&"ok"], "low and high contact branches both preview") + _expect(low[&"totals"][&"contact_minutes"] == 0, "separate existing facilities are a zero-contact branch") + _expect(low[&"totals"][&"shield_attack_candidates"] == 0, "low-contact branch creates no Shield attack candidates") + _expect(low[&"totals"][&"triggerable_events"] == 0, "low-contact branch creates no Event candidates") + _expect(high[&"totals"][&"shield_attack_candidates"] == 5, "only high contact creates the deterministic Shield attack sequence") + _expect(high[&"totals"][&"triggerable_events"] == 1, "only high contact unlocks the threshold Event") + + var relationship: Dictionary = high[&"relationships"][0] + var triggers: Array[int] = [] + for attack: Dictionary in relationship[&"shield_attack_candidates"]: + triggers.append(int(attack[&"trigger_at"])) + _expect(triggers == [119, 179, 239, 1_529, 1_589], "Shield candidates use stable 30-minute then 60-minute trigger points") + _expect(relationship[&"projected_values"][&"shield_current"] == 0, "candidate mechanics clamp projected Shield at zero") + _expect(relationship[&"projected_values"][&"rival_bond"] == 49, "post-break candidates project bounded Rival Bond gains") + var candidate: Dictionary = relationship[&"triggerable_events"][0] + _expect(candidate[&"event_id"] == "danger_event", "eligible Event is selected by mechanical thresholds") + _expect(_reason_codes(candidate[&"reasons"]) == [ + "CONTACT_THRESHOLD_MET", "SHIELD_THRESHOLD_MET", "RIVAL_BOND_RANGE_MET", "SHARED_FACILITY", + ], "Event candidate reports every deterministic eligibility reason") + _expect(candidate[&"mechanics"][&"route_lock_requested"] == "EXCLUSIVE_LOCKED", "candidate retains requested exclusive Route Lock") + _expect(candidate[&"mechanics"][&"route_lock_preview"] == "EXCLUSIVE_PENDING", "preview downgrades unconfirmed exclusive lock to pending") + _expect(not candidate[&"mechanics"][&"route_lock_confirmed"], "Event candidate never claims an unconfirmed Route Lock") + var warning: Dictionary = relationship[&"route_lock_warning"] + _expect(warning[&"confirmation_required"], "exclusive candidate emits a confirmation-required warning") + _expect(not warning[&"preview_confirms_lock"], "read-only preview never confirms Route Lock") + _expect(warning[&"projected_route_lock"] == "EXCLUSIVE_PENDING", "mechanical summary keeps Route Lock pending") + _expect(relationship[&"risk_level"] == "CRITICAL", "confirmation-required high-contact branch is critical risk") + var soft_request := _base_request(true) + soft_request[&"events"][1][&"route_lock"] = "SOFT" + soft_request[&"events"][1][&"route_id"] = "" + var soft_result: Dictionary = service.preview(soft_request) + _expect(soft_result[&"relationships"][0][&"route_lock_warning"][&"projected_route_lock"] == "SOFT", "non-exclusive candidate Route Lock is still projected mechanically") + _expect(not soft_result[&"relationships"][0][&"route_lock_warning"][&"confirmation_required"], "SOFT Route Lock does not invent an exclusive confirmation") + var vulnerable_low := _base_request(false) + vulnerable_low[&"relationships"][0][&"shield_current"] = 0 + vulnerable_low[&"relationships"][0][&"rival_bond"] = 90 + _expect(service.preview(vulnerable_low)[&"relationships"][0][&"risk_level"] == "NONE", "zero-contact placement is not mislabeled from vulnerable baseline values alone") + + +func _test_deterministic_order_across_facilities() -> void: + var service := PreviewServiceType.new() + var request := _base_request(false) + request[&"period"] = {&"start_at": 0, &"end_at": 200} + request[&"actors"][0][&"existing_shifts"] = [ + _shift("hero_zeta", "zeta", 0, 60), + _shift("hero_alpha", "alpha", 100, 160), + ] + request[&"actors"][1][&"existing_shifts"] = [ + _shift("rival_zeta", "zeta", 0, 60), + _shift("rival_alpha", "alpha", 100, 160), + ] + var result: Dictionary = service.preview(request) + _expect(result[&"ok"], "multi-facility ordering fixture previews") + var relationship: Dictionary = result[&"relationships"][0] + _expect( + [ + relationship[&"contacts_by_facility"][0][&"facility_id"], + relationship[&"contacts_by_facility"][1][&"facility_id"], + ] == ["alpha", "zeta"], + "facility summaries use stable lexical order" + ) + var triggers: Array[int] = [] + for attack: Dictionary in relationship[&"shield_attack_candidates"]: + triggers.append(int(attack[&"trigger_at"])) + _expect(triggers == [29, 129], "cross-facility Shield candidates use chronological mechanical order") + + +func _test_content_filter_changes_presentation_only() -> void: + var service := PreviewServiceType.new() + var outputs: Dictionary = {} + for mode: String in ["NONE", "FADE", "REPLACE", "DISABLE"]: + var request := _base_request(true) + request[&"content_filter"][&"mode"] = mode + outputs[mode] = service.preview(request) + _expect(outputs[mode][&"ok"], "%s Content Filter preview succeeds" % mode) + + var baseline: Dictionary = outputs["NONE"][&"relationships"][0] + var expected_modes := { + "NONE": "FULL", + "FADE": "FADE", + "REPLACE": "REPLACE", + "DISABLE": "DISABLE", + } + for mode: String in ["NONE", "FADE", "REPLACE", "DISABLE"]: + var current: Dictionary = outputs[mode][&"relationships"][0] + _expect(current[&"mechanical_summary"] == baseline[&"mechanical_summary"], "%s preserves risk, cost, cooldown, lock, and projected values" % mode) + _expect(current[&"contacts_by_facility"] == baseline[&"contacts_by_facility"], "%s preserves mechanical contact intervals" % mode) + _expect(current[&"shield_attack_candidates"] == baseline[&"shield_attack_candidates"], "%s preserves Shield attack mechanics" % mode) + _expect(current[&"triggerable_events"][0][&"mechanics"] == baseline[&"triggerable_events"][0][&"mechanics"], "%s preserves Event mechanics" % mode) + _expect(current[&"triggerable_events"][0][&"reasons"] == baseline[&"triggerable_events"][0][&"reasons"], "%s preserves Event reasons" % mode) + _expect(current[&"triggerable_events"][0][&"presentation"][&"mode"] == expected_modes[mode], "%s changes only the Event presentation mode" % mode) + _expect(outputs["REPLACE"][&"relationships"][0][&"triggerable_events"][0][&"presentation"][&"display_event_id"] == "safe_notice", "REPLACE displays the validated safe replacement") + _expect(not outputs["DISABLE"][&"relationships"][0][&"triggerable_events"][0][&"presentation"][&"visible"], "DISABLE hides presentation without removing mechanics") + + +func _test_determinism_purity_and_statelessness() -> void: + var service := PreviewServiceType.new() + var request := _base_request(true) + var before := request.duplicate(true) + var first: Dictionary = service.preview(request) + var second: Dictionary = service.preview(request) + _expect(first == second, "identical request produces exact deterministic output") + _expect(request == before, "preview does not mutate any nested input data") + var shuffled := request.duplicate(true) + shuffled[&"actors"].reverse() + shuffled[&"events"].reverse() + for actor: Dictionary in shuffled[&"actors"]: + actor[&"existing_shifts"].reverse() + actor[&"proposed_shifts"].reverse() + _expect(service.preview(shuffled) == first, "input array order does not change canonical Preview order") + var low: Dictionary = service.preview(_base_request(false)) + var third: Dictionary = service.preview(request) + _expect(low[&"totals"][&"contact_minutes"] == 0 and third == first, "interleaved preview calls retain no service state") + _expect(not service.has_method("create_snapshot") and not service.has_method("restore_snapshot"), "stateless preview exposes no snapshot lifecycle") + _expect(not service.has_method("configure_callbacks"), "pure preview exposes no callback or re-entry surface") + + +func _test_avoidance_suggestions_reduce_contact_and_risk() -> void: + var service := PreviewServiceType.new() + var request := _base_request(true) + var original: Dictionary = service.preview(request) + var relationship: Dictionary = original[&"relationships"][0] + var suggestions: Array = relationship[&"avoidance_suggestions"] + _expect(suggestions.size() == 2, "preview returns both facility and time separation suggestions") + _expect(suggestions[0][&"kind"] == "CHANGE_FACILITY" and suggestions[1][&"kind"] == "SEPARATE_TIME", "avoidance suggestions have deterministic kind order") + for suggestion: Dictionary in suggestions: + _expect(suggestion[&"reduction_minutes"] > 0, "%s advertises only a positive deterministic reduction" % suggestion[&"kind"]) + var applied := request.duplicate(true) + _replace_actor_proposal(applied, String(suggestion[&"actor_id"]), suggestion[&"proposed_shift"]) + var reduced: Dictionary = service.preview(applied) + _expect(reduced[&"ok"], "%s proposal is directly previewable" % suggestion[&"kind"]) + _expect(reduced[&"totals"][&"contact_minutes"] == suggestion[&"contact_minutes_after"], "%s predicted contact matches reapplied result" % suggestion[&"kind"]) + _expect(reduced[&"totals"][&"contact_minutes"] < original[&"totals"][&"contact_minutes"], "%s lowers shared-facility contact" % suggestion[&"kind"]) + _expect(reduced[&"totals"][&"shield_attack_candidates"] < original[&"totals"][&"shield_attack_candidates"], "%s lowers Shield attack candidates" % suggestion[&"kind"]) + _expect(_risk_rank(reduced[&"relationships"][0][&"risk_level"]) < _risk_rank(relationship[&"risk_level"]), "%s lowers the resulting risk level" % suggestion[&"kind"]) + _expect(request == _base_request(true), "applying suggestions to copies leaves source proposal unchanged") + + +func _test_strict_fail_closed_schema() -> void: + var service := PreviewServiceType.new() + _expect_rejected(service.preview(null), "INVALID_TYPE", "non-Dictionary request is rejected") + var unknown_top := _base_request(false) + unknown_top[&"derived_risk"] = "NONE" + _expect_rejected(service.preview(unknown_top), "INVALID_SCHEMA", "unknown top-level field is rejected") + var missing_top := _base_request(false) + missing_top.erase(&"events") + _expect_rejected(service.preview(missing_top), "INVALID_SCHEMA", "missing top-level field is rejected") + var fake_version := _base_request(false) + fake_version[&"schema_version"] = "1" + _expect_rejected(service.preview(fake_version), "UNSUPPORTED_SCHEMA", "numeric-string schema version is rejected") + var fake_time := _base_request(false) + fake_time[&"period"][&"start_at"] = 0.0 + _expect_rejected(service.preview(fake_time), "INVALID_TYPE", "float period bound is rejected") + var long_period := _base_request(false) + long_period[&"period"][&"end_at"] = 14 * 1_440 + 1 + _expect_rejected(service.preview(long_period), "OUT_OF_RANGE", "period beyond the 14-day limit is rejected") + var actor_unknown := _base_request(false) + actor_unknown[&"actors"][0][&"derived_availability"] = true + _expect_rejected(service.preview(actor_unknown), "INVALID_SCHEMA", "unknown actor field is rejected") + var role_fake := _base_request(false) + role_fake[&"actors"][0][&"role"] = "heroine" + _expect_rejected(service.preview(role_fake), "INVALID_ENUM", "case-forged role enum is rejected") + var shift_unknown := _base_request(false) + shift_unknown[&"actors"][0][&"existing_shifts"][0][&"duration"] = 120 + _expect_rejected(service.preview(shift_unknown), "INVALID_SCHEMA", "derived Shift duration is rejected") + var shift_string := _base_request(false) + shift_string[&"actors"][0][&"existing_shifts"][0][&"start_at"] = "60" + _expect_rejected(service.preview(shift_string), "INVALID_TYPE", "numeric-string Shift bound is rejected") + var double_booked := _base_request(true) + double_booked[&"actors"][1][&"proposed_shifts"][1][&"start_at"] = 200 + double_booked[&"actors"][1][&"proposed_shifts"][1][&"end_at"] = 300 + _expect_rejected(service.preview(double_booked), "ACTOR_DOUBLE_BOOKED", "overlapping effective Shift placement is rejected") + var filter_unknown := _base_request(false) + filter_unknown[&"content_filter"][&"visible"] = false + _expect_rejected(service.preview(filter_unknown), "INVALID_SCHEMA", "unknown Content Filter field is rejected") + var bad_filter_enum := _base_request(false) + bad_filter_enum[&"content_filter"][&"mode"] = "HIDE" + _expect_rejected(service.preview(bad_filter_enum), "INVALID_ENUM", "unknown Content Filter mode is rejected") + var event_unknown := _base_request(false) + event_unknown[&"events"][0][&"eligible"] = true + _expect_rejected(service.preview(event_unknown), "INVALID_SCHEMA", "derived Event eligibility is rejected") + var event_missing := _base_request(false) + event_missing[&"events"][0].erase(&"cooldown_minutes") + _expect_rejected(service.preview(event_missing), "INVALID_SCHEMA", "missing Event field is rejected") + var event_float := _base_request(false) + event_float[&"events"][0][&"cost_gold"] = 75.0 + _expect_rejected(service.preview(event_float), "INVALID_TYPE", "float Event cost is rejected") + var event_route := _base_request(false) + event_route[&"events"][0][&"route_lock"] = "LOCKED" + _expect_rejected(service.preview(event_route), "INVALID_ENUM", "forged Route Lock enum is rejected") + var event_relationship := _base_request(false) + event_relationship[&"events"][0][&"relationship_id"] = "missing" + _expect_rejected(service.preview(event_relationship), "MISSING_RELATIONSHIP", "Event cannot reference an unknown relationship") + var duplicate_pair := _base_request(false) + var repeated_relationship: Dictionary = duplicate_pair[&"relationships"][0].duplicate(true) + repeated_relationship[&"relationship_id"] = "duplicate_pair" + duplicate_pair[&"relationships"].append(repeated_relationship) + _expect_rejected(service.preview(duplicate_pair), "DUPLICATE_PAIR", "duplicate heroine-rival pair cannot double-count Preview totals") + var missing_replacement := _base_request(true) + missing_replacement[&"content_filter"][&"mode"] = "REPLACE" + missing_replacement[&"events"][1][&"replacement_event_id"] = "missing" + _expect_rejected(service.preview(missing_replacement), "REPLACEMENT_MISSING", "REPLACE fails closed when safe replacement is absent") + var unsafe_replacement := _base_request(true) + unsafe_replacement[&"content_filter"][&"mode"] = "REPLACE" + unsafe_replacement[&"events"][0][&"content_rating"] = "RESTRICTED" + _expect_rejected(service.preview(unsafe_replacement), "REPLACEMENT_UNSAFE", "REPLACE rejects a filtered replacement chain") + + +func _test_collection_limits_and_derived_values() -> void: + var service := PreviewServiceType.new() + var too_many_actors := _base_request(false) + while too_many_actors[&"actors"].size() <= 64: + too_many_actors[&"actors"].append(too_many_actors[&"actors"][0].duplicate(true)) + _expect_rejected(service.preview(too_many_actors), "COLLECTION_LIMIT", "actor collection upper bound is enforced before parsing") + var too_many_shifts := _base_request(false) + too_many_shifts[&"actors"][0][&"existing_shifts"] = [] + for index: int in range(65): + too_many_shifts[&"actors"][0][&"existing_shifts"].append(_shift("h_%02d" % index, "solo", index * 10, index * 10 + 5)) + _expect_rejected(service.preview(too_many_shifts), "COLLECTION_LIMIT", "per-list Shift upper bound is enforced") + var too_many_effective := _base_request(false) + too_many_effective[&"actors"][0][&"existing_shifts"] = [] + for index: int in range(64): + too_many_effective[&"actors"][0][&"existing_shifts"].append(_shift("h_%02d" % index, "solo", index * 10, index * 10 + 5)) + too_many_effective[&"actors"][0][&"proposed_shifts"] = [_shift("h_new", "solo", 700, 705)] + _expect_rejected(service.preview(too_many_effective), "COLLECTION_LIMIT", "combined effective Shift upper bound prevents overlap-pair truncation") + var too_many_events := _base_request(false) + too_many_events[&"events"] = [] + for _index: int in range(257): + too_many_events[&"events"].append(_safe_event()) + _expect_rejected(service.preview(too_many_events), "COLLECTION_LIMIT", "Event definition upper bound is enforced before parsing") + var forged_shield := _base_request(false) + forged_shield[&"relationships"][0][&"shield_current"] = 90 + forged_shield[&"relationships"][0][&"shield_max"] = 80 + _expect_rejected(service.preview(forged_shield), "INVALID_DERIVED_VALUE", "forged current Shield above Shield Max is rejected") + var duplicate_tag := _base_request(false) + duplicate_tag[&"events"][1][&"content_tags"] = ["intimacy", "intimacy"] + _expect_rejected(service.preview(duplicate_tag), "DUPLICATE_VALUE", "duplicate content tags are rejected") + + +func _base_request(high_contact: bool) -> Dictionary: + var rival_proposals: Array[Dictionary] = [] + if high_contact: + rival_proposals = [ + _shift("rival_day", "shared", 90, 240), + _shift("rival_next_day", "shared", 1_500, 1_590), + ] + return { + &"schema_version": 1, + &"period": {&"start_at": 0, &"end_at": 2_880}, + &"actors": [ + { + &"actor_id": "heroine", + &"role": "HEROINE", + &"existing_shifts": [ + _shift("hero_day_a", "shared", 60, 180), + _shift("hero_day_b", "shared", 180, 300), + _shift("hero_next_day", "shared", 1_500, 1_620), + ], + &"proposed_shifts": [], + &"mechanical_effects": { + &"base_shield_attack": 0, + &"night_shield_attack_bonus": 0, + }, + }, + { + &"actor_id": "rival", + &"role": "RIVAL", + &"existing_shifts": [ + _shift("rival_day", "archive", 90, 240), + _shift("rival_next_day", "archive", 1_500, 1_590), + ], + &"proposed_shifts": rival_proposals, + &"mechanical_effects": { + &"base_shield_attack": 8, + &"night_shield_attack_bonus": 4, + }, + }, + ], + &"relationships": [ + { + &"relationship_id": "main_pair", + &"heroine_id": "heroine", + &"rival_id": "rival", + &"affection": 55, + &"rival_bond": 40, + &"shield_current": 20, + &"shield_max": 100, + &"route_lock": "NONE", + &"route_id": "", + &"target_resistance": 0, + &"monitoring_bonus": 0, + }, + ], + &"events": [_safe_event(), _danger_event()], + &"content_filter": { + &"mode": "NONE", + &"maximum_rating": "GENERAL", + &"disabled_tags": [], + }, + } + + +func _safe_event() -> Dictionary: + return { + &"event_id": "safe_notice", + &"relationship_id": "main_pair", + &"priority": 1, + &"min_contact_minutes": 1_000, + &"max_shield_current": 100, + &"min_rival_bond": 0, + &"max_rival_bond": 100, + &"required_facility_id": "", + &"shield_damage": 0, + &"rival_bond_delta": 0, + &"cost_gold": 0, + &"cooldown_minutes": 0, + &"route_lock": "NONE", + &"route_id": "", + &"content_rating": "GENERAL", + &"content_tags": [], + &"filter_behavior": "NONE", + &"replacement_event_id": "", + &"display_name": "A quiet scheduling notice", + } + + +func _danger_event() -> Dictionary: + return { + &"event_id": "danger_event", + &"relationship_id": "main_pair", + &"priority": 100, + &"min_contact_minutes": 120, + &"max_shield_current": 0, + &"min_rival_bond": 0, + &"max_rival_bond": 100, + &"required_facility_id": "shared", + &"shield_damage": 0, + &"rival_bond_delta": 5, + &"cost_gold": 75, + &"cooldown_minutes": 720, + &"route_lock": "EXCLUSIVE_LOCKED", + &"route_id": "rival_route", + &"content_rating": "RESTRICTED", + &"content_tags": ["intimacy"], + &"filter_behavior": "NONE", + &"replacement_event_id": "safe_notice", + &"display_name": "Restricted encounter", + } + + +func _shift(shift_id: String, facility_id: String, start_at: int, end_at: int) -> Dictionary: + return { + &"shift_id": shift_id, + &"facility_id": facility_id, + &"start_at": start_at, + &"end_at": end_at, + } + + +func _replace_actor_proposal(request: Dictionary, actor_id: String, proposal: Dictionary) -> void: + for actor: Dictionary in request[&"actors"]: + if actor[&"actor_id"] != actor_id: + continue + var replaced := false + for index: int in range(actor[&"proposed_shifts"].size()): + if actor[&"proposed_shifts"][index][&"shift_id"] == proposal[&"shift_id"]: + actor[&"proposed_shifts"][index] = proposal.duplicate(true) + replaced = true + break + if not replaced: + actor[&"proposed_shifts"].append(proposal.duplicate(true)) + return + + +func _reason_codes(reasons: Array) -> Array[String]: + var result: Array[String] = [] + for reason: Dictionary in reasons: + result.append(String(reason[&"code"])) + return result + + +func _risk_rank(level: String) -> int: + return ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"].find(level) + + +func _expect_rejected(result: Dictionary, code: String, message: String) -> void: + _expect(not bool(result.get(&"ok", true)) and result.get(&"error_code", "") == code, message) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if condition: + return + _failures += 1 + push_error("FAIL: %s" % message) diff --git a/tests/s4/test_placement_risk_preview_service.gd.uid b/tests/s4/test_placement_risk_preview_service.gd.uid new file mode 100644 index 0000000..056642d --- /dev/null +++ b/tests/s4/test_placement_risk_preview_service.gd.uid @@ -0,0 +1 @@ +uid://bp0j42sq0baq4 diff --git a/tests/s4/test_relationship_event_service.gd b/tests/s4/test_relationship_event_service.gd new file mode 100644 index 0000000..bacee86 --- /dev/null +++ b/tests/s4/test_relationship_event_service.gd @@ -0,0 +1,441 @@ +extends SceneTree + +const RelationshipStateType = preload("res://scripts/relationship/relationship_state.gd") +const RelationshipServiceType = preload("res://scripts/relationship/relationship_service.gd") +const EventConditionType = preload("res://scripts/events/event_condition.gd") +const EventActionType = preload("res://scripts/events/event_action.gd") +const EventChoiceType = preload("res://scripts/events/event_choice.gd") +const EventDefinitionType = preload("res://scripts/events/event_definition.gd") +const EventInstanceType = preload("res://scripts/events/event_instance.gd") +const EventServiceType = preload("res://scripts/events/event_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S4: Relationship and Event Service") + _test_relationship_pair_metrics_and_thresholds() + _test_shield_penalty_and_route_confirmation() + _test_relationship_snapshot_atomicity() + _test_condition_tree() + _test_foreground_queue_and_choice_idempotency() + _test_missing_actor_releases_lock_and_cost() + _test_content_replacement_boundary() + _test_transaction_policies() + _test_event_snapshot_round_trip_and_atomicity() + _test_event_recovery_lock_scope_and_strict_filter_parse() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_relationship_pair_metrics_and_thresholds() -> void: + var service := RelationshipServiceType.new() + var created = service.create_relationship( + &"rel_main", + &"zoe", + &"alex", + { + &"trust": 20, + &"respect": 30, + &"affection": 45, + &"fear": 5, + &"dependence": 10, + &"shield_max": 40, + &"shield_current": 35, + } + ) + _expect(created.is_success(), "relationship registers") + var state := service.get_relationship(&"rel_main") + _expect(state.actor_a_id == &"alex" and state.actor_b_id == &"zoe", "actor pair normalizes lexically") + _expect(service.find_relationship(&"alex", &"zoe") == state, "normalized pair lookup is direction independent") + _expect(not service.create_relationship(&"rel_duplicate", &"alex", &"zoe").is_success(), "duplicate normalized actor pair is rejected") + _expect(state.get_metric(&"trust") == 20 and state.get_metric(&"respect") == 30, "five canonical metrics are retained") + + var gated = service.apply_value_change(&"rel_main", &"affection", 10, 100) + _expect(gated.is_success() and state.get_metric(&"affection") == 49, "background change stops before gated threshold 50") + _expect(gated.details[&"gated_threshold"] == 50, "gated change exposes its required threshold Event") + var candidates := service.get_threshold_candidates() + _expect(candidates.size() == 1 and candidates[0][&"requires_unlock_event"], "threshold crossing registers a candidate without immediate playback") + var unlocked = service.apply_value_change( + &"rel_main", + &"affection", + 1, + 101, + {&"important": true, &"allow_threshold_crossing": true} + ) + _expect(unlocked.is_success() and state.get_metric(&"affection") == 50, "explicit important Event may cross the gated threshold") + service.apply_value_change(&"rel_main", &"trust", 50, 102) + _expect(state.get_metric(&"trust") == 30, "automatic relationship drift is limited to +10 per day") + service.apply_value_change(&"rel_main", &"trust", 50, 1_500) + _expect(state.get_metric(&"trust") == 40, "daily automatic limit resets on a new day") + + +func _test_shield_penalty_and_route_confirmation() -> void: + var service := RelationshipServiceType.new() + service.create_relationship( + &"rel_shield", + &"owner", + &"target", + {&"shield_max": 100, &"shield_current": 100} + ) + var state := service.get_relationship(&"rel_shield") + var damages: Array[int] = [] + var penalties: Array[int] = [] + for minute: int in range(4): + var result = service.apply_shield_attack( + &"rel_shield", + &"repeat_action", + minute, + {&"base_damage": 20, &"max_damage": 20, &"daily_limit": 4} + ) + damages.append(int(result.details[&"damage"])) + penalties.append(int(result.details[&"penalty_percent"])) + _expect(penalties == [0, 25, 50, 75], "repeat Action applies 0/25/50/75 percent penalty stages") + _expect(damages == [20, 15, 10, 5], "repeat penalty changes deterministic Shield damage") + _expect(not service.apply_shield_attack(&"rel_shield", &"repeat_action", 4, {&"base_damage": 20}).is_success(), "daily Action use limit is enforced") + service.apply_shield_attack(&"rel_shield", &"fresh_action", 5, {&"base_damage": 500, &"max_damage": 500}) + _expect(state.shield_current == 0, "Shield never falls below zero") + var next_day = service.apply_shield_attack(&"rel_shield", &"repeat_action", 1_440, {&"base_damage": 20, &"max_damage": 20}) + _expect(next_day.is_success() and next_day.details[&"penalty_percent"] == 0, "repeat count resets at the day boundary") + + var direct = service.request_route_lock( + &"rel_shield", + RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED, + &"route_a" + ) + _expect(not direct.is_success() and state.route_lock == RelationshipStateType.RouteLock.NONE, "exclusive route cannot lock without confirmation") + _expect(service.request_route_lock(&"rel_shield", RelationshipStateType.RouteLock.EXCLUSIVE_PENDING, &"route_a").is_success(), "exclusive route first enters pending state") + _expect(not service.confirm_route_lock(&"rel_shield", &"route_b").is_success(), "mismatched route confirmation is rejected") + _expect(service.confirm_route_lock(&"rel_shield", &"route_a").is_success(), "matching explicit confirmation locks the route") + + +func _test_relationship_snapshot_atomicity() -> void: + var service := RelationshipServiceType.new() + service.create_relationship(&"rel_save", &"b", &"a", {&"trust": 12, &"shield_max": 50, &"shield_current": 25}) + service.apply_value_change(&"rel_save", &"trust", 5, 20) + var snapshot: Dictionary = service.create_snapshot() + var restored := RelationshipServiceType.new() + _expect(restored.restore_snapshot(snapshot), "valid Relationship snapshot restores") + _expect(restored.create_snapshot() == snapshot, "Relationship snapshot round trips exactly") + var before: Dictionary = restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"version"] = 999 + _expect(not restored.restore_snapshot(corrupt), "unsupported Relationship snapshot is rejected") + _expect(restored.create_snapshot() == before, "failed Relationship restore is atomic") + + +func _test_condition_tree() -> void: + var condition := EventConditionType.new().configure( + EventConditionType.Operator.ALL, + [], + null, + [ + EventConditionType.new().configure(EventConditionType.Operator.GTE, "relationship.trust", 50), + EventConditionType.new().configure(EventConditionType.Operator.HAS_FLAG, "flags", &"met_before"), + EventConditionType.new().configure(EventConditionType.Operator.TIME_IN_WINDOW, "absolute_minute", [1_200, 300]), + EventConditionType.new().configure(EventConditionType.Operator.ENTITY_EXISTS, "actor_id"), + ] + ) + var context := { + &"relationship": {&"trust": 60}, + &"flags": {&"met_before": true}, + &"absolute_minute": 1_300, + &"actor_id": &"actor_a", + } + var callbacks := {&"entity_exists": func(actor_id: Variant) -> bool: return actor_id == &"actor_a"} + _expect(condition.is_valid() and condition.evaluate(context, callbacks), "nested data Condition evaluates comparisons, flag, overnight time and entity callback") + var clone := EventConditionType.from_variant(condition.create_snapshot()) + _expect(clone != null and clone.evaluate(context, callbacks), "Condition snapshot preserves deterministic evaluation") + context[&"relationship"][&"trust"] = 49 + _expect(not clone.evaluate(context, callbacks), "ALL condition fails when one child changes") + + +func _test_foreground_queue_and_choice_idempotency() -> void: + var actor_live := {&"actor_a": true, &"actor_b": true} + var applied := {&"count": 0} + var service := EventServiceType.new().configure_callbacks( + func(actor_id: StringName) -> bool: return bool(actor_live.get(actor_id, false)), + func(action: EventAction, phase: StringName, _context: Dictionary, token: Variant) -> Dictionary: + if phase == &"validate": + return {&"ok": true} + if phase == &"apply": + var old_value := int(applied[&"count"]) + applied[&"count"] = old_value + int(action.payload.get(&"delta", 0)) + return {&"ok": true, &"token": old_value} + if phase == &"rollback": + applied[&"count"] = int(token) + return {&"ok": true} + ) + service.register_definition(_choice_definition(&"low", 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [_delta_action(&"low_add", 1)])) + service.register_definition(_choice_definition(&"high", 50, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [_delta_action(&"high_add", 3)])) + service.enqueue_candidate(&"low", [&"actor_b"], 0, {&"event_instance_id": &"event_low"}) + service.enqueue_candidate(&"high", [&"actor_a"], 0, {&"event_instance_id": &"event_high"}) + _expect(_instance_ids(service.get_queued_instances()) == [&"event_high", &"event_low"], "Event queue sorts priority descending then deterministic instance ID") + var started = service.start_next_event(0) + _expect(started.is_success() and started.details[&"event_instance_id"] == &"event_high", "highest priority foreground Event starts first") + _expect(service.get_actor_locks() == {&"actor_a": &"event_high"}, "foreground Event locks all actors by stable ID") + _expect(not service.start_next_event(0).is_success(), "only one foreground Event may run at a time") + var choice_token: StringName = started.details[&"choice_instance_id"] + var first = service.submit_choice(&"event_high", choice_token, &"accept", 0) + var second = service.submit_choice(&"event_high", choice_token, &"accept", 0) + _expect(first.is_success() and second.is_success() and second.is_replay, "duplicate Choice submission returns cached replay") + _expect(applied[&"count"] == 3, "duplicate Choice never applies Actions twice") + _expect(service.get_actor_locks().is_empty(), "completed Event releases actor locks") + _expect(service.start_next_event(1).is_success(), "next foreground Event starts after lock release") + + +func _test_missing_actor_releases_lock_and_cost() -> void: + var actor_live := {&"actor_a": true} + var released: Array = [] + var committed: Array = [] + var service := EventServiceType.new().configure_callbacks( + func(actor_id: StringName) -> bool: return bool(actor_live.get(actor_id, false)), + func(_action: EventAction, _phase: StringName, _context: Dictionary, _token: Variant) -> Dictionary: + return {&"ok": true}, + func(cost: Dictionary, _context: Dictionary) -> Dictionary: + return {&"ok": true, &"token": {&"reserved_gold": int(cost.get(&"gold", 0))}}, + func(token: Variant, _context: Dictionary) -> bool: + released.append(token) + return true, + func(token: Variant, _context: Dictionary) -> bool: + committed.append(token) + return true + ) + var definition := _choice_definition(&"missing_actor", 20, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [_delta_action(&"noop", 0)], {&"gold": 25}) + service.register_definition(definition) + service.enqueue_candidate(&"missing_actor", [&"actor_a"], 10, {&"event_instance_id": &"event_missing"}) + var started = service.start_next_event(10) + _expect(started.is_success(), "missing-actor fixture starts while actor exists") + _expect(service.reserve_choice_cost(&"event_missing", &"accept", 10).is_success(), "Choice cost reserves while UI is waiting") + actor_live[&"actor_a"] = false + var audit := service.audit_actor_availability(11) + _expect(audit.size() == 1 and service.get_instance(&"event_missing").state == EventInstanceType.State.CANCELLED, "actor disappearance cancels Event by default policy") + _expect(service.get_actor_locks().is_empty(), "actor disappearance always releases foreground lock") + _expect(released.size() == 1 and committed.is_empty(), "actor disappearance releases reserved Cost without committing it") + + +func _test_content_replacement_boundary() -> void: + var service := EventServiceType.new() + service.register_definition(EventDefinitionType.new().configure(&"safe_business", { + &"content_rating": &"GENERAL", + &"content_tags": [&"business_conflict"], + })) + service.register_definition(EventDefinitionType.new().configure(&"restricted_with_replacement", { + &"content_rating": &"RESTRICTED", + &"content_tags": [&"restricted_route"], + &"filter_behavior": &"REPLACE", + &"replacement_event_id": &"safe_business", + })) + service.register_definition(EventDefinitionType.new().configure(&"restricted_missing", { + &"content_rating": &"RESTRICTED", + &"content_tags": [&"restricted_route"], + &"filter_behavior": &"REPLACE", + &"replacement_event_id": &"missing_safe_event", + })) + service.configure_content_filter( + EventDefinitionType.FilterBehavior.REPLACE, + EventDefinitionType.ContentRating.GENERAL, + [&"restricted_route"] + ) + var replaced := service.enqueue_candidate(&"restricted_with_replacement", [], 0, {&"event_instance_id": &"event_replaced"}) + _expect(replaced.is_success() and replaced.details[&"resolved_event_id"] == &"safe_business", "restricted Event resolves only to configured safe replacement ID") + var disabled := service.enqueue_candidate(&"restricted_missing", [], 0) + _expect(not disabled.is_success() and disabled.details[&"reason"] == &"REPLACEMENT_MISSING", "missing replacement safely disables candidate") + _expect(not service.validate_definitions()[&"ok"], "definition audit reports missing replacement boundary") + + +func _test_transaction_policies() -> void: + var rollback_state := {&"value": 0} + var rollback_service: Variant = _transaction_service(rollback_state) + rollback_service.register_definition(_choice_definition( + &"rollback_event", + 10, + EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, + [_delta_action(&"add_first", 5), _failing_action(&"fail_second")] + )) + var rollback_result = _run_choice(rollback_service, &"rollback_event", &"rollback_instance") + _expect(not rollback_result.is_success() and rollback_state[&"value"] == 0, "ROLLBACK_ALL reverses prior Action after later failure") + + var skip_state := {&"value": 0} + var skip_service: Variant = _transaction_service(skip_state) + skip_service.register_definition(_choice_definition( + &"skip_event", + 10, + EventDefinitionType.TransactionPolicy.SKIP_FAILED, + [_delta_action(&"add_one", 2), _failing_action(&"skip_me"), _delta_action(&"add_two", 3)] + )) + var skip_result = _run_choice(skip_service, &"skip_event", &"skip_instance") + _expect(skip_result.is_success() and skip_state[&"value"] == 5, "SKIP_FAILED keeps successful Actions and skips only failed Action") + _expect(skip_result.details[&"skipped_action_ids"] == [&"skip_me"], "SKIP_FAILED reports deterministic skipped Action IDs") + + var branch_state := {&"value": 0} + var branch_service: Variant = _transaction_service(branch_state) + var branch_definition := _choice_definition( + &"branch_event", + 10, + EventDefinitionType.TransactionPolicy.BRANCH_FAILURE, + [_delta_action(&"primary_add", 9), _failing_action(&"primary_fail")] + ) + branch_definition.failure_actions = [_delta_action(&"failure_branch_add", 4)] + branch_service.register_definition(branch_definition) + var branch_result = _run_choice(branch_service, &"branch_event", &"branch_instance") + _expect(branch_result.is_success() and branch_state[&"value"] == 4, "BRANCH_FAILURE rolls back primary Actions then applies failure branch") + _expect(branch_result.details[&"branched_failure"], "BRANCH_FAILURE result marks selected failure branch") + + +func _test_event_snapshot_round_trip_and_atomicity() -> void: + var service := EventServiceType.new() + service.register_definition(_choice_definition(&"save_event", 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [])) + service.enqueue_candidate(&"save_event", [&"actor_a"], 100, {&"event_instance_id": &"save_instance"}) + service.start_next_event(100) + var snapshot: Dictionary = service.create_snapshot() + var restored := EventServiceType.new() + restored.register_definition(_choice_definition(&"save_event", 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [])) + _expect(restored.restore_snapshot(snapshot), "Event queue, Choice token and Actor Locks restore") + _expect(restored.create_snapshot() == snapshot, "active foreground Event snapshot round trips exactly") + _expect(restored.validate_invariants()[&"ok"], "restored EventService satisfies Actor Lock invariants") + var before: Dictionary = restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"actor_locks"] = {&"wrong_actor": &"save_instance"} + _expect(not restored.restore_snapshot(corrupt), "orphaned Actor Lock snapshot is rejected") + _expect(restored.create_snapshot() == before, "failed Event restore is atomic") + + +func _test_event_recovery_lock_scope_and_strict_filter_parse() -> void: + var definition := _choice_definition( + &"recover_event", + 10, + EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, + [] + ) + var source := EventServiceType.new() + source.register_definition(definition) + source.enqueue_candidate(&"recover_event", [&"actor_a"], 10, {&"event_instance_id": &"recover_instance"}) + source.start_next_event(10) + var terminal_snapshot: Dictionary = source.create_snapshot() + terminal_snapshot[&"instances"][0][&"state"] = "COMPLETED" + terminal_snapshot[&"active_deduplication_keys"] = {} + var recovered := EventServiceType.new() + recovered.register_definition(definition) + _expect(recovered.restore_snapshot_with_lock_recovery(terminal_snapshot), "recovery accepts the terminal foreground's exact Actor Lock set") + _expect(recovered.get_actor_locks().is_empty() and recovered.get_foreground_instance() == null, "terminal recovery releases only the matching foreground pointer and lock") + _expect(recovered.last_recovery_repair_report[&"released_actor_ids"] == [&"actor_a"], "terminal recovery report names the exact released Actor Lock") + + var live_before: Dictionary = recovered.create_snapshot() + var mixed_corruption: Dictionary = terminal_snapshot.duplicate(true) + mixed_corruption[&"actor_locks"][&"unrelated_actor"] = "recover_instance" + _expect(not recovered.restore_snapshot_with_lock_recovery(mixed_corruption), "valid foreground lock mixed with unrelated lock corruption is hard-rejected") + _expect(recovered.create_snapshot() == live_before, "unrelated lock rejection preserves the live Event state atomically") + + var missing_foreground: Dictionary = terminal_snapshot.duplicate(true) + missing_foreground[&"instances"] = [] + var missing_recovered := EventServiceType.new() + missing_recovered.register_definition(definition) + _expect(missing_recovered.restore_snapshot_with_lock_recovery(missing_foreground), "missing foreground recovery releases locks that still name only the missing foreground ID") + _expect(missing_recovered.last_recovery_repair_report[&"reason"] == &"MISSING_FOREGROUND" and missing_recovered.last_recovery_repair_report[&"released_actor_ids"] == [&"actor_a"], "missing foreground report preserves the exact released Actor IDs and reason") + + var unknown_mode: Dictionary = live_before.duplicate(true) + unknown_mode[&"filter_mode"] = "UNKNOWN_FILTER" + _expect(not recovered.restore_snapshot(unknown_mode), "unknown serialized Content Filter behavior is rejected instead of falling back") + _expect(recovered.create_snapshot() == live_before, "unknown filter rejection preserves the live Event state") + var out_of_range_rating: Dictionary = live_before.duplicate(true) + out_of_range_rating[&"maximum_content_rating"] = 99 + _expect(not recovered.restore_snapshot(out_of_range_rating), "out-of-range serialized Content Rating is rejected instead of clamped") + _expect(recovered.create_snapshot() == live_before, "out-of-range rating rejection preserves the live Event state") + + var serialized_definition: Dictionary = definition.create_snapshot() + serialized_definition[&"filter_behavior"] = "UNKNOWN_FILTER" + _expect(EventDefinitionType.from_variant(serialized_definition) == null, "serialized EventDefinition rejects an unknown FilterBehavior") + serialized_definition = definition.create_snapshot() + serialized_definition[&"content_rating"] = 99 + _expect(EventDefinitionType.from_variant(serialized_definition) == null, "serialized EventDefinition rejects an out-of-range ContentRating") + var lenient_input := EventDefinitionType.new().configure(&"settings_boundary", {&"filter_behavior": "UNKNOWN_FILTER"}) + _expect(lenient_input.filter_behavior == EventDefinitionType.FilterBehavior.NONE, "non-snapshot configuration keeps its explicit lenient fallback boundary") + + +func _transaction_service(state: Dictionary) -> Variant: + return EventServiceType.new().configure_callbacks( + Callable(), + func(action: EventAction, phase: StringName, _context: Dictionary, token: Variant) -> Dictionary: + if phase == &"validate": + return {&"ok": true} + if phase == &"apply": + if bool(action.payload.get(&"fail", false)): + return {&"ok": false, &"error": "requested failure"} + var old_value := int(state[&"value"]) + state[&"value"] = old_value + int(action.payload.get(&"delta", 0)) + return {&"ok": true, &"token": old_value} + if phase == &"rollback": + state[&"value"] = int(token) + return {&"ok": true} + ) + + +func _run_choice(service: Variant, event_id: StringName, instance_id: StringName) -> CommandResult: + service.enqueue_candidate(event_id, [], 0, {&"event_instance_id": instance_id}) + var started = service.start_next_event(0) + return service.submit_choice(instance_id, started.details[&"choice_instance_id"], &"accept", 0) + + +func _choice_definition( + event_id: StringName, + priority: int, + policy: EventDefinition.TransactionPolicy, + actions: Array, + cost: Dictionary = {} +) -> EventDefinition: + var choice := EventChoiceType.new().configure( + &"accept", + StringName("choice.%s.accept" % event_id), + [], + cost, + {}, + actions + ) + return EventDefinitionType.new().configure(event_id, { + &"category": &"RELATION", + &"priority": priority, + &"foreground": true, + &"transaction_policy": policy, + &"choices": [choice], + }) + + +func _delta_action(action_id: StringName, delta: int) -> EventAction: + return EventActionType.new().configure( + action_id, + EventActionType.Type.ADD_LEDGER_ENTRY, + {&"delta": delta} + ) + + +func _failing_action(action_id: StringName) -> EventAction: + return EventActionType.new().configure( + action_id, + EventActionType.Type.ADD_LEDGER_ENTRY, + {&"fail": true} + ) + + +func _instance_ids(instances: Array[EventInstance]) -> Array[StringName]: + var result: Array[StringName] = [] + for instance: EventInstance in instances: + result.append(instance.event_instance_id) + return result + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ok %s" % label) + else: + _failures += 1 + push_error(" not ok %s" % label) diff --git a/tests/s4/test_relationship_event_service.gd.uid b/tests/s4/test_relationship_event_service.gd.uid new file mode 100644 index 0000000..7ed70f3 --- /dev/null +++ b/tests/s4/test_relationship_event_service.gd.uid @@ -0,0 +1 @@ +uid://rqdt3la3n4hp diff --git a/tests/s4/test_runtime_relationship_events.gd b/tests/s4/test_runtime_relationship_events.gd new file mode 100644 index 0000000..e36ef98 --- /dev/null +++ b/tests/s4/test_runtime_relationship_events.gd @@ -0,0 +1,425 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S4: runtime relationship and event integration") + _test_threshold_foreground_acknowledgement_and_idempotency() + _test_paid_choice_escrow_and_commit() + _test_missing_actor_releases_reservation_and_lock() + _test_waiting_choice_snapshot_exact_replay() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed: int) -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _clock.set_paused(true) + _expect(report[&"valid"], "runtime initializes S4 definitions") + _expect(_runtime.event_service.get_definition(&"relationship.threshold") != null, "canonical relationship threshold Event is registered") + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_threshold_foreground_acknowledgement_and_idempotency() -> void: + _reset_runtime(4_001) + _expect(_spawn_guest(&"relation_guest", 150).is_success(), "relationship actor is created through Runtime Command") + var created = _runtime.submit_command( + &"create_relationship", + { + &"relationship_id": &"relationship.runtime.threshold", + &"actor_a_id": &"player", + &"actor_b_id": &"relation_guest", + &"options": { + &"trust": 35, + &"respect": 40, + &"affection": 45, + &"fear": 5, + &"dependence": 10, + &"shield_max": 60, + &"shield_current": 60, + }, + }, + &"player", + &"relation_guest", + 80, + -1, + &"s4.relationship.create.threshold" + ) + _expect(created.is_success(), "Relationship is created through Runtime Command") + var relationship = _runtime.relationship_service.get_relationship(&"relationship.runtime.threshold") + _expect(relationship != null and relationship.actor_a_id == &"player" and relationship.actor_b_id == &"relation_guest", "Runtime preserves normalized relationship actors") + + var changed = _runtime.submit_command( + &"change_relationship", + { + &"relationship_id": relationship.relationship_id, + &"metric_id": &"affection", + &"delta": 10, + }, + &"player", + relationship.relationship_id, + 80, + -1, + &"s4.relationship.affection.gate" + ) + _expect(changed.is_success() and changed.details[&"gated_threshold"] == 50, "Affection change reports the 50 threshold gate") + _expect(relationship.get_metric(&"affection") == 49, "background calculation cannot cross 50 before Event acknowledgement") + _expect(_runtime.relationship_service.get_threshold_candidates().size() == 1, "threshold crossing creates one deterministic relationship candidate") + _expect(_runtime.event_service.get_queued_instances().size() == 1, "candidate is projected into EventService without immediate playback") + + _clock.set_paused(false) + _clock.advance_one_minute() + var foreground = _runtime.event_service.get_foreground_instance() + _expect(foreground != null and foreground.source_event_id == &"relationship.threshold", "relationship candidate starts as the foreground Event") + _expect(_clock.paused, "foreground Choice automatically pauses Simulation") + _expect(_runtime.event_service.get_actor_locks().size() == 2, "foreground threshold Event locks both relationship actors") + var instance_id: StringName = foreground.event_instance_id + var choice_instance_id: StringName = foreground.choice_instance_id + var acknowledged = _runtime.submit_command( + &"submit_event_choice", + { + &"event_instance_id": instance_id, + &"choice_instance_id": choice_instance_id, + &"choice_id": &"acknowledge", + }, + &"player", + instance_id, + 100, + -1, + &"s4.threshold.acknowledge.first" + ) + _expect(acknowledged.is_success(), "foreground acknowledgement resolves through Runtime Command") + _expect(relationship.get_metric(&"affection") == 50, "acknowledged threshold Event applies the gated Affection point") + _expect(_runtime.relationship_service.get_threshold_candidates().is_empty(), "resolved Event consumes its relationship candidate") + _expect(_runtime.event_service.get_actor_locks().is_empty() and _runtime.event_service.get_foreground_instance() == null, "resolved Event releases every Actor Lock") + _expect(not _clock.paused, "Runtime resumes when no foreground Event remains") + + _clock.set_paused(true) + var replayed = _runtime.submit_command( + &"submit_event_choice", + { + &"event_instance_id": instance_id, + &"choice_instance_id": choice_instance_id, + &"choice_id": &"acknowledge", + }, + &"player", + instance_id, + 100, + -1, + &"s4.threshold.acknowledge.transport-retry" + ) + _expect(replayed.is_success() and replayed.is_replay, "same choice_instance_id returns EventService idempotent replay") + _expect(relationship.get_metric(&"affection") == 50, "choice replay does not apply relationship Actions twice") + _expect(_count_domain_events(&"EVENT_CHOICE_RESOLVED", instance_id) == 1, "choice replay emits no duplicate resolution Domain Event") + _expect(_runtime.relationship_service.validate_invariants()[&"ok"], "threshold flow preserves Relationship invariants") + _expect(_runtime.event_service.validate_invariants()[&"ok"], "threshold flow preserves Event and Actor Lock invariants") + + +func _test_paid_choice_escrow_and_commit() -> void: + _reset_runtime(4_002) + var registered = _register_paid_event_definition(&"event.paid.commit", 125) + _expect(registered.is_success(), "paid EventDefinition registers through Runtime Command") + var enqueued = _runtime.submit_command( + &"enqueue_event", + { + &"event_id": &"event.paid.commit", + &"actor_ids": [&"player"], + &"options": {&"event_instance_id": &"event_instance.paid.commit"}, + }, + &"player", + &"event_instance.paid.commit", + 60, + -1, + &"s4.event.paid.enqueue" + ) + _expect(enqueued.is_success(), "paid Event candidate enters Runtime queue") + _start_next_foreground_minute() + var foreground = _runtime.event_service.get_foreground_instance() + _expect(foreground != null and foreground.event_instance_id == &"event_instance.paid.commit", "paid Event reaches foreground Choice") + var cash_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var reserved = _runtime.submit_command( + &"reserve_event_choice_cost", + { + &"event_instance_id": foreground.event_instance_id, + &"choice_id": &"purchase", + }, + &"player", + foreground.event_instance_id, + 100, + -1, + &"s4.event.paid.reserve" + ) + var escrow_account := &"EVENT_ESCROW:event_instance.paid.commit" + _expect(reserved.is_success(), "paid Choice reserves Cost through Runtime boundary") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 125, "Gold leaves PLAYER_CASH exactly once at reservation") + _expect(_runtime.economy_service.get_balance(escrow_account) == 125, "reserved Gold is held in Event-specific escrow") + _expect(_state.get_resource(&"gold") == cash_before - 125, "GameState Gold projection follows escrow transfer") + + var committed = _runtime.submit_command( + &"submit_event_choice", + { + &"event_instance_id": foreground.event_instance_id, + &"choice_instance_id": foreground.choice_instance_id, + &"choice_id": &"purchase", + }, + &"player", + foreground.event_instance_id, + 100, + -1, + &"s4.event.paid.commit" + ) + _expect(committed.is_success(), "paid Choice commits after revalidation") + _expect(_runtime.economy_service.get_balance(escrow_account) == 0, "committed Event leaves no Gold in escrow") + _expect(_runtime.economy_service.get_balance(&"OPERATING_EXPENSE") == 125, "escrow Gold posts to the configured expense account") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 125, "Cost commit never charges PLAYER_CASH a second time") + _expect(_runtime.event_service.has_flag(&"paid_choice_completed"), "paid Choice Action applies only after Cost reservation") + _expect(_runtime.economy_service.validate_ledger()[&"ok"], "escrow reserve and commit remain double-entry balanced") + _expect(_runtime.event_service.validate_invariants()[&"ok"], "paid Choice completion preserves Event invariants") + + +func _test_missing_actor_releases_reservation_and_lock() -> void: + _reset_runtime(4_003) + var registered_staff = _runtime.submit_command( + &"register_staff", + { + &"staff_id": &"temporary_actor", + &"role_id": &"service", + &"options": {}, + }, + &"player", + &"temporary_actor", + 80, + -1, + &"s4.actor.temporary.register" + ) + _expect(registered_staff.is_success(), "temporary foreground actor registers through Runtime") + _expect(_register_paid_event_definition(&"event.actor.missing", 80).is_success(), "missing-actor fixture EventDefinition registers") + var enqueued = _runtime.submit_command( + &"enqueue_event", + { + &"event_id": &"event.actor.missing", + &"actor_ids": [&"temporary_actor"], + &"options": {&"event_instance_id": &"event_instance.actor.missing"}, + }, + &"player", + &"event_instance.actor.missing", + 60, + -1, + &"s4.actor.missing.enqueue" + ) + _expect(enqueued.is_success(), "missing-actor fixture enters Event queue") + _start_next_foreground_minute() + var foreground = _runtime.event_service.get_foreground_instance() + _expect(foreground != null and _runtime.event_service.get_actor_locks().has(&"temporary_actor"), "foreground Event holds temporary actor lock") + var cash_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var reserved = _runtime.submit_command( + &"reserve_event_choice_cost", + { + &"event_instance_id": foreground.event_instance_id, + &"choice_id": &"purchase", + }, + &"player", + foreground.event_instance_id, + 100, + -1, + &"s4.actor.missing.reserve" + ) + _expect(reserved.is_success() and _runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 80, "missing-actor fixture owns reserved Gold") + _expect(_runtime.staff_service.remove_staff(&"temporary_actor", &"TEST_REMOVED", true, _clock.absolute_minute).is_success(), "actor disappears through StaffService lifecycle") + _clock.set_paused(false) + _clock.advance_one_minute() + var instance = _runtime.event_service.get_instance(&"event_instance.actor.missing") + _expect(instance != null and instance.state == EventInstance.State.CANCELLED, "Runtime audit cancels Event whose actor disappeared") + _expect(_runtime.event_service.get_actor_locks().is_empty() and _runtime.event_service.get_foreground_instance() == null, "actor disappearance releases Lock and foreground ownership") + _expect(_runtime.economy_service.get_balance(&"EVENT_ESCROW:event_instance.actor.missing") == 0, "actor disappearance empties Event escrow") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before and _state.get_resource(&"gold") == cash_before, "actor disappearance refunds reserved Gold exactly once") + _expect(_runtime.economy_service.get_balance(&"OPERATING_EXPENSE") == 0, "cancelled missing-actor Event never commits Cost") + _expect(_runtime.economy_service.validate_ledger()[&"ok"], "missing-actor refund preserves ledger invariants") + _expect(_runtime.event_service.validate_invariants()[&"ok"], "missing-actor recovery preserves Event invariants") + + +func _test_waiting_choice_snapshot_exact_replay() -> void: + _reset_runtime(4_004) + _expect(_spawn_guest(&"snapshot_relation_guest", 175).is_success(), "snapshot relationship actor is created") + var relationship_id := &"relationship.runtime.snapshot" + var created = _runtime.submit_command( + &"create_relationship", + { + &"relationship_id": relationship_id, + &"actor_a_id": &"player", + &"actor_b_id": &"snapshot_relation_guest", + &"options": {&"affection": 45, &"shield_max": 50, &"shield_current": 50}, + }, + &"player", + relationship_id, + 80, + -1, + &"s4.snapshot.relationship.create" + ) + _expect(created.is_success(), "snapshot Relationship registers") + var gated = _runtime.submit_command( + &"change_relationship", + {&"relationship_id": relationship_id, &"metric_id": &"affection", &"delta": 10}, + &"player", + relationship_id, + 80, + -1, + &"s4.snapshot.relationship.gate" + ) + _expect(gated.is_success(), "snapshot scenario creates threshold Event candidate") + _start_next_foreground_minute() + var foreground = _runtime.event_service.get_foreground_instance() + _expect(foreground != null and _clock.paused, "snapshot is captured while foreground Choice is waiting") + var instance_id: StringName = foreground.event_instance_id + var choice_instance_id: StringName = foreground.choice_instance_id + # Save capture occurs after tick completion, including the incremented Kernel + # snapshot_version; this is the exact boundary replayed below. + var waiting_snapshot: Dictionary = _runtime.create_snapshot() + var waiting_digest: String = SimulationDigest.compute(waiting_snapshot) + _expect(waiting_digest.length() == 64, "waiting-Choice snapshot produces a stable digest") + + var first_resolution = _runtime.submit_command( + &"submit_event_choice", + { + &"event_instance_id": instance_id, + &"choice_instance_id": choice_instance_id, + &"choice_id": &"acknowledge", + }, + &"player", + instance_id, + 100, + -1, + &"s4.snapshot.choice.resolve" + ) + _expect(first_resolution.is_success(), "waiting Choice resolves before rewind") + var final_digest: String = _runtime.latest_digest + _expect(_runtime.relationship_service.get_relationship(relationship_id).get_metric(&"affection") == 50, "first timeline reaches acknowledged threshold") + + var restored_ok: bool = _runtime.restore_snapshot(waiting_snapshot) + _expect(restored_ok, "Runtime restores a snapshot containing Choice token and Actor Locks") + _expect(_runtime.latest_digest == waiting_digest, "waiting-Choice restore reproduces exact digest") + var restored_foreground = _runtime.event_service.get_foreground_instance() + _expect(restored_foreground != null and restored_foreground.event_instance_id == instance_id, "restored Runtime recovers the same foreground Event") + _expect(restored_foreground.choice_instance_id == choice_instance_id and _runtime.event_service.get_actor_locks().size() == 2, "Choice token and Actor Locks restore exactly") + _expect(_clock.paused and _runtime.relationship_service.get_relationship(relationship_id).get_metric(&"affection") == 49, "restore returns to pre-acknowledgement paused relationship state") + + var replay_resolution = _runtime.submit_command( + &"submit_event_choice", + { + &"event_instance_id": instance_id, + &"choice_instance_id": choice_instance_id, + &"choice_id": &"acknowledge", + }, + &"player", + instance_id, + 100, + -1, + &"s4.snapshot.choice.resolve" + ) + _expect(replay_resolution.is_success(), "restored Choice accepts the same deterministic Command") + _expect(_runtime.latest_digest == final_digest, "Choice replay after restore reproduces exact final digest") + _expect(_runtime.relationship_service.get_relationship(relationship_id).get_metric(&"affection") == 50, "replayed timeline reaches threshold once") + _expect(_runtime.relationship_service.validate_invariants()[&"ok"], "restored relationship state satisfies invariants") + _expect(_runtime.event_service.validate_invariants()[&"ok"], "restored Event queue and Lock state satisfy invariants") + _expect(_runtime.economy_service.validate_ledger()[&"ok"], "snapshot replay preserves economic invariants") + + +func _spawn_guest(guest_id: StringName, gold: int) -> CommandResult: + return _runtime.submit_command( + &"spawn_guest", + { + &"guest_id": guest_id, + &"guest_type_id": &"traveler", + &"gold": gold, + }, + &"player", + guest_id, + 50, + -1, + StringName("s4.guest.spawn.%s" % guest_id) + ) + + +func _register_paid_event_definition(event_id: StringName, gold_cost: int) -> CommandResult: + return _runtime.submit_command( + &"register_event_definition", + {&"definition": { + &"event_id": event_id, + &"category": &"RELATIONSHIP", + &"priority": 70, + &"foreground": true, + &"transaction_policy": &"ROLLBACK_ALL", + &"missing_actor_policy": &"CANCEL", + &"content_rating": &"GENERAL", + &"choices": [{ + &"choice_id": &"purchase", + &"text_key": StringName("event.%s.purchase" % event_id), + &"cost": {&"gold": gold_cost}, + &"actions": [{ + &"action_id": &"paid_choice_flag", + &"type": &"SET_FLAG", + &"payload": {&"flag_id": &"paid_choice_completed"}, + }], + }], + }}, + &"player", + event_id, + 60, + -1, + StringName("s4.event.definition.%s" % event_id) + ) + + +func _start_next_foreground_minute() -> void: + _clock.set_paused(false) + _clock.advance_one_minute() + + +func _count_domain_events(event_type: StringName, entity_id: StringName) -> int: + var count := 0 + for event in _runtime.domain_events.recent_events(): + if event.event_type == event_type and ( + entity_id in event.entity_ids + or StringName(event.payload.get(&"event_instance_id", &"")) == entity_id + ): + count += 1 + return count + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s4/test_runtime_relationship_events.gd.uid b/tests/s4/test_runtime_relationship_events.gd.uid new file mode 100644 index 0000000..8775dda --- /dev/null +++ b/tests/s4/test_runtime_relationship_events.gd.uid @@ -0,0 +1 @@ +uid://dfrs30r1g0jqe diff --git a/tests/s5/test_authority_service.gd b/tests/s5/test_authority_service.gd new file mode 100644 index 0000000..74fcd5a --- /dev/null +++ b/tests/s5/test_authority_service.gd @@ -0,0 +1,362 @@ +extends SceneTree + +const HeatStateType = preload("res://scripts/authority/heat_state.gd") +const IncidentType = preload("res://scripts/authority/incident.gd") +const EvidenceType = preload("res://scripts/authority/evidence.gd") +const InspectionType = preload("res://scripts/authority/inspection.gd") +const AuthorityServiceType = preload("res://scripts/authority/authority_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S5: Authority, Incident, Evidence and Inspection") + _test_heat_incident_dedup_and_decay_floor() + _test_evidence_visibility_and_incident_scoring() + _test_inspection_selection_and_patrol_order() + _test_path_unreachable_records_departure_reason() + _test_resolution_idempotency() + _test_resolution_failure_is_atomic() + _test_snapshot_round_trip_and_atomicity() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_heat_incident_dedup_and_decay_floor() -> void: + var service := AuthorityServiceType.new() + var first = service.create_incident( + &"incident_shared", + &"illegal_service", + 10, + &"noise_detector", + {&"severity": 80, &"heat_delta": 30, &"facility_id": &"hidden_room"} + ) + _expect(first.is_success(), "first Incident report is accepted") + _expect(service.get_authority_state().heat == 30, "Incident applies its Heat delta once") + var duplicate = service.report_incident( + IncidentType.new().configure( + &"incident_shared", + &"conflicting_duplicate", + 11, + {&"severity": 100, &"heat_delta": 100} + ), + &"witness_detector" + ) + _expect(duplicate.is_success() and duplicate.is_replay, "duplicate incident_id is an idempotent replay") + _expect(duplicate.details[&"heat_applied"] == 0 and service.get_authority_state().heat == 30, "second detector cannot add Heat again") + var canonical := service.get_incident(&"incident_shared") + _expect(canonical.detector_ids == [&"noise_detector", &"witness_detector"], "all unique detector IDs remain auditable") + _expect(canonical.incident_type == &"illegal_service" and canonical.severity == 80, "first Incident payload remains canonical") + + service.advance_time(1_440, 15, 20) + _expect(service.get_authority_state().heat == 20, "daily decay stops at the active-case floor") + service.advance_time(2_880, 15, 20) + _expect(service.get_authority_state().heat == 20, "active-case floor remains stable on later days") + service.dismiss_incident(&"incident_shared") + service.advance_time(4_320, 15, 20) + _expect(service.get_authority_state().heat == 5, "Heat decays below the floor after the case closes") + var low_state := HeatStateType.new().configure({&"heat": 10, &"active_case_ids": [&"case_a"]}) + low_state.apply_daily_decay(1, 5, 20) + _expect(low_state.heat == 10, "daily decay never raises pre-existing low Heat up to its floor") + _expect(service.validate_invariants()[&"ok"], "Incident dedup and active-case state satisfy invariants") + + +func _test_evidence_visibility_and_incident_scoring() -> void: + var los_calls := {&"count": 0, &"visible": false} + var service := AuthorityServiceType.new().configure_callbacks( + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: + los_calls[&"count"] = int(los_calls[&"count"]) + 1 + return bool(los_calls[&"visible"]) + ) + service.create_incident(&"incident_evidence", &"hidden_facility", 0, &"detector_a", {&"heat_delta": 5}) + service.create_evidence(&"evidence_wall", &"visual_trace", 25, { + &"incident_id": &"incident_evidence", + &"facility_id": &"illegal_room", + &"location_cell": Vector3i(5, 5, -1), + &"created_at": 0, + &"expires_at": 1_000, + }) + var blocked = service.attempt_discover_evidence( + &"evidence_wall", &"inspector_a", Vector3i(4, 5, -1), 10 + ) + _expect(not blocked.is_success() and blocked.details[&"reason"] == &"LINE_OF_SIGHT_BLOCKED", "SYS-AUT-001: a wall-blocked callback prevents discovery") + _expect(service.get_authority_state().evidence_points == 0, "blocked visual evidence contributes no points") + var other_layer = service.attempt_discover_evidence( + &"evidence_wall", &"inspector_a", Vector3i(5, 5, 0), 11 + ) + _expect(not other_layer.is_success() and other_layer.details[&"reason"] == &"DIFFERENT_LAYER", "different layers never fall through to line-of-sight") + _expect(los_calls[&"count"] == 1, "cross-layer evidence does not call the visibility boundary") + los_calls[&"visible"] = true + var visible = service.attempt_discover_evidence( + &"evidence_wall", &"inspector_a", Vector3i(4, 5, -1), 12, + {&"illegal_confirmed": true} + ) + _expect(visible.is_success() and visible.details[&"discovery_method"] == &"LINE_OF_SIGHT", "same-layer evidence is found only after the injected boundary confirms sight") + _expect(service.get_authority_state().evidence_points == 25, "discovered evidence contributes its severity") + _expect(service.get_authority_state().known_illegal_facility_ids == [&"illegal_room"], "confirmed visual evidence records the known illegal facility") + + service.create_evidence(&"evidence_same_incident", &"second_photo", 25, { + &"incident_id": &"incident_evidence", + &"location_cell": Vector3i(8, 8, -1), + &"created_at": 0, + }) + service.attempt_discover_evidence( + &"evidence_same_incident", &"inspector_a", Vector3i(8, 8, -1), 13 + ) + _expect(service.get_authority_state().evidence_points == 25, "SYS-AUT-002: the same Incident is not added to evidence points twice") + service.create_evidence(&"evidence_stronger", &"ledger", 40, { + &"incident_id": &"incident_evidence", + &"location_cell": Vector3i(99, 99, 5), + &"created_at": 0, + }) + service.attempt_discover_evidence( + &"evidence_stronger", &"inspector_a", Vector3i.ZERO, 14, + {&"trusted_source": true} + ) + _expect(service.get_authority_state().evidence_points == 40, "corroboration upgrades an Incident to its strongest evidence rather than stacking") + var replay = service.attempt_discover_evidence( + &"evidence_stronger", &"inspector_a", Vector3i.ZERO, 15, + {&"trusted_source": true} + ) + _expect(replay.is_success() and replay.is_replay and service.get_authority_state().evidence_points == 40, "repeated evidence discovery is idempotent") + _expect(service.validate_invariants()[&"ok"], "visibility discoveries and reverse references satisfy invariants") + + +func _test_inspection_selection_and_patrol_order() -> void: + var service := AuthorityServiceType.new().configure_callbacks( + Callable(), + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> Dictionary: + return {&"reachable": true, &"travel_minutes": 2} + ) + service.apply_heat_delta(30, &"fixture", 0) + service.set_suspicion(20) + var selected = service.evaluate_inspection(360, { + &"base_chance": 5, + &"heat_weight": 1, + &"suspicion_weight": 1, + &"overdue_case_bonus": 0, + &"reputation_protection": 0, + &"roll": 54, + &"inspection_id": &"inspection_order", + &"inspector_id": &"inspector_order", + &"entrance_cell": Vector3i.ZERO, + }) + _expect(selected.is_success() and selected.details[&"chance"] == 55 and selected.details[&"selected"], "06:00 inspection chance uses deterministic integer terms") + var replay = service.evaluate_inspection(360, {&"roll": 99}) + _expect(replay.is_success() and replay.is_replay and replay.details[&"inspection_id"] == &"inspection_order", "same-day inspection selection replays the stored result") + _expect(service.get_authority_state().next_inspection_window == 1_800, "selection updates the next inspection Window") + service.start_inspection(&"inspection_order", 360) + service.mark_inspector_arrived(&"inspection_order", 361) + var candidates := [ + {&"waypoint_id": &"z_tie", &"cell": Vector3i(3, 0, 0), &"suspect_score": 10, &"evidence_potential": 10, &"travel_minutes": 5, &"inspection_minutes": 5}, + {&"waypoint_id": &"top", &"cell": Vector3i(1, 0, 0), &"suspect_score": 10, &"evidence_potential": 20, &"travel_minutes": 5, &"inspection_minutes": 5}, + {&"waypoint_id": &"a_tie", &"cell": Vector3i(2, 0, 0), &"suspect_score": 10, &"evidence_potential": 10, &"travel_minutes": 5, &"inspection_minutes": 5}, + ] + var plan = service.plan_patrol(&"inspection_order", candidates, 362, 30) + _expect(plan.is_success(), "patrol plan is accepted after CHECK_IN") + _expect(plan.details[&"waypoint_ids"] == [&"top", &"a_tie", &"z_tie"], "patrol sorts Utility descending then waypoint ID lexically") + _expect(service.get_inspection(&"inspection_order").discovered_evidence_ids.is_empty(), "planning patrol does not perform an instantaneous map scan") + var moved = service.advance_patrol(&"inspection_order", 363) + _expect(moved.is_success() and moved.details[&"waypoint_id"] == &"top", "patrol advances exactly one waypoint per call") + + var second := AuthorityServiceType.new() + second.schedule_inspection(360, {&"inspection_id": &"stable", &"inspector_id": &"stable_i"}) + second.start_inspection(&"stable", 360) + second.mark_inspector_arrived(&"stable", 361) + var reversed := candidates.duplicate(true) + reversed.reverse() + var second_plan = second.plan_patrol(&"stable", reversed, 362, 30) + _expect(second_plan.details[&"waypoint_ids"] == plan.details[&"waypoint_ids"], "patrol route is independent of candidate input order") + + +func _test_path_unreachable_records_departure_reason() -> void: + var service := AuthorityServiceType.new().configure_callbacks( + Callable(), + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> Dictionary: + return {&"reachable": false, &"reason": &"SEALED_BY_WALL"} + ) + service.schedule_inspection(10, { + &"inspection_id": &"inspection_blocked", + &"inspector_id": &"inspector_blocked", + &"entrance_cell": Vector3i.ZERO, + }) + service.start_inspection(&"inspection_blocked", 10) + service.mark_inspector_arrived(&"inspection_blocked", 11) + service.plan_patrol(&"inspection_blocked", [{ + &"waypoint_id": &"sealed_room", + &"cell": Vector3i(9, 9, -1), + &"suspect_score": 90, + &"evidence_potential": 90, + &"travel_minutes": 10, + }], 12) + var blocked = service.advance_patrol(&"inspection_blocked", 13) + var inspection := service.get_inspection(&"inspection_blocked") + _expect(blocked.is_success() and not blocked.details[&"reachable"], "unreachable patrol target is a handled inspection outcome") + _expect(inspection.state == InspectionType.State.DEPARTING, "SYS-AUT-004: unreachable inspector enters DEPARTING instead of teleporting") + _expect(inspection.exit_reason == &"PATH_UNREACHABLE" and inspection.result_reason == &"SEALED_BY_WALL", "path failure records both stable exit and detailed result reasons") + _expect(inspection.inaccessible_waypoint_ids == [&"sealed_room"] and inspection.visited_waypoint_ids.is_empty(), "unreachable waypoint is recorded without being marked visited") + var departed = service.depart_inspection(&"inspection_blocked", 14) + _expect(departed.is_success() and inspection.state == InspectionType.State.CLOSED, "inspector safely closes after departure") + _expect(departed.details[&"exit_reason"] == &"PATH_UNREACHABLE", "closed inspection preserves the path-unreachable exit reason") + _expect(service.validate_invariants()[&"ok"], "path-unreachable inspection remains snapshot-safe") + + +func _test_resolution_idempotency() -> void: + var applications := {&"validate": 0, &"apply": 0, &"fine_total": 0, &"sanctions": []} + var service := AuthorityServiceType.new().configure_callbacks( + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: return true, + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: return true, + func(payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + applications[phase] = int(applications.get(phase, 0)) + 1 + if phase == &"apply": + applications[&"fine_total"] = int(applications[&"fine_total"]) + int(payload[&"fine_amount"]) + (applications[&"sanctions"] as Array).append(payload[&"sanction"]) + return {&"ok": true, &"token": &"validated"} + ) + service.create_incident(&"incident_fine", &"illegal_facility", 0, &"detector", {&"severity": 80, &"heat_delta": 10}) + service.create_evidence(&"evidence_fine", &"physical", 60, { + &"incident_id": &"incident_fine", + &"facility_id": &"facility_illegal", + &"location_cell": Vector3i(2, 0, 0), + &"created_at": 0, + }) + _prepare_reviewing_inspection(service, &"inspection_fine", &"inspector_fine", &"evidence_fine") + var first = service.resolve_inspection(&"inspection_fine", &"resolution_once", 5, {&"fine_per_point": 10}) + _expect(first.is_success() and first.details[&"result"] == &"TEMPORARY_CLOSURE", "evidence thresholds deterministically select the sanction tier") + _expect(first.details[&"fine_amount"] == 600 and first.details[&"sanction"] == &"TEMPORARY_CLOSURE", "resolution projection contains fine and sanction for external domains") + _expect(applications[&"validate"] == 1 and applications[&"apply"] == 1, "resolution boundary validates then applies exactly once") + _expect(service.get_authority_state().fines_due == 600 and applications[&"fine_total"] == 600, "successful resolution records one fine liability and one external application") + _expect(service.get_incident(&"incident_fine").state == IncidentType.State.RESOLVED, "resolution closes linked active Incident cases") + var duplicate = service.resolve_inspection(&"inspection_fine", &"resolution_once", 6, {&"fine_per_point": 999}) + _expect(duplicate.is_success() and duplicate.is_replay, "SYS-AUT-003: duplicate resolution_id returns its stored result") + _expect(applications[&"apply"] == 1 and service.get_authority_state().fines_due == 600, "duplicate resolution cannot apply fine or sanction twice") + _expect(duplicate.details[&"fine_amount"] == 600, "replay ignores conflicting later resolution terms") + _expect(service.validate_invariants()[&"ok"], "resolved inspection, resolution and Incident references satisfy invariants") + + +func _test_resolution_failure_is_atomic() -> void: + var external := {&"fine": 0, &"rollback": 0} + var service := AuthorityServiceType.new().configure_callbacks( + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: return true, + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: return true, + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"validate": + return {&"ok": true, &"token": int(external[&"fine"])} + if phase == &"apply": + external[&"fine"] = int(external[&"fine"]) + int(payload[&"fine_amount"]) + return {&"ok": false, &"token": token, &"reason": &"LEDGER_REJECTED"} + if phase == &"rollback": + external[&"fine"] = int(token) + external[&"rollback"] = int(external[&"rollback"]) + 1 + return {&"ok": true} + return {&"ok": false} + ) + service.create_incident(&"incident_fail", &"illegal_facility", 0, &"detector", {&"heat_delta": 5}) + service.create_evidence(&"evidence_fail", &"physical", 30, { + &"incident_id": &"incident_fail", + &"location_cell": Vector3i(2, 0, 0), + &"created_at": 0, + }) + _prepare_reviewing_inspection(service, &"inspection_fail", &"inspector_fail", &"evidence_fail") + var before: Dictionary = service.create_snapshot() + var failed = service.resolve_inspection(&"inspection_fail", &"resolution_fail", 5) + _expect(not failed.is_success() and failed.details[&"rollback_ok"], "failed external resolution invokes its rollback boundary") + _expect(external[&"fine"] == 0 and external[&"rollback"] == 1, "failed fine application leaves no external value behind") + _expect(service.create_snapshot() == before, "failed resolution leaves the full Authority snapshot unchanged") + _expect(service.get_resolution(&"resolution_fail").is_empty(), "failed resolution_id is not marked processed") + + +func _test_snapshot_round_trip_and_atomicity() -> void: + var application_count := {&"apply": 0} + var service := AuthorityServiceType.new().configure_callbacks( + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: return true, + func(_from: Vector3i, _to: Vector3i, _context: Dictionary) -> bool: return true, + func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + if phase == &"apply": + application_count[&"apply"] = int(application_count[&"apply"]) + 1 + return {&"ok": true} + ) + service.create_incident(&"incident_save", &"contraband", 2, &"detector_b", {&"heat_delta": 12}) + service.report_incident( + IncidentType.new().configure(&"incident_save", &"contraband", 2, {&"heat_delta": 12}), + &"detector_a" + ) + service.create_evidence(&"evidence_save", &"inventory_record", 30, { + &"incident_id": &"incident_save", + &"location_cell": Vector3i(1, 1, 0), + &"created_at": 2, + }) + _prepare_reviewing_inspection(service, &"inspection_save", &"inspector_save", &"evidence_save") + service.resolve_inspection(&"inspection_save", &"resolution_save", 5) + service.depart_inspection(&"inspection_save", 6) + service.advance_time(1_440) + var snapshot: Dictionary = service.create_snapshot() + var restored := AuthorityServiceType.new().configure_callbacks( + Callable(), Callable(), + func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + if phase == &"apply": + application_count[&"apply"] = int(application_count[&"apply"]) + 1 + return {&"ok": true} + ) + _expect(restored.restore_snapshot(snapshot), "valid Authority snapshot restores") + _expect(restored.create_snapshot() == snapshot, "Authority snapshot round trips exactly") + _expect(restored.validate_invariants()[&"ok"], "restored Authority aggregate satisfies invariants") + var replay = restored.resolve_inspection(&"inspection_save", &"resolution_save", 1_441) + _expect(replay.is_success() and replay.is_replay, "resolution idempotency survives save and load") + _expect(application_count[&"apply"] == 1, "loaded resolution replay never calls external application again") + var before: Dictionary = restored.create_snapshot() + var corrupt := snapshot.duplicate(true) + corrupt[&"authority_state"][&"evidence_points"] = 999 + _expect(not restored.restore_snapshot(corrupt), "cross-domain cache mismatch rejects a corrupt Authority snapshot") + _expect(restored.create_snapshot() == before, "failed Authority restore leaves every live field unchanged") + var wrong_version := snapshot.duplicate(true) + wrong_version[&"version"] = 999 + _expect(not restored.restore_snapshot(wrong_version), "unsupported Authority snapshot version is rejected") + _expect(restored.create_snapshot() == before, "version rejection is also atomic") + var out_of_range := snapshot.duplicate(true) + out_of_range[&"authority_state"][&"heat"] = 101 + _expect(not restored.restore_snapshot(out_of_range), "out-of-range snapshot values are rejected instead of silently clamped") + _expect(restored.create_snapshot() == before, "strict value rejection preserves the live aggregate") + + +func _prepare_reviewing_inspection( + service: AuthorityService, + inspection_id: StringName, + inspector_id: StringName, + evidence_id: StringName +) -> void: + service.schedule_inspection(0, { + &"inspection_id": inspection_id, + &"inspector_id": inspector_id, + &"entrance_cell": Vector3i.ZERO, + }) + service.start_inspection(inspection_id, 0) + service.mark_inspector_arrived(inspection_id, 1) + service.plan_patrol(inspection_id, [{ + &"waypoint_id": StringName("waypoint_%s" % inspection_id), + &"cell": Vector3i(1, 1, 0) if evidence_id == &"evidence_save" else Vector3i(2, 0, 0), + &"suspect_score": 100, + &"evidence_potential": 100, + &"travel_minutes": 1, + &"inspection_minutes": 1, + }], 2) + service.advance_patrol(inspection_id, 3) + service.inspect_current_waypoint(inspection_id, [evidence_id], 4) + service.finish_patrol(inspection_id, 5) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s5/test_authority_service.gd.uid b/tests/s5/test_authority_service.gd.uid new file mode 100644 index 0000000..6079fee --- /dev/null +++ b/tests/s5/test_authority_service.gd.uid @@ -0,0 +1 @@ +uid://bdrksuqlwvmu4 diff --git a/tests/s5/test_defense_service.gd b/tests/s5/test_defense_service.gd new file mode 100644 index 0000000..96cf164 --- /dev/null +++ b/tests/s5/test_defense_service.gd @@ -0,0 +1,970 @@ +extends SceneTree + +const DefenseServiceType = preload("res://scripts/defense/defense_service.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S5: deterministic wave-by-wave Raid defense") + _test_definition_and_registration_validation() + _test_scores_penalties_and_stable_order() + _test_layered_assets_and_breach() + _test_explicit_and_offscreen_attackers_share_one_formula() + _test_active_skills_and_successful_boundaries() + _test_skill_boundary_validation_rollback() + _test_skill_boundary_apply_and_outcome_rollback() + _test_rng_transaction_retry_stability() + _test_retreat_policies_and_manual_retreat_replay() + _test_cancel_transaction_and_reconciliation() + _test_resolution_operation_namespace_and_legacy_v1() + _test_multiple_waves_require_explicit_transition() + _test_raid_duration_and_wave_round_limits() + _test_round_resolution_replay() + _test_round_clock_contract() + _test_wave_entry_and_metadata_validation() + _test_armor_pool_accounting() + _test_snapshot_round_trip_and_corrupt_atomicity() + _test_cross_time_snapshot_rejection() + _test_input_order_determinism() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_definition_and_registration_validation() -> void: + var service := DefenseServiceType.new() + _expect(not service.create_raid(&"", 0, _definition()).is_success(), "Raid ID is required") + _expect(not service.create_raid(&"bad_minute", -1, _definition()).is_success(), "creation minute cannot be negative") + _expect(not service.create_raid(&"no_entries", 0, _definition({&"entry_points": []})).is_success(), "at least one entry point is required") + _expect(not service.create_raid(&"no_objective", 0, _definition({&"objective_ids": []})).is_success(), "at least one objective is required") + _expect(not service.create_raid(&"no_waves", 0, _definition({&"waves": []})).is_success(), "at least one wave is required") + _expect(not service.create_raid(&"bad_threat", 0, _definition({&"waves": [{&"wave_id": &"w", &"threat_strength": 0}]})).is_success(), "wave strength must be positive") + _expect(not service.create_raid(&"duplicate_wave", 0, _definition({&"waves": [ + {&"wave_id": &"same", &"threat_strength": 10}, + {&"wave_id": &"same", &"threat_strength": 20}, + ]})).is_success(), "wave IDs are unique") + _expect(not service.create_raid(&"unknown_entry", 0, _definition({&"waves": [{ + &"wave_id": &"w", &"threat_strength": 10, &"entry_points": [&"not_declared"], + }]})).is_success(), "wave entry points must belong to the Raid") + _expect(not service.create_raid(&"unknown_objective", 0, _definition({&"waves": [{ + &"wave_id": &"w", &"threat_strength": 10, &"objective_ids": [&"not_declared"], + }]})).is_success(), "wave objectives must belong to the Raid") + _expect(not service.create_raid(&"bad_policy", 0, _definition({&"retreat_policy": &"PANIC"})).is_success(), "retreat policy is enumerated") + _expect(not service.create_raid(&"bad_duration", 0, _definition({&"round_minutes": 0})).is_success(), "round duration must be positive") + _expect(not service.create_raid(&"bad_threshold", 0, _definition({&"defender_retreat_threshold": 101})).is_success(), "defender retreat threshold is bounded") + + var created = service.create_raid(&"valid", 10, _definition({ + &"entry_points": [&"west", &"east", &"west"], + &"objective_ids": [&"vault", &"core", &"vault"], + &"waves": [{ + &"wave_id": &"wave_a", + &"threat_strength": 40, + &"entry_points": [&"west", &"east"], + &"objective_ids": [&"vault", &"core"], + }], + })) + _expect(created.is_success() and created.details[&"wave_count"] == 1, "valid Raid definition is accepted") + var canonical := service.get_raid(&"valid") + _expect(canonical[&"entry_points"] == [&"east", &"west"], "entry point IDs are canonicalized") + _expect(canonical[&"objective_ids"] == [&"core", &"vault"], "objective IDs are canonicalized") + _expect(not service.create_raid(&"valid", 10, _definition()).is_success(), "duplicate Raid ID is rejected") + _expect(not service.register_combatant(&"valid", &"bad_side", &"NEUTRAL", 10, 10).is_success(), "combatant side is validated") + _expect(not service.register_combatant(&"valid", &"bad_health", &"DEFENDER", 10, 0).is_success(), "combatant health is validated") + _expect(not service.register_combatant(&"valid", &"bad_wave", &"ATTACKER", 10, 10, {&"wave_id": &"missing"}).is_success(), "attacker wave reference is validated") + _expect(not service.register_combatant(&"valid", &"bad_defender_wave", &"DEFENDER", 10, 10, {&"wave_id": &"wave_a"}).is_success(), "defenders cannot be wave-bound") + _expect(service.register_combatant(&"valid", &"guard", &"DEFENDER", 20, 100, {&"health": 60, &"entry_point_id": &"east"}).is_success(), "valid wounded defender is registered") + _expect(service.get_raid(&"valid")[&"combatants"][&"guard"][&"initial_health"] == 100, "retreat baseline uses maximum health") + _expect(not service.register_asset(&"valid", &"core", &"DOOR", 100, 10).is_success(), "declared objective cannot be registered as a door") + _expect(not service.register_asset(&"valid", &"ordinary", &"OBJECTIVE", 100, 10).is_success(), "undeclared asset cannot be registered as an objective") + _expect(not service.register_asset(&"valid", &"door_bad_entry", &"DOOR", 100, 10, {&"entry_points": [&"north"]}).is_success(), "asset entry coverage is validated") + _expect(service.register_asset(&"valid", &"core", &"OBJECTIVE", 100, 5, {&"integrity": 40}).is_success(), "first objective asset is registered") + _expect(service.get_raid(&"valid")[&"assets"][&"core"][&"initial_integrity"] == 100, "objective retreat baseline uses maximum integrity") + _expect(not service.start_raid(&"valid", 10).is_success(), "Raid cannot start until every objective is registered") + _expect(service.register_asset(&"valid", &"vault", &"OBJECTIVE", 80, 5).is_success(), "second objective asset is registered") + _expect(service.start_raid(&"valid", 10).is_success(), "complete Raid can start") + _expect(service.get_raid(&"valid")[&"waves"][0][&"state"] == &"ACTIVE", "starting marks the first wave ACTIVE") + _expect(not service.register_asset(&"valid", &"late", &"DOOR", 10, 1).is_success(), "assets cannot be added after start") + _expect(not service.register_combatant(&"valid", &"late", &"DEFENDER", 1, 1).is_success(), "combatants cannot be added after start") + _expect(service.validate_invariants()[&"ok"], "validated registration state satisfies all invariants") + + +func _test_scores_penalties_and_stable_order() -> void: + var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(service.create_raid(&"scores", 0, _definition({ + &"entry_points": [&"west", &"east"], + &"terrain_bonus": 3, + &"command_bonus": 4, + &"surprise_penalty": 7, + &"multiple_entry_penalty": 5, + &"waves": [{ + &"wave_id": &"wave_1", &"threat_strength": 40, + &"entry_points": [&"west", &"east"], &"objective_ids": [&"core"], + &"retreat_threshold": 0, &"max_rounds": 10, + }], + })), "score fixture Raid is created") + _must(service.register_combatant(&"scores", &"z_guard", &"DEFENDER", 20, 100, {&"fatigue": 25}), "fatigued defender is registered") + _must(service.register_combatant(&"scores", &"a_guard", &"DEFENDER", 10, 100), "fresh defender is registered") + _must(service.register_combatant(&"scores", &"raider", &"ATTACKER", 20, 100, {&"wave_id": &"wave_1"}), "explicit attacker is registered") + _must(service.register_asset(&"scores", &"core", &"OBJECTIVE", 100, 5), "score objective is registered") + _must(service.register_asset(&"scores", &"tower", &"DEFENSE_DEVICE", 100, 12, {&"terrain_bonus": 2}), "score device is registered") + _must(service.register_asset(&"scores", &"gate", &"DOOR", 100, 8), "score door is registered") + _must(service.start_raid(&"scores", 0), "score Raid starts") + var first = service.resolve_next_round(&"scores", &"score_round_1", 5) + _expect(first.is_success(), "first score round resolves") + var components: Dictionary = first.details[&"score_components"] + _expect(components[&"defender_combat"] == 30, "defender score sums effective combat") + _expect(components[&"asset_defense"] == 25, "door, device and objective all contribute defense") + _expect(components[&"terrain_bonus"] == 5 and components[&"command_bonus"] == 4, "terrain and command bonuses are explicit score components") + _expect(components[&"fatigue_penalty"] == 5, "fatigue scales the affected defender's effective combat") + _expect(components[&"multiple_entry_penalty"] == 5, "second entry point applies one deterministic penalty") + _expect(components[&"surprise_penalty"] == 7, "surprise applies on the first wave round") + _expect(components[&"attacker_combat"] == 20 and components[&"aggregate_attacker_combat"] == 40, "explicit and aggregate attackers are visible score components") + _expect(first.details[&"defense_score"] == 47 and first.details[&"attack_score"] == 60, "integer score formula has the expected result") + var raid_after_first := service.get_raid(&"scores") + _expect(raid_after_first[&"combatants"][&"a_guard"][&"fatigue"] == 5, "fresh defender gains five fatigue per round") + _expect(raid_after_first[&"combatants"][&"z_guard"][&"fatigue"] == 30, "existing fatigue increases by the same fixed amount") + var second = service.resolve_next_round(&"scores", &"score_round_2", 10) + _expect(second.is_success() and second.details[&"score_components"][&"surprise_penalty"] == 0, "surprise never repeats after round one") + _expect(second.details[&"defense_roll"] == 50 and second.details[&"attack_roll"] == 50, "injected rolls are deterministic") + _expect(service.validate_invariants()[&"ok"], "multi-entry and fatigue score state remains valid") + + +func _test_layered_assets_and_breach() -> void: + var outcomes := {&"count": 0, &"payload": {}} + var service := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), Callable(), + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"apply": + outcomes[&"count"] = int(outcomes[&"count"]) + 1 + outcomes[&"payload"] = _payload.duplicate(true) + return {&"ok": true, &"token": token} + ) + _must(service.create_raid(&"layers", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "layered Raid is created") + _must(service.register_asset(&"layers", &"core", &"OBJECTIVE", 3, 0), "fragile objective is registered") + _must(service.register_asset(&"layers", &"z_door", &"DOOR", 3, 0), "door is registered") + _must(service.register_asset(&"layers", &"a_device", &"DEFENSE_DEVICE", 3, 0), "device is registered") + _must(service.start_raid(&"layers", 0), "layered Raid starts") + var door_round = service.resolve_next_round(&"layers", &"layer_1", 5) + _expect(door_round.is_success() and door_round.details[&"defense_damage"][&"target_id"] == &"z_door", "lowest target-order door is damaged first") + _expect(door_round.details[&"defense_damage"][&"destroyed"], "door integrity reaches zero in its round") + var device_round = service.resolve_next_round(&"layers", &"layer_2", 10) + _expect(device_round.is_success() and device_round.details[&"defense_damage"][&"target_id"] == &"a_device", "device is targeted after destroyed doors") + _expect(device_round.details[&"score_components"][&"asset_defense"] == 0, "destroyed door no longer contributes defense") + var objective_round = service.resolve_next_round(&"layers", &"layer_3", 15) + _expect(objective_round.is_success() and objective_round.details[&"defense_damage"][&"target_id"] == &"core", "objective is reached after devices") + _expect(objective_round.details[&"state"] == &"BREACHED" and objective_round.details[&"reason"] == &"OBJECTIVE_DESTROYED", "destroyed objective breaches the Raid") + var raid := service.get_raid(&"layers") + _expect(raid[&"assets"][&"z_door"][&"integrity"] == 0 and raid[&"assets"][&"a_device"][&"integrity"] == 0 and raid[&"assets"][&"core"][&"integrity"] == 0, "all three defense layers retain their per-round integrity updates") + _expect(raid[&"waves"][0][&"state"] == &"BREACHED" and raid[&"result"] == &"BREACHED", "wave and Raid terminal states agree") + _expect(outcomes[&"count"] == 1, "terminal outcome boundary applies exactly once") + var asset_results: Array = outcomes[&"payload"][&"asset_results"] + _expect([asset_results[0][&"asset_id"], asset_results[1][&"asset_id"], asset_results[2][&"asset_id"]] == [&"a_device", &"core", &"z_door"], "terminal asset reconciliation payload uses stable ID order") + _expect(asset_results.all(func(asset: Dictionary) -> bool: return bool(asset[&"destroyed"])), "terminal payload exposes final integrity for every destroyed defense layer") + _expect(service.validate_invariants()[&"ok"], "breached layered Raid remains valid") + + +func _test_explicit_and_offscreen_attackers_share_one_formula() -> void: + var aggregate_only := _attack_mix_service(&"aggregate_only", false) + var mixed := _attack_mix_service(&"mixed", true) + var aggregate_round = aggregate_only.resolve_next_round(&"aggregate_only", &"round", 5) + var mixed_round = mixed.resolve_next_round(&"mixed", &"round", 5) + _expect(aggregate_round.is_success() and mixed_round.is_success(), "aggregate-only and mixed rounds resolve") + _expect(aggregate_round.details[&"attack_score"] == mixed_round.details[&"attack_score"], "equal explicit/offscreen effective power produces the same attack score") + _expect(aggregate_round.details[&"damage_to_threat"] == mixed_round.details[&"damage_to_threat"], "equal attack power uses the same damage formula") + _expect(mixed_round.details[&"attacker_damage"].size() == 1, "mixed damage is allocated to the stable explicit attacker first") + _expect(mixed_round.details[&"attacker_damage"][0][&"target_id"] == &"visible_raider" and mixed_round.details[&"attacker_damage"][0][&"damage"] == 2, "explicit attacker consumes only its actual remaining durability") + _expect(mixed_round.details[&"aggregate_damage"] == mixed_round.details[&"damage_applied_to_threat"] - 2, "only leftover damage reaches the offscreen aggregate") + _expect(mixed.get_raid(&"mixed")[&"waves"][0][&"remaining_threat"] == 10 - mixed_round.details[&"aggregate_damage"], "aggregate threat is reduced once, never in parallel with explicit damage") + _expect(mixed.get_raid(&"mixed")[&"combatants"][&"visible_raider"][&"state"] == DefenseServiceType.CombatantState.INCAPACITATED, "explicit attacker state follows its health") + _expect(mixed.validate_invariants()[&"ok"], "mixed on/offscreen attacker state remains valid") + + +func _test_active_skills_and_successful_boundaries() -> void: + var calls: Array[String] = [] + var service := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + calls.append("%s:%s" % [phase, payload[&"action_id"]]) + return {&"ok": true, &"token": token if token != null else String(payload[&"action_id"])} + ) + _must(service.create_raid(&"skills", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 50, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "skill Raid is created") + _must(service.register_combatant(&"skills", &"caster", &"DEFENDER", 20, 100, {&"health": 50}), "skill caster is registered") + _must(service.register_asset(&"skills", &"core", &"OBJECTIVE", 100, 5), "skill objective is registered") + _must(service.register_asset(&"skills", &"door", &"DOOR", 100, 5, {&"integrity": 40}), "fortify target is registered") + _must(service.start_raid(&"skills", 0), "skill Raid starts") + _must(service.queue_active_skill(&"skills", &"z_rally", &"caster", &"RALLY", {&"potency": 10}), "RALLY is queued") + _must(service.queue_active_skill(&"skills", &"a_power", &"caster", &"POWER_STRIKE", {&"potency": 5}), "POWER_STRIKE is queued") + _must(service.queue_active_skill(&"skills", &"m_heal", &"caster", &"HEAL", {&"potency": 7, &"target_id": &"caster"}), "HEAL is queued") + _must(service.queue_active_skill(&"skills", &"n_fortify", &"caster", &"FORTIFY", {&"potency": 6, &"target_id": &"door"}), "FORTIFY is queued") + var queued_replay = service.queue_active_skill(&"skills", &"z_rally", &"caster", &"RALLY", {&"potency": 99}) + _expect(queued_replay.is_success() and queued_replay.is_replay, "duplicate queued action ID is an idempotent replay") + var result = service.resolve_next_round(&"skills", &"skill_round", 5) + _expect(result.is_success(), "round with all active skills resolves") + _expect(result.details[&"skill_ids"] == [&"a_power", &"m_heal", &"n_fortify", &"z_rally"], "skills resolve in stable action-ID order") + _expect(result.details[&"score_components"][&"skill_bonus"] == 10, "RALLY contributes its score bonus") + _expect(result.details[&"damage_to_threat"] >= 6, "POWER_STRIKE adds direct threat damage") + var raid := service.get_raid(&"skills") + _expect(raid[&"combatants"][&"caster"][&"health"] == 57, "HEAL updates defender health before damage") + var fortify_effect: Dictionary = result.details[&"skill_effects"][2] + _expect(fortify_effect[&"action_id"] == &"n_fortify" and fortify_effect[&"repaired"] == 6, "FORTIFY repairs its target before the attack") + _expect(raid[&"assets"][&"door"][&"integrity"] == 46 - int(result.details[&"defense_damage"][&"damage"]), "round damage is applied after the FORTIFY repair") + _expect(raid[&"queued_skills"].is_empty() and raid[&"applied_skill_ids"].size() == 4, "applied skills move from queue to idempotency history") + _expect(calls.slice(0, 4) == ["validate:a_power", "validate:m_heal", "validate:n_fortify", "validate:z_rally"], "all boundary validations use stable action-ID order") + _expect(calls.slice(4, 8) == ["apply:a_power", "apply:m_heal", "apply:n_fortify", "apply:z_rally"], "all boundary applications follow the validated order") + var applied_replay = service.queue_active_skill(&"skills", &"z_rally", &"caster", &"RALLY") + _expect(applied_replay.is_success() and applied_replay.is_replay, "applied skill action ID also replays without recharging") + _expect(service.validate_invariants()[&"ok"], "active skill state satisfies invariants") + + +func _test_skill_boundary_validation_rollback() -> void: + var calls: Array[String] = [] + var service := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + calls.append("%s:%s" % [phase, payload[&"action_id"]]) + if phase == &"validate" and payload[&"action_id"] == &"b_reject": + return {&"ok": false, &"reason": &"NO_RESOURCE"} + return {&"ok": true, &"token": token if token != null else String(payload[&"action_id"])} + ) + _prepare_basic(service, &"validation_rollback") + _must(service.queue_active_skill(&"validation_rollback", &"a_reserved", &"guard", &"RALLY", {&"potency": 3}), "first reserved skill is queued") + _must(service.queue_active_skill(&"validation_rollback", &"b_reject", &"guard", &"RALLY", {&"potency": 3}), "rejecting skill is queued") + var before := service.create_snapshot() + var failed = service.resolve_next_round(&"validation_rollback", &"rejected_round", 5) + _expect(not failed.is_success(), "skill validation rejection aborts the round") + _expect(service.create_snapshot() == before, "validation rejection leaves all local Raid state unchanged") + _expect(calls == ["validate:a_reserved", "validate:b_reject", "rollback:a_reserved"], "earlier validation reservations roll back in reverse order") + _expect(service.validate_invariants()[&"ok"], "validation rollback preserves invariants") + + +func _test_skill_boundary_apply_and_outcome_rollback() -> void: + var apply_calls: Array[String] = [] + var applying := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), + func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + apply_calls.append("%s:%s" % [phase, payload[&"action_id"]]) + if phase == &"apply" and payload[&"action_id"] == &"b_fail": + return {&"ok": false, &"token": "failed_token"} + return {&"ok": true, &"token": token if token != null else String(payload[&"action_id"])} + ) + _prepare_basic(applying, &"apply_rollback") + for action_id: StringName in [&"a_apply", &"b_fail", &"c_validated"]: + _must(applying.queue_active_skill(&"apply_rollback", action_id, &"guard", &"RALLY", {&"potency": 2}), "apply rollback skill queues: %s" % action_id) + var apply_before := applying.create_snapshot() + var apply_failed = applying.resolve_next_round(&"apply_rollback", &"apply_failed", 5) + _expect(not apply_failed.is_success(), "skill application rejection aborts the round") + _expect(applying.create_snapshot() == apply_before, "application rejection leaves local state atomic") + _expect(apply_calls == [ + "validate:a_apply", "validate:b_fail", "validate:c_validated", + "apply:a_apply", "apply:b_fail", + "rollback:c_validated", "rollback:b_fail", "rollback:a_apply", + ], "applied, failed and not-yet-applied validation tokens all roll back once") + + var skill_calls: Array[String] = [] + var outcome_calls: Array[String] = [] + var skill_boundary := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + skill_calls.append("%s:%s" % [phase, payload[&"action_id"]]) + return {&"ok": true, &"token": token if token != null else &"skill_token"} + var outcome_boundary := func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + outcome_calls.append(String(phase)) + if phase == &"apply": + return {&"ok": false, &"token": &"outcome_token"} + return {&"ok": true, &"token": token if token != null else &"outcome_token"} + var terminal := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), skill_boundary, outcome_boundary + ) + _must(terminal.create_raid(&"outcome_rollback", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}], + })), "terminal rollback Raid is created") + _must(terminal.register_combatant(&"outcome_rollback", &"guard", &"DEFENDER", 10, 100), "terminal rollback guard is registered") + _must(terminal.register_asset(&"outcome_rollback", &"core", &"OBJECTIVE", 100, 0), "terminal rollback objective is registered") + _must(terminal.start_raid(&"outcome_rollback", 0), "terminal rollback Raid starts") + _must(terminal.queue_active_skill(&"outcome_rollback", &"skill", &"guard", &"RALLY", {&"potency": 10}), "terminal rollback skill is queued") + var terminal_before := terminal.create_snapshot() + var outcome_failed = terminal.resolve_next_round(&"outcome_rollback", &"outcome_failed", 5) + _expect(not outcome_failed.is_success(), "rejected terminal outcome aborts the round") + _expect(terminal.create_snapshot() == terminal_before, "outcome rejection restores round, skill and wave state atomically") + _expect(skill_calls == ["validate:skill", "apply:skill", "rollback:skill"], "successful external skill application is rolled back after outcome failure") + _expect(outcome_calls == ["validate", "apply", "rollback"], "failed outcome apply invokes its rollback boundary") + _expect(terminal.validate_invariants()[&"ok"], "cross-boundary rollback leaves a valid Raid") + + +func _test_rng_transaction_retry_stability() -> void: + var rng_state := {&"cursor": 0, &"values": [17, 83, 41, 59], &"phases": []} + var outcome_state := {&"apply_attempts": 0} + var roll_callback := func(_key: StringName, _context: Dictionary) -> int: + var cursor := int(rng_state[&"cursor"]) + var value := int(rng_state[&"values"][cursor]) + rng_state[&"cursor"] = cursor + 1 + return value + var rng_boundary := func(_context: Dictionary, phase: StringName, token: Variant) -> Dictionary: + (rng_state[&"phases"] as Array).append(phase) + if phase == &"snapshot": + return {&"ok": true, &"token": int(rng_state[&"cursor"])} + if phase == &"rollback": + rng_state[&"cursor"] = int(token) + return {&"ok": true, &"token": token} + var outcome_boundary := func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"apply": + outcome_state[&"apply_attempts"] = int(outcome_state[&"apply_attempts"]) + 1 + if int(outcome_state[&"apply_attempts"]) == 1: + return {&"ok": false, &"token": &"first_attempt"} + return {&"ok": true, &"token": token} + var service := DefenseServiceType.new().configure_callbacks( + roll_callback, Callable(), outcome_boundary, rng_boundary + ) + _must(service.create_raid(&"rng_retry", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}], + })), "RNG retry Raid is created") + _must(service.register_asset(&"rng_retry", &"core", &"OBJECTIVE", 1_000, 0), "RNG retry objective is registered") + _must(service.start_raid(&"rng_retry", 0), "RNG retry Raid starts") + var before := service.create_snapshot() + var failed = service.resolve_next_round(&"rng_retry", &"rng_resolution", 5) + _expect(not failed.is_success(), "terminal outcome failure aborts a stateful-RNG round") + _expect(service.create_snapshot() == before, "failed stateful-RNG round leaves Defense state atomic") + _expect(rng_state[&"cursor"] == 0, "RNG rollback restores the cursor consumed by both rolls") + _expect(rng_state[&"phases"] == [&"snapshot", &"rollback"], "failed round snapshots then rolls back RNG state") + var retried = service.resolve_next_round(&"rng_retry", &"rng_resolution", 5) + _expect(retried.is_success() and retried.details[&"defense_roll"] == 17 and retried.details[&"attack_roll"] == 83, "retry consumes the exact same RNG values") + _expect(rng_state[&"cursor"] == 2, "successful retry commits exactly two RNG draws") + _expect(rng_state[&"phases"] == [&"snapshot", &"rollback", &"snapshot", &"commit"], "successful retry commits its RNG transaction") + var replay = service.resolve_next_round(&"rng_retry", &"rng_resolution", 999) + _expect(replay.is_success() and replay.is_replay and replay.details == retried.details, "RNG-backed resolution replays its exact committed payload") + _expect(rng_state[&"cursor"] == 2 and (rng_state[&"phases"] as Array).size() == 4, "resolution replay consumes no RNG or boundary phase") + _expect(service.validate_invariants()[&"ok"], "RNG retry result satisfies invariants") + + +func _test_retreat_policies_and_manual_retreat_replay() -> void: + var threshold := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(threshold.create_raid(&"threshold", 0, _definition({&"retreat_policy": &"THRESHOLD", &"defender_retreat_threshold": 50})), "threshold Raid is created") + _must(threshold.register_combatant(&"threshold", &"guard", &"DEFENDER", 10, 100, {&"health": 40}), "wounded threshold guard is registered") + _must(threshold.register_asset(&"threshold", &"core", &"OBJECTIVE", 100, 5), "threshold objective is registered") + _must(threshold.start_raid(&"threshold", 0), "threshold Raid starts") + var threshold_round = threshold.resolve_next_round(&"threshold", &"threshold_round", 5) + _expect(threshold_round.is_success() and threshold_round.details[&"state"] == &"DEFENDERS_RETREATED", "THRESHOLD retreats at or below configured health percentage") + _expect(threshold.get_raid(&"threshold")[&"combatants"][&"guard"][&"state"] == DefenseServiceType.CombatantState.RETREATED, "automatic retreat updates active defenders") + + var protect := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(protect.create_raid(&"protect", 0, _definition({&"retreat_policy": &"PROTECT_AND_RETREAT", &"defender_retreat_threshold": 50})), "protect Raid is created") + _must(protect.register_combatant(&"protect", &"guard", &"DEFENDER", 10, 100), "protect guard is registered") + _must(protect.register_asset(&"protect", &"core", &"OBJECTIVE", 100, 5, {&"integrity": 40}), "pre-damaged protected objective is registered") + _must(protect.start_raid(&"protect", 0), "protect Raid starts") + var protect_round = protect.resolve_next_round(&"protect", &"protect_round", 5) + _expect(protect_round.is_success() and protect_round.details[&"reason"] == &"RETREAT_POLICY_TRIGGERED", "PROTECT_AND_RETREAT observes objective integrity against full capacity") + + for policy: StringName in [&"HOLD", &"MANUAL"]: + var holding := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(holding.create_raid(policy, 0, _definition({&"retreat_policy": policy, &"defender_retreat_threshold": 90})), "%s Raid is created" % policy) + _must(holding.register_combatant(policy, &"guard", &"DEFENDER", 10, 100, {&"health": 10}), "%s wounded guard is registered" % policy) + _must(holding.register_asset(policy, &"core", &"OBJECTIVE", 100, 5), "%s objective is registered" % policy) + _must(holding.start_raid(policy, 0), "%s Raid starts" % policy) + var round = holding.resolve_next_round(policy, StringName("%s_round" % policy), 5) + _expect(round.is_success() and round.details[&"state"] == &"ACTIVE", "%s never triggers automatic retreat" % policy) + + var outcome_count := {&"apply": 0} + var manual := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), Callable(), + func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"apply": + outcome_count[&"apply"] = int(outcome_count[&"apply"]) + 1 + return {&"ok": true, &"token": token} + ) + _prepare_basic(manual, &"manual") + _must(manual.queue_active_skill(&"manual", &"unused_skill", &"guard", &"RALLY", {&"potency": 2}), "manual retreat fixture queues an unused skill") + var retreated = manual.retreat_defenders(&"manual", &"manual_resolution", 3) + var replay = manual.retreat_defenders(&"manual", &"manual_resolution", 99) + _expect(retreated.is_success() and retreated.details[&"result"] == &"DEFENDERS_RETREATED", "manual retreat reaches its terminal state") + _expect(replay.is_success() and replay.is_replay and replay.details == retreated.details, "manual retreat resolution ID replays exact stored outcome") + _expect(outcome_count[&"apply"] == 1, "manual retreat consequence applies once across replay") + _expect(retreated.details[&"retreated_defender_ids"] == [&"guard"] and retreated.details[&"retreated_attacker_ids"].is_empty(), "outcome separates defender and attacker retreat IDs for reconciliation") + _expect(manual.get_raid(&"manual")[&"waves"][0][&"state"] == &"RETREATED", "manual retreat updates current wave state") + _expect(manual.get_raid(&"manual")[&"queued_skills"].is_empty(), "manual retreat discards skills that never crossed the apply boundary") + _expect(manual.validate_invariants()[&"ok"], "all retreat policy outcomes preserve invariants") + + +func _test_cancel_transaction_and_reconciliation() -> void: + var boundary_state := {&"apply_attempts": 0, &"rollbacks": 0, &"last_payload": {}} + var resolved_signals := {&"count": 0, &"payload": {}} + var outcome_boundary := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == &"apply": + boundary_state[&"apply_attempts"] = int(boundary_state[&"apply_attempts"]) + 1 + boundary_state[&"last_payload"] = payload.duplicate(true) + if int(boundary_state[&"apply_attempts"]) == 1: + return {&"ok": false, &"token": &"cancel_attempt"} + elif phase == &"rollback": + boundary_state[&"rollbacks"] = int(boundary_state[&"rollbacks"]) + 1 + return {&"ok": true, &"token": token} + var service := DefenseServiceType.new().configure_callbacks( + _fixed_roll(50), Callable(), outcome_boundary + ) + service.raid_resolved.connect(func(_raid_id: StringName, payload: Dictionary) -> void: + resolved_signals[&"count"] = int(resolved_signals[&"count"]) + 1 + resolved_signals[&"payload"] = payload.duplicate(true) + ) + _must(service.create_raid(&"cancel_tx", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "cancel transaction Raid is created") + _must(service.register_combatant(&"cancel_tx", &"guard", &"DEFENDER", 10, 100), "cancel transaction guard is registered") + _must(service.register_asset(&"cancel_tx", &"core", &"OBJECTIVE", 1_000, 0), "cancel transaction objective is registered") + _must(service.register_asset(&"cancel_tx", &"door", &"DOOR", 30, 0), "cancel transaction door is registered") + _must(service.start_raid(&"cancel_tx", 0), "cancel transaction Raid starts") + _must(service.resolve_next_round(&"cancel_tx", &"damage_round", 5), "cancel transaction accumulates one round of damage") + _must(service.queue_active_skill(&"cancel_tx", &"pending_on_cancel", &"guard", &"RALLY", { + &"potency": 3, &"metadata": {&"source": "cancel-test"}, + }), "cancel transaction retains a pending skill before cancellation") + var before_cancel := service.create_snapshot() + var rejected = service.cancel_raid(&"cancel_tx", 6, &"PLAYER_ABORT", &"cancel_resolution") + _expect(not rejected.is_success(), "cancel outcome rejection aborts cancellation") + _expect(service.create_snapshot() == before_cancel, "rejected cancellation preserves accumulated Raid state atomically") + _expect(boundary_state[&"apply_attempts"] == 1 and boundary_state[&"rollbacks"] == 1, "failed cancel apply invokes outcome rollback") + _expect(resolved_signals[&"count"] == 0, "rejected cancellation emits no raid_resolved signal") + var cancelled = service.cancel_raid(&"cancel_tx", 6, &"PLAYER_ABORT", &"cancel_resolution") + _expect(cancelled.is_success() and cancelled.details[&"result"] == &"CANCELLED", "accepted cancellation reaches CANCELLED through the outcome boundary") + _expect(cancelled.details[&"operation_kind"] == &"CANCEL", "cancel result stores its operation namespace") + _expect(cancelled.details[&"asset_results"] == boundary_state[&"last_payload"][&"asset_results"], "cancel boundary receives final accumulated asset integrity") + _expect(cancelled.details[&"combatant_results"] == boundary_state[&"last_payload"][&"combatant_results"], "cancel boundary receives final accumulated combatant state") + _expect(service.get_raid(&"cancel_tx")[&"queued_skills"].is_empty(), "accepted cancel discards skills that never crossed the apply boundary") + _expect(resolved_signals[&"count"] == 1 and resolved_signals[&"payload"] == cancelled.details, "accepted cancel emits one exact raid_resolved payload") + var replay = service.cancel_raid(&"cancel_tx", 999, &"DIFFERENT_REASON", &"cancel_resolution") + _expect(replay.is_success() and replay.is_replay and replay.details == cancelled.details, "cancel resolution replays canonically after terminal state") + _expect(boundary_state[&"apply_attempts"] == 2 and resolved_signals[&"count"] == 1, "cancel replay cannot reapply consequences or signals") + _expect(service.validate_invariants()[&"ok"], "transactional cancellation satisfies invariants") + + +func _test_resolution_operation_namespace_and_legacy_v1() -> void: + var round_service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _prepare_basic(round_service, &"namespace") + var round_result = round_service.resolve_next_round(&"namespace", &"shared_id", 5) + _expect(round_result.is_success() and round_result.details[&"operation_kind"] == &"ROUND", "round history stores ROUND operation kind") + var after_round := round_service.create_snapshot() + var wrong_retreat = round_service.retreat_defenders(&"namespace", &"shared_id", 6) + _expect(not wrong_retreat.is_success() and not wrong_retreat.is_replay, "round resolution ID cannot replay as manual retreat") + _expect(round_service.create_snapshot() == after_round, "cross-operation retreat collision is atomic") + var wrong_cancel = round_service.cancel_raid(&"namespace", 6, &"CANCELLED", &"shared_id") + _expect(not wrong_cancel.is_success() and not wrong_cancel.is_replay, "round resolution ID cannot replay as cancellation") + _expect(round_service.create_snapshot() == after_round, "cross-operation cancel collision is atomic") + + var retreat_service := DefenseServiceType.new() + _prepare_basic(retreat_service, &"retreat_namespace") + var retreat = retreat_service.retreat_defenders(&"retreat_namespace", &"terminal_id", 3) + _expect(retreat.is_success() and retreat.details[&"operation_kind"] == &"RETREAT", "manual retreat history stores RETREAT operation kind") + var wrong_round = retreat_service.resolve_next_round(&"retreat_namespace", &"terminal_id", 500) + _expect(not wrong_round.is_success() and not wrong_round.is_replay, "retreat resolution ID cannot replay as a round") + + var legacy_round_snapshot := after_round.duplicate(true) + legacy_round_snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"].erase("operation_kind") + var legacy_round := DefenseServiceType.new() + _expect(legacy_round.restore_snapshot(legacy_round_snapshot), "version-1 round snapshot without operation_kind restores") + _expect(legacy_round.create_snapshot() == legacy_round_snapshot, "legacy round snapshot round-trips without forced schema rewrite") + var legacy_round_replay = legacy_round.resolve_next_round(&"namespace", &"shared_id", 999) + _expect(legacy_round_replay.is_success() and legacy_round_replay.is_replay, "legacy round operation kind is inferred for exact replay") + _expect(not legacy_round.retreat_defenders(&"namespace", &"shared_id", 999).is_success(), "legacy inferred round still rejects cross-operation replay") + + var legacy_retreat_snapshot := retreat_service.create_snapshot() + legacy_retreat_snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"].erase("operation_kind") + var legacy_retreat := DefenseServiceType.new() + _expect(legacy_retreat.restore_snapshot(legacy_retreat_snapshot), "version-1 retreat snapshot without operation_kind restores") + var legacy_retreat_replay = legacy_retreat.retreat_defenders(&"retreat_namespace", &"terminal_id", 999) + _expect(legacy_retreat_replay.is_success() and legacy_retreat_replay.is_replay, "legacy retreat operation kind is inferred for exact replay") + + var cancel_service := DefenseServiceType.new() + _prepare_basic(cancel_service, &"cancel_namespace") + _must(cancel_service.cancel_raid(&"cancel_namespace", 3, &"LEGACY_CANCEL", &"cancel_terminal_id"), "legacy cancel fixture reaches its terminal state") + var legacy_cancel_snapshot := cancel_service.create_snapshot() + legacy_cancel_snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"].erase("operation_kind") + var legacy_cancel := DefenseServiceType.new() + _expect(legacy_cancel.restore_snapshot(legacy_cancel_snapshot), "version-1 cancel snapshot without operation_kind restores") + _expect(legacy_cancel.create_snapshot() == legacy_cancel_snapshot, "legacy cancel snapshot round-trips without forced schema rewrite") + var legacy_cancel_replay = legacy_cancel.cancel_raid(&"cancel_namespace", 999, &"DIFFERENT", &"cancel_terminal_id") + _expect(legacy_cancel_replay.is_success() and legacy_cancel_replay.is_replay, "legacy cancel operation kind is inferred for exact replay") + _expect(not legacy_cancel.retreat_defenders(&"cancel_namespace", &"cancel_terminal_id", 999).is_success(), "legacy inferred cancel rejects cross-operation replay") + _expect(round_service.validate_invariants()[&"ok"] and retreat_service.validate_invariants()[&"ok"] and cancel_service.validate_invariants()[&"ok"], "operation namespaces preserve valid aggregates") + + +func _test_multiple_waves_require_explicit_transition() -> void: + var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(service.create_raid(&"multi", 0, _definition({ + &"waves": [ + {&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}, + {&"wave_id": &"wave_2", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}, + ], + })), "two-wave Raid is created") + _must(service.register_asset(&"multi", &"core", &"OBJECTIVE", 1_000, 0), "durable multi-wave objective is registered") + _must(service.start_raid(&"multi", 0), "multi-wave Raid starts") + var wave_one = service.resolve_next_round(&"multi", &"wave_one_round", 5) + _expect(wave_one.is_success() and wave_one.details[&"state"] == &"BETWEEN_WAVES", "first max-round wave ends BETWEEN_WAVES") + var between_snapshot := service.create_snapshot() + _expect(not service.resolve_next_round(&"multi", &"illegal_between_round", 6).is_success(), "round cannot resolve while waiting between waves") + _expect(service.create_snapshot() == between_snapshot, "failed between-wave round is atomic") + _expect(service.get_raid(&"multi")[&"current_wave_index"] == 0, "next wave never starts implicitly") + var begun = service.begin_next_wave(&"multi", 7) + _expect(begun.is_success() and begun.details[&"wave_id"] == &"wave_2" and begun.details[&"wave_index"] == 1, "explicit command starts exactly the next wave") + _expect(service.get_raid(&"multi")[&"waves"][1][&"state"] == &"ACTIVE", "next wave state becomes ACTIVE") + _expect(not service.begin_next_wave(&"multi", 7).is_success(), "active wave cannot be skipped") + var wave_two = service.resolve_next_round(&"multi", &"wave_two_round", 12) + _expect(wave_two.is_success() and wave_two.details[&"state"] == &"DEFENDED", "last wave completion defends the Raid") + var raid := service.get_raid(&"multi") + _expect(raid[&"current_round"] == 2 and raid[&"elapsed_minutes"] == 10, "round and elapsed counters span all waves") + _expect(raid[&"waves"][0][&"state"] == &"RESOLVED" and raid[&"waves"][1][&"state"] == &"RESOLVED", "each completed wave retains its own state") + _expect(not service.begin_next_wave(&"multi", 13).is_success(), "terminal Raid has no phantom next wave") + _expect(service.validate_invariants()[&"ok"], "multi-wave terminal state satisfies invariants") + + +func _test_raid_duration_and_wave_round_limits() -> void: + var duration := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(duration.create_raid(&"duration", 0, _definition({ + &"max_duration_minutes": 5, + &"round_minutes": 5, + &"waves": [ + {&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}, + {&"wave_id": &"wave_2", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}, + ], + })), "duration-limited Raid is created") + _must(duration.register_asset(&"duration", &"core", &"OBJECTIVE", 1_000, 0), "duration objective is registered") + _must(duration.start_raid(&"duration", 0), "duration Raid starts") + var duration_result = duration.resolve_next_round(&"duration", &"duration_round", 5) + _expect(duration_result.is_success() and duration_result.details[&"state"] == &"DEFENDED", "Raid-wide duration is a hard terminal boundary") + _expect(duration_result.details[&"reason"] == &"RAID_DURATION_EXHAUSTED", "duration terminal reason is explicit") + _expect(duration.get_raid(&"duration")[&"current_wave_index"] == 0, "duration never opens a later wave") + + var max_round := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(max_round.create_raid(&"max_round", 0, _definition({ + &"max_duration_minutes": 100, + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}], + })), "max-round Raid is created") + _must(max_round.register_asset(&"max_round", &"core", &"OBJECTIVE", 1_000, 0), "max-round objective is registered") + _must(max_round.start_raid(&"max_round", 0), "max-round Raid starts") + var round_result = max_round.resolve_next_round(&"max_round", &"max_round_resolution", 5) + _expect(round_result.is_success() and round_result.details[&"reason"] == &"WAVE_DURATION_EXHAUSTED", "wave max-round limit has a stable terminal reason") + _expect(max_round.get_raid(&"max_round")[&"result"] == &"DEFENDED", "surviving the final wave round limit defends the Raid") + _expect(duration.validate_invariants()[&"ok"] and max_round.validate_invariants()[&"ok"], "both duration boundaries remain valid") + + +func _test_round_resolution_replay() -> void: + var roll_calls := {&"count": 0} + var roll_keys: Array[StringName] = [] + var service := DefenseServiceType.new().configure_callbacks( + func(key: StringName, _context: Dictionary) -> int: + roll_calls[&"count"] = int(roll_calls[&"count"]) + 1 + roll_keys.append(key) + return 50 + ) + _prepare_basic(service, &"replay") + var first = service.resolve_next_round(&"replay", &"same_resolution", 5) + var snapshot_after_first := service.create_snapshot() + var replay = service.resolve_next_round(&"replay", &"same_resolution", 500) + _expect(first.is_success() and replay.is_success() and replay.is_replay, "duplicate resolution ID returns an idempotent replay") + _expect(replay.details == first.details, "resolution replay returns the exact canonical payload") + _expect(service.create_snapshot() == snapshot_after_first, "resolution replay mutates no Raid or clock state") + _expect(roll_calls[&"count"] == 2, "defense and attack rolls are consumed only on first resolution") + _expect(roll_keys == [&"replay:wave_1:1:DEFENSE", &"replay:wave_1:1:ATTACK"], "round roll keys and call order are stable") + _expect(service.get_raid(&"replay")[&"current_round"] == 1, "replay cannot advance the round twice") + _expect(service.validate_invariants()[&"ok"], "replayed Raid remains valid") + + var default_a := DefenseServiceType.new() + var default_b := DefenseServiceType.new() + _prepare_basic(default_a, &"default_roll") + _prepare_basic(default_b, &"default_roll") + var default_result_a = default_a.resolve_next_round(&"default_roll", &"default_round", 5) + var default_result_b = default_b.resolve_next_round(&"default_roll", &"default_round", 5) + _expect(default_result_a.details[&"defense_roll"] == default_result_b.details[&"defense_roll"] and default_result_a.details[&"attack_roll"] == default_result_b.details[&"attack_roll"], "built-in fallback rolls are deterministic from stable keys") + _expect(default_result_a.details == default_result_b.details and default_a.create_snapshot() == default_b.create_snapshot(), "built-in deterministic roll reproduces the full round") + + +func _test_round_clock_contract() -> void: + var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _prepare_basic(service, &"clock_contract") + var initial := service.create_snapshot() + var too_early = service.resolve_next_round(&"clock_contract", &"too_early", 4) + _expect(not too_early.is_success() and too_early.details[&"earliest_minute"] == 5, "first round waits one full configured interval after start") + _expect(service.create_snapshot() == initial, "early first round rejection is atomic") + _must(service.resolve_next_round(&"clock_contract", &"round_1", 5), "first round resolves at its exact boundary") + var after_first := service.create_snapshot() + _expect(not service.resolve_next_round(&"clock_contract", &"same_minute_other_id", 5).is_success(), "different resolution ID cannot create a second round in the same minute") + _expect(not service.resolve_next_round(&"clock_contract", &"before_interval", 9).is_success(), "later round cannot violate previous-round interval") + _expect(service.create_snapshot() == after_first, "same-minute and interval failures preserve round history") + _must(service.resolve_next_round(&"clock_contract", &"round_2", 10), "second round resolves after the full interval") + _must(service.advance_time(20), "Defense clock advances independently") + var after_clock := service.create_snapshot() + _expect(not service.resolve_next_round(&"clock_contract", &"past_global_clock", 19).is_success(), "round cannot be inserted behind the current Defense clock") + _expect(service.create_snapshot() == after_clock, "past-global-clock rejection is atomic") + _must(service.resolve_next_round(&"clock_contract", &"round_3", 20), "round at current clock succeeds when interval is satisfied") + + var historical := DefenseServiceType.new() + _must(historical.advance_time(10), "historical fixture clock advances") + _expect(not historical.create_raid(&"past_create", 9, _definition()).is_success(), "Raid cannot be created in the past of the Defense clock") + _must(historical.create_raid(&"future_start", 10, _definition()), "Raid can be created at the current clock") + _must(historical.register_asset(&"future_start", &"core", &"OBJECTIVE", 100, 0), "future-start objective is registered") + _must(historical.advance_time(15), "clock advances before Raid start") + _expect(not historical.start_raid(&"future_start", 14).is_success(), "Raid cannot start behind the current Defense clock") + _must(historical.start_raid(&"future_start", 15), "Raid starts at the current Defense clock") + _expect(not historical.resolve_next_round(&"future_start", &"early_after_start", 19).is_success(), "first round interval is measured from actual start time") + _must(historical.resolve_next_round(&"future_start", &"on_time_after_start", 20), "first round resolves exactly one interval after late start") + _expect(service.validate_invariants()[&"ok"] and historical.validate_invariants()[&"ok"], "clock-contract fixtures satisfy invariants") + + +func _test_wave_entry_and_metadata_validation() -> void: + var entry_service := DefenseServiceType.new() + _must(entry_service.create_raid(&"entry_scope", 0, _definition({ + &"entry_points": [&"east", &"west"], + &"waves": [{ + &"wave_id": &"east_wave", &"threat_strength": 20, + &"entry_points": [&"east"], &"objective_ids": [&"core"], + }], + })), "entry-scope Raid is created") + _expect(not entry_service.register_combatant(&"entry_scope", &"wrong_gate", &"ATTACKER", 10, 10, { + &"wave_id": &"east_wave", &"entry_point_id": &"west", + }).is_success(), "attacker cannot use a global entry omitted by its assigned wave") + _expect(entry_service.register_combatant(&"entry_scope", &"east_raider", &"ATTACKER", 10, 10, { + &"wave_id": &"east_wave", &"entry_point_id": &"east", + }).is_success(), "attacker can use an entry included by its assigned wave") + + var metadata_service := DefenseServiceType.new() + _prepare_basic(metadata_service, &"metadata") + var metadata_before := metadata_service.create_snapshot() + var invalid_metadata: Array = [ + {&"label": "non-dictionary root", &"value": 7}, + {&"label": "Object", &"value": {"bad": RefCounted.new()}}, + {&"label": "Callable", &"value": {"bad": Callable(self, "_run")}}, + {&"label": "RID", &"value": {"bad": RID()}}, + {&"label": "NaN", &"value": {"bad": NAN}}, + {&"label": "positive infinity", &"value": {"nested": [1, INF]}}, + {&"label": "non-string dictionary key", &"value": {1: "bad"}}, + ] + for fixture: Dictionary in invalid_metadata: + var result = metadata_service.queue_active_skill(&"metadata", &"invalid_meta", &"guard", &"RALLY", { + &"potency": 1, &"metadata": fixture[&"value"], + }) + _expect(not result.is_success(), "skill metadata rejects %s" % fixture[&"label"]) + _expect(metadata_service.create_snapshot() == metadata_before, "rejected %s metadata is atomic" % fixture[&"label"]) + + var metadata_a := {"zeta": [&"name", 2, true, null], "alpha": {"b": 2.5, "a": "first"}} + var metadata_b := {"alpha": {"a": "first", "b": 2.5}, "zeta": [&"name", 2, true, null]} + _must(metadata_service.queue_active_skill(&"metadata", &"canonical", &"guard", &"RALLY", { + &"potency": 1, &"metadata": metadata_a, + }), "nested saveable metadata is accepted") + var stored_metadata: Dictionary = metadata_service.get_raid(&"metadata")[&"queued_skills"][&"canonical"][&"metadata"] + _expect(stored_metadata.keys() == ["alpha", "zeta"], "metadata dictionary keys are stored in lexical order") + _expect(stored_metadata["zeta"][0] == "name" and stored_metadata["alpha"].keys() == ["a", "b"], "metadata recursively canonicalizes StringName values and nested keys") + + var metadata_peer := DefenseServiceType.new() + _prepare_basic(metadata_peer, &"metadata") + _must(metadata_peer.queue_active_skill(&"metadata", &"canonical", &"guard", &"RALLY", { + &"potency": 1, &"metadata": metadata_b, + }), "reverse-order metadata fixture is accepted") + _expect(metadata_service.create_snapshot() == metadata_peer.create_snapshot(), "metadata snapshot is independent of dictionary insertion order") + var corrupt_metadata := metadata_service.create_snapshot() + corrupt_metadata[&"raids"][0][&"queued_skills"][0][&"metadata"] = {"bad": NAN} + var restored := DefenseServiceType.new() + _prepare_basic(restored, &"restored_baseline") + var restored_before := restored.create_snapshot() + _expect(not restored.restore_snapshot(corrupt_metadata), "snapshot restore rejects non-finite queued metadata") + _expect(restored.create_snapshot() == restored_before, "corrupt metadata snapshot rejection is atomic") + _expect(metadata_service.validate_invariants()[&"ok"] and metadata_peer.validate_invariants()[&"ok"], "canonical metadata fixtures satisfy invariants") + + +func _test_armor_pool_accounting() -> void: + var blocked := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(blocked.create_raid(&"armor_block", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 20, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "armor-block Raid is created") + _must(blocked.register_combatant(&"armor_block", &"guard", &"DEFENDER", 100, 100), "armor-block guard is registered") + _must(blocked.register_combatant(&"armor_block", &"armored_raider", &"ATTACKER", 1, 100, { + &"wave_id": &"wave_1", &"armor": 100, + }), "heavily armored attacker is registered") + _must(blocked.register_asset(&"armor_block", &"core", &"OBJECTIVE", 1_000, 0), "armor-block objective is registered") + _must(blocked.start_raid(&"armor_block", 0), "armor-block Raid starts") + var blocked_round = blocked.resolve_next_round(&"armor_block", &"blocked_round", 5) + _expect(blocked_round.is_success() and blocked_round.details[&"damage_to_threat"] > 0, "armored target receives a positive raw damage budget") + _expect(blocked_round.details[&"damage_applied_to_threat"] == 0, "armor uses max(0, damage - armor) without forced chip damage") + _expect(blocked_round.details[&"aggregate_damage"] == 0, "damage absorbed by explicit armor cannot leak into aggregate threat") + _expect(blocked_round.details[&"damage_mitigated_by_attackers"] == blocked_round.details[&"damage_to_threat"], "mitigation accounts for the entire blocked damage pool") + _expect(blocked.get_raid(&"armor_block")[&"waves"][0][&"remaining_threat"] == 20 and blocked.get_raid(&"armor_block")[&"combatants"][&"armored_raider"][&"health"] == 100, "full armor block changes neither explicit nor aggregate durability") + + var overflow := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(overflow.create_raid(&"armor_overflow", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 10, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "armor-overflow Raid is created") + _must(overflow.register_combatant(&"armor_overflow", &"guard", &"DEFENDER", 200, 100), "armor-overflow guard is registered") + _must(overflow.register_combatant(&"armor_overflow", &"front_raider", &"ATTACKER", 2, 2, { + &"wave_id": &"wave_1", &"armor": 5, + }), "front armored attacker is registered") + _must(overflow.register_asset(&"armor_overflow", &"core", &"OBJECTIVE", 1_000, 0), "armor-overflow objective is registered") + _must(overflow.start_raid(&"armor_overflow", 0), "armor-overflow Raid starts") + var overflow_round = overflow.resolve_next_round(&"armor_overflow", &"overflow_round", 5) + _expect(overflow_round.details[&"damage_to_threat"] == 12, "raw damage is capped by combined explicit and aggregate durability") + _expect(overflow_round.details[&"attacker_damage"][0][&"damage"] == 2 and overflow_round.details[&"attacker_damage"][0][&"mitigated"] == 5, "front attacker consumes armor plus remaining health from the pool") + _expect(overflow_round.details[&"aggregate_damage"] == 5, "only true raw overflow reaches aggregate threat") + _expect(overflow_round.details[&"damage_applied_to_threat"] == 7 and overflow_round.details[&"damage_mitigated_by_attackers"] == 5, "applied plus mitigated damage reconciles the complete pool") + _expect(overflow.get_raid(&"armor_overflow")[&"waves"][0][&"remaining_threat"] == 5, "aggregate durability decreases by overflow exactly once") + + var defended := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(defended.create_raid(&"defense_armor", 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "defense-armor Raid is created") + _must(defended.register_asset(&"defense_armor", &"core", &"OBJECTIVE", 1_000, 0), "defense-armor objective is registered") + _must(defended.register_asset(&"defense_armor", &"door", &"DOOR", 100, 0, {&"armor": 100}), "fully armored defense door is registered") + _must(defended.start_raid(&"defense_armor", 0), "defense-armor Raid starts") + var defense_round = defended.resolve_next_round(&"defense_armor", &"defense_armor_round", 5) + _expect(defense_round.details[&"damage_to_defense"] > 0 and defense_round.details[&"defense_damage"][&"damage"] == 0, "defense armor also applies max(0, damage - armor)") + _expect(defense_round.details[&"defense_damage"][&"mitigated"] == defense_round.details[&"damage_to_defense"] and defended.get_raid(&"defense_armor")[&"assets"][&"door"][&"integrity"] == 100, "fully mitigated defense damage leaves integrity unchanged") + _expect(blocked.validate_invariants()[&"ok"] and overflow.validate_invariants()[&"ok"] and defended.validate_invariants()[&"ok"], "armor pool fixtures satisfy invariants") + + +func _test_snapshot_round_trip_and_corrupt_atomicity() -> void: + var source := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _must(source.create_raid(&"snapshot_raid", 0, _definition({ + &"entry_points": [&"west", &"east"], + &"waves": [ + {&"wave_id": &"wave_1", &"threat_strength": 60, &"entry_points": [&"east", &"west"], &"max_rounds": 10}, + {&"wave_id": &"wave_2", &"threat_strength": 30, &"entry_points": [&"west"], &"max_rounds": 10}, + ], + })), "snapshot Raid is created") + _must(source.register_combatant(&"snapshot_raid", &"guard_b", &"DEFENDER", 20, 100), "snapshot defender B is registered") + _must(source.register_combatant(&"snapshot_raid", &"guard_a", &"DEFENDER", 10, 80), "snapshot defender A is registered") + _must(source.register_combatant(&"snapshot_raid", &"raider", &"ATTACKER", 15, 30, {&"wave_id": &"wave_1", &"entry_point_id": &"east"}), "snapshot attacker is registered") + _must(source.register_asset(&"snapshot_raid", &"core", &"OBJECTIVE", 200, 20), "snapshot objective is registered") + _must(source.register_asset(&"snapshot_raid", &"door", &"DOOR", 50, 10, {&"entry_points": [&"east"]}), "snapshot door is registered") + _must(source.start_raid(&"snapshot_raid", 0), "snapshot Raid starts") + _must(source.queue_active_skill(&"snapshot_raid", &"applied", &"guard_a", &"RALLY", {&"potency": 4, &"metadata": {&"source": "test"}}), "snapshot applied skill is queued") + _must(source.resolve_next_round(&"snapshot_raid", &"snapshot_round", 5), "snapshot round is resolved") + _must(source.queue_active_skill(&"snapshot_raid", &"queued", &"guard_b", &"POWER_STRIKE", {&"potency": 2, &"metadata": {&"source": "test"}}), "snapshot pending skill is queued") + var snapshot := source.create_snapshot() + var restored := DefenseServiceType.new() + _expect(restored.restore_snapshot(snapshot), "valid Defense snapshot restores") + _expect(restored.create_snapshot() == snapshot, "Defense snapshot has an exact round trip") + _expect(restored.validate_invariants()[&"ok"], "restored Defense snapshot satisfies invariants") + _expect(restored.get_raid_ids() == [&"snapshot_raid"] and restored.get_active_raid_ids() == [&"snapshot_raid"], "restored indexes expose canonical Raid IDs") + var restored_replay = restored.resolve_next_round(&"snapshot_raid", &"snapshot_round", 999) + _expect(restored_replay.is_success() and restored_replay.is_replay and restored.get_raid(&"snapshot_raid")[&"current_round"] == 1, "restored resolution history remains idempotent") + _expect(JSON.stringify(restored_replay.details) == JSON.stringify(snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"]), "restored replay returns its exact canonical payload") + var json_snapshot: Variant = JSON.parse_string(JSON.stringify(snapshot)) + var json_restored := DefenseServiceType.new() + _expect(json_snapshot is Dictionary and json_restored.restore_snapshot(json_snapshot), "JSON-serialized Defense snapshot restores") + var expected_json := JSON.stringify(snapshot) + var actual_json := JSON.stringify(json_restored.create_snapshot()) + _expect(actual_json == expected_json, "JSON transport preserves the canonical Defense snapshot") + + # Each corrupt restore is attempted against a non-empty live aggregate. A + # rejection must preserve the previously restored snapshot byte-for-value. + var corruptions: Array[Dictionary] = [] + var duplicate := snapshot.duplicate(true) + duplicate[&"raids"].append(duplicate[&"raids"][0].duplicate(true)) + corruptions.append({&"name": "duplicate Raid ID", &"snapshot": duplicate}) + var bad_remaining := snapshot.duplicate(true) + bad_remaining[&"raids"][0][&"waves"][0][&"remaining_threat"] = 9_999 + corruptions.append({&"name": "remaining threat above maximum", &"snapshot": bad_remaining}) + var bad_wave_state := snapshot.duplicate(true) + bad_wave_state[&"raids"][0][&"waves"][0][&"state"] = "TELEPORTING" + corruptions.append({&"name": "unknown wave state", &"snapshot": bad_wave_state}) + var bad_clock := snapshot.duplicate(true) + bad_clock[&"current_minute"] = -1 + corruptions.append({&"name": "negative Defense clock", &"snapshot": bad_clock}) + var future_clock := snapshot.duplicate(true) + future_clock[&"current_minute"] = 0 + future_clock[&"raids"][0][&"state_changed_at"] = 1 + corruptions.append({&"name": "Raid timestamp beyond clock", &"snapshot": future_clock}) + var bad_health := snapshot.duplicate(true) + bad_health[&"raids"][0][&"combatants"][0][&"health"] = -1 + corruptions.append({&"name": "negative combatant health", &"snapshot": bad_health}) + var bad_asset_entry := snapshot.duplicate(true) + bad_asset_entry[&"raids"][0][&"assets"][0][&"entry_points"] = ["not_declared"] + corruptions.append({&"name": "unknown asset entry", &"snapshot": bad_asset_entry}) + var bad_attacker_wave_entry := snapshot.duplicate(true) + bad_attacker_wave_entry[&"raids"][0][&"waves"][0][&"entry_points"] = ["west"] + corruptions.append({ + &"name": "attacker entry omitted by assigned wave", + &"snapshot": bad_attacker_wave_entry, + }) + var bad_skill := snapshot.duplicate(true) + bad_skill[&"raids"][0][&"queued_skills"][0][&"potency"] = 101 + corruptions.append({&"name": "invalid queued skill potency", &"snapshot": bad_skill}) + for corruption: Dictionary in corruptions: + var before := restored.create_snapshot() + _expect(not restored.restore_snapshot(corruption[&"snapshot"]), "corrupt snapshot rejects %s" % corruption[&"name"]) + _expect(restored.create_snapshot() == before, "rejected %s is atomic" % corruption[&"name"]) + _expect(not restored.last_error.is_empty(), "corrupt restore exposes a diagnostic error") + _expect(restored.validate_invariants()[&"ok"], "all corrupt snapshot attempts preserve live invariants") + + +func _test_cross_time_snapshot_rejection() -> void: + var source := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + _prepare_basic(source, &"time_snapshot") + _must(source.resolve_next_round(&"time_snapshot", &"time_round_1", 5), "time snapshot first round resolves") + _must(source.resolve_next_round(&"time_snapshot", &"time_round_2", 10), "time snapshot second round resolves") + var valid := source.create_snapshot() + var restored := DefenseServiceType.new() + _expect(restored.restore_snapshot(valid), "valid cross-time snapshot restores") + var corruptions: Array[Dictionary] = [] + var start_after_state := valid.duplicate(true) + start_after_state[&"raids"][0][&"started_at"] = 11 + corruptions.append({&"name": "start after state change", &"snapshot": start_after_state}) + var first_too_early := valid.duplicate(true) + first_too_early[&"raids"][0][&"resolved_rounds"][0][&"payload"]["absolute_minute"] = 4 + corruptions.append({&"name": "first round before start interval", &"snapshot": first_too_early}) + var same_minute_rounds := valid.duplicate(true) + same_minute_rounds[&"raids"][0][&"resolved_rounds"][1][&"payload"]["absolute_minute"] = 5 + corruptions.append({&"name": "two rounds in one minute", &"snapshot": same_minute_rounds}) + var resolution_after_state := valid.duplicate(true) + resolution_after_state[&"raids"][0][&"resolved_rounds"][1][&"payload"]["absolute_minute"] = 11 + corruptions.append({&"name": "resolution after state change", &"snapshot": resolution_after_state}) + var missing_history := valid.duplicate(true) + missing_history[&"raids"][0][&"resolved_rounds"].remove_at(0) + corruptions.append({&"name": "missing round history", &"snapshot": missing_history}) + var invalid_operation := valid.duplicate(true) + invalid_operation[&"raids"][0][&"resolved_rounds"][0][&"payload"]["operation_kind"] = "DUEL" + corruptions.append({&"name": "invalid explicit operation kind", &"snapshot": invalid_operation}) + var clock_before_state := valid.duplicate(true) + clock_before_state[&"current_minute"] = 9 + corruptions.append({&"name": "clock before latest state", &"snapshot": clock_before_state}) + var live_finished := valid.duplicate(true) + live_finished[&"raids"][0][&"finished_at"] = 10 + corruptions.append({&"name": "live Raid with finish time", &"snapshot": live_finished}) + for corruption: Dictionary in corruptions: + var before := restored.create_snapshot() + _expect(not restored.restore_snapshot(corruption[&"snapshot"]), "cross-time snapshot rejects %s" % corruption[&"name"]) + _expect(restored.create_snapshot() == before, "rejected %s remains atomic" % corruption[&"name"]) + + var terminal := DefenseServiceType.new() + _prepare_basic(terminal, &"terminal_time") + _must(terminal.cancel_raid(&"terminal_time", 2, &"TEST_CANCEL", &"terminal_cancel"), "terminal time fixture cancels") + var bad_finish := terminal.create_snapshot() + bad_finish[&"raids"][0][&"finished_at"] = 1 + var before_terminal_restore := restored.create_snapshot() + _expect(not restored.restore_snapshot(bad_finish), "terminal snapshot rejects finish time different from state change") + _expect(restored.create_snapshot() == before_terminal_restore, "terminal finish-time rejection is atomic") + var bad_terminal_resolution_time := terminal.create_snapshot() + bad_terminal_resolution_time[&"raids"][0][&"resolved_rounds"][0][&"payload"]["absolute_minute"] = 1 + _expect(not restored.restore_snapshot(bad_terminal_resolution_time), "terminal command resolution must occur at the Raid finish time") + _expect(restored.create_snapshot() == before_terminal_restore, "terminal resolution-time rejection is atomic") + var wrong_terminal_operation := terminal.create_snapshot() + wrong_terminal_operation[&"raids"][0][&"resolved_rounds"][0][&"payload"]["operation_kind"] = "RETREAT" + _expect(not restored.restore_snapshot(wrong_terminal_operation), "terminal operation kind must agree with its result and Raid state") + _expect(restored.create_snapshot() == before_terminal_restore, "terminal operation mismatch rejection is atomic") + _expect(restored.validate_invariants()[&"ok"], "cross-time corruptions preserve restored invariants") + + +func _test_input_order_determinism() -> void: + var forward := _ordered_fixture(false) + var reverse := _ordered_fixture(true) + _expect(forward.create_snapshot() == reverse.create_snapshot(), "canonical snapshot is independent of registration and collection input order") + var forward_round = forward.resolve_next_round(&"ordered", &"ordered_round", 5) + var reverse_round = reverse.resolve_next_round(&"ordered", &"ordered_round", 5) + _expect(forward_round.is_success() and reverse_round.is_success(), "both input-order variants resolve") + _expect(forward_round.details == reverse_round.details, "round payload is input-order deterministic") + _expect(forward.create_snapshot() == reverse.create_snapshot(), "post-round aggregate is input-order deterministic") + _expect(forward_round.details[&"defense_damage"][&"target_id"] == &"a_door", "equal-priority asset targeting uses lexical stable ID") + _expect(forward_round.details[&"attacker_damage"][0][&"target_id"] == &"a_attacker", "explicit attacker damage uses lexical stable ID") + _expect(forward.validate_invariants()[&"ok"] and reverse.validate_invariants()[&"ok"], "both deterministic orderings preserve invariants") + + +func _definition(overrides: Dictionary = {}) -> Dictionary: + var definition := { + &"source_id": &"fixture", + &"entry_points": [&"gate"], + &"objective_ids": [&"core"], + &"waves": [{ + &"wave_id": &"wave_1", + &"threat_strength": 30, + &"retreat_threshold": 0, + &"max_rounds": 12, + }], + &"max_duration_minutes": 120, + &"round_minutes": 5, + &"retreat_policy": &"HOLD", + &"defender_retreat_threshold": 20, + &"terrain_bonus": 0, + &"command_bonus": 0, + &"surprise_penalty": 0, + &"multiple_entry_penalty": 5, + } + for key: Variant in overrides.keys(): + definition[key] = overrides[key] + return definition + + +func _fixed_roll(value: int) -> Callable: + return func(_key: StringName, _context: Dictionary) -> int: return value + + +func _prepare_basic(service: DefenseService, raid_id: StringName) -> void: + _must(service.create_raid(raid_id, 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "basic Raid is created: %s" % raid_id) + _must(service.register_combatant(raid_id, &"guard", &"DEFENDER", 20, 100), "basic guard is registered: %s" % raid_id) + _must(service.register_asset(raid_id, &"core", &"OBJECTIVE", 1_000, 10), "basic objective is registered: %s" % raid_id) + _must(service.start_raid(raid_id, 0), "basic Raid starts: %s" % raid_id) + + +func _attack_mix_service(raid_id: StringName, include_explicit: bool) -> DefenseService: + var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + var aggregate_strength := 10 if include_explicit else 12 + _must(service.create_raid(raid_id, 0, _definition({ + &"waves": [{&"wave_id": &"wave_1", &"threat_strength": aggregate_strength, &"retreat_threshold": 0, &"max_rounds": 10}], + })), "attack mix Raid is created: %s" % raid_id) + _must(service.register_combatant(raid_id, &"guard", &"DEFENDER", 100, 100), "attack mix guard is registered: %s" % raid_id) + if include_explicit: + _must(service.register_combatant(raid_id, &"visible_raider", &"ATTACKER", 2, 2, {&"wave_id": &"wave_1"}), "visible attacker is registered") + _must(service.register_asset(raid_id, &"core", &"OBJECTIVE", 1_000, 0), "attack mix objective is registered: %s" % raid_id) + _must(service.start_raid(raid_id, 0), "attack mix Raid starts: %s" % raid_id) + return service + + +func _ordered_fixture(reverse: bool) -> DefenseService: + var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50)) + var entries: Array[StringName] = [&"east", &"west"] + if reverse: + entries.reverse() + _must(service.create_raid(&"ordered", 0, _definition({ + &"entry_points": entries, + &"waves": [{ + &"wave_id": &"wave_1", &"threat_strength": 40, + &"entry_points": entries.duplicate(), &"objective_ids": [&"core"], + &"retreat_threshold": 0, &"max_rounds": 10, + }], + })), "ordered Raid definition is created") + var combatants := [ + [&"z_guard", &"DEFENDER", 10, 100, {}], + [&"a_guard", &"DEFENDER", 20, 100, {}], + [&"z_attacker", &"ATTACKER", 5, 20, {&"wave_id": &"wave_1"}], + [&"a_attacker", &"ATTACKER", 5, 20, {&"wave_id": &"wave_1"}], + ] + var assets := [ + [&"core", &"OBJECTIVE", 1_000, 10, {}], + [&"z_door", &"DOOR", 50, 5, {&"target_order": 10}], + [&"a_door", &"DOOR", 50, 5, {&"target_order": 10}], + ] + if reverse: + combatants.reverse() + assets.reverse() + for row: Array in combatants: + _must(service.register_combatant(&"ordered", row[0], row[1], row[2], row[3], row[4]), "ordered combatant registers: %s" % row[0]) + for row: Array in assets: + _must(service.register_asset(&"ordered", row[0], row[1], row[2], row[3], row[4]), "ordered asset registers: %s" % row[0]) + _must(service.start_raid(&"ordered", 0), "ordered Raid starts") + return service + + +func _must(result: CommandResult, message: String) -> bool: + return _expect(result != null and result.is_success(), message) + + +func _expect(condition: bool, message: String) -> bool: + _checks += 1 + if condition: + return true + _failures += 1 + push_error(" CHECK FAILED: %s" % message) + return false diff --git a/tests/s5/test_defense_service.gd.uid b/tests/s5/test_defense_service.gd.uid new file mode 100644 index 0000000..e6ce75b --- /dev/null +++ b/tests/s5/test_defense_service.gd.uid @@ -0,0 +1 @@ +uid://ceq33fn7vgm51 diff --git a/tests/s5/test_runtime_authority_cycle.gd b/tests/s5/test_runtime_authority_cycle.gd new file mode 100644 index 0000000..6b769cf --- /dev/null +++ b/tests/s5/test_runtime_authority_cycle.gd @@ -0,0 +1,542 @@ +extends SceneTree + +const PLAYER_CASH := &"PLAYER_CASH" +const AUTHORITY_FINE_EXPENSE := &"AUTHORITY_FINE_EXPENSE" + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S5: integrated Authority runtime cycle") + _test_incident_visibility_and_daily_evaluation() + _test_resolution_replay_unreachable_exit_and_long_horizon() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed: int, hour := 6, minute := 0) -> void: + _cleanup() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, hour, minute) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _clock.set_paused(true) + _expect(report[&"valid"], "runtime initializes with the Authority aggregate") + _expect( + _runtime.authority_service.current_minute == _clock.absolute_minute, + "Authority time begins aligned with SimulationKernel" + ) + _expect( + _runtime.economy_service.has_account(AUTHORITY_FINE_EXPENSE), + "Authority fine expense account exists at initialization" + ) + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_incident_visibility_and_daily_evaluation() -> void: + _reset_runtime(5_501, 5, 59) + var reported := _submit( + &"report_authority_incident", + { + &"incident_id": &"incident.runtime.shared", + &"incident_type": &"illegal_facility", + &"detector_id": &"detector.noise", + &"options": { + &"severity": 80, + &"heat_delta": 100, + &"facility_id": &"facility.hidden", + }, + }, + &"s5.incident.first" + ) + var duplicate := _submit( + &"report_authority_incident", + { + &"incident_id": &"incident.runtime.shared", + &"incident_type": &"conflicting_payload", + &"detector_id": &"detector.witness", + &"options": {&"severity": 100, &"heat_delta": 100}, + }, + &"s5.incident.second-detector" + ) + var incident: AuthorityIncident = _runtime.authority_service.get_incident(&"incident.runtime.shared") + _expect(reported.is_success(), "incident report executes through Runtime Command") + _expect( + duplicate.is_success() and duplicate.is_replay, + "incident_id deduplicates reports from independent detectors" + ) + _expect( + _runtime.authority_service.get_authority_state().heat == 100, + "duplicate detector cannot apply Heat twice" + ) + _expect( + incident.detector_ids == [&"detector.noise", &"detector.witness"] + and incident.incident_type == &"illegal_facility", + "dedup retains sorted detector audit data and the first canonical payload" + ) + _expect( + _count_domain_events(&"INCIDENT_CREATED", incident.incident_id) == 1 + and _count_domain_events(&"INCIDENT_DETECTOR_ADDED", incident.incident_id) == 1, + "Authority signals project once into DomainEventStream" + ) + + var wall := Vector3i(1, 0, WorldService.LAYER_FRONT) + var evidence_cell := Vector3i(2, 0, WorldService.LAYER_FRONT) + _expect(_runtime.world_service.set_walkable(wall, false), "LOS fixture wall is installed") + _expect(_runtime.rebuild_path_graph_from_world(), "Path graph rebuilds after wall installation") + var registered := _submit( + &"register_authority_evidence", + { + &"evidence_id": &"evidence.runtime.wall", + &"source": &"visual_trace", + &"severity": 35, + &"options": { + &"incident_id": incident.incident_id, + &"facility_id": &"facility.hidden", + &"location_cell": evidence_cell, + &"expires_at": 2_000, + }, + }, + &"s5.evidence.register" + ) + var blocked := _submit( + &"discover_authority_evidence", + { + &"evidence_id": &"evidence.runtime.wall", + &"inspector_id": &"inspector.los", + &"inspector_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"context": {}, + }, + &"s5.evidence.blocked" + ) + _expect(registered.is_success(), "latent evidence registers through Runtime Command") + _expect( + not blocked.is_success() and blocked.details.get(&"reason", &"") == &"LINE_OF_SIGHT_BLOCKED", + "WorldService wall blocks automatic evidence discovery" + ) + _expect( + _runtime.authority_service.get_authority_state().evidence_points == 0, + "wall-blocked evidence contributes no Authority score" + ) + _expect(_runtime.world_service.set_walkable(wall, true), "LOS fixture wall is removed") + _expect(_runtime.rebuild_path_graph_from_world(), "Path graph rebuilds after opening sight line") + var discovered := _submit( + &"discover_authority_evidence", + { + &"evidence_id": &"evidence.runtime.wall", + &"inspector_id": &"inspector.los", + &"inspector_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"context": {&"illegal_confirmed": true}, + }, + &"s5.evidence.visible" + ) + _expect( + discovered.is_success() and discovered.details[&"discovery_method"] == &"LINE_OF_SIGHT", + "evidence becomes visible only after the World callback confirms sight" + ) + _expect( + _runtime.authority_service.get_authority_state().evidence_points == 35, + "visible evidence contributes its deterministic Incident score" + ) + + var authority_rng: DeterministicRng = _runtime.get_rng_stream(&"authority") + var expected_rng := DeterministicRng.from_root_seed(0, &"authority") as DeterministicRng + _expect(expected_rng.restore(authority_rng.snapshot()), "Authority RNG snapshot clones for prediction") + var expected_roll := expected_rng.randi_range(0, 99) + var before_six: Dictionary = _runtime.create_snapshot() + _clock.advance_one_minute() + var evaluation := _evaluation_for_day(0) + _expect( + _clock.absolute_minute == 360 and _runtime.authority_service.current_minute == 360, + "INCIDENTS_AND_EVENTS advances Authority time at 06:00" + ) + _expect( + int(evaluation.get(&"roll", -1)) == expected_roll + and bool(evaluation.get(&"selected", false)) == (expected_roll < int(evaluation.get(&"chance", 0))), + "06:00 inspection decision consumes only the deterministic Authority RNG stream" + ) + _expect( + int(evaluation.get(&"chance", -1)) == 95 + and not StringName(evaluation.get(&"inspection_id", &"")).is_empty(), + "Heat and active-case terms deterministically schedule the selected inspection" + ) + _expect( + _runtime.get_rng_stream(&"authority").get_counter() == expected_rng.get_counter(), + "06:00 evaluation advances the Authority RNG counter exactly once" + ) + _expect( + _count_domain_events(&"AUTHORITY_INSPECTION_EVALUATED") == 1 + and _count_domain_events(&"INSPECTION_SCHEDULED") == 1, + "automatic inspection selection emits its Domain Events once" + ) + var first_six_snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(before_six), "pre-06:00 Runtime snapshot restores atomically") + _clock.advance_one_minute() + _expect( + _runtime.create_snapshot() == first_six_snapshot, + "restored 06:00 Authority selection reproduces the full Runtime snapshot" + ) + + var stable_before_corruption: Dictionary = _runtime.create_snapshot() + var corrupt := stable_before_corruption.duplicate(true) + corrupt[&"systems"][&"runtime"][&"authority"][&"current_minute"] = 359 + _expect( + not _runtime.restore_snapshot(corrupt), + "restore rejects an Authority/SimulationKernel minute mismatch" + ) + _expect( + _runtime.create_snapshot() == stable_before_corruption, + "rejected Authority restore leaves the live Runtime snapshot unchanged" + ) + _expect( + _runtime.authority_service.validate_invariants()[&"ok"], + "integrated incident, evidence and inspection selection preserve Authority invariants" + ) + + +func _test_resolution_replay_unreachable_exit_and_long_horizon() -> void: + _reset_runtime(5_502) + _expect(_submit( + &"report_authority_incident", + { + &"incident_id": &"incident.runtime.fine", + &"incident_type": &"contraband", + &"detector_id": &"detector.audit", + &"options": {&"severity": 80, &"heat_delta": 20}, + }, + &"s5.fine.incident" + ).is_success(), "fine fixture Incident registers") + _expect(_submit( + &"register_authority_evidence", + { + &"evidence_id": &"evidence.runtime.fine", + &"source": &"physical_record", + &"severity": 60, + &"options": { + &"incident_id": &"incident.runtime.fine", + &"facility_id": &"facility.illegal", + &"location_cell": Vector3i(2, 0, WorldService.LAYER_FRONT), + }, + }, + &"s5.fine.evidence" + ).is_success(), "fine fixture Evidence registers") + _expect(_submit( + &"schedule_authority_inspection", + {&"options": { + &"inspection_id": &"inspection.runtime.fine", + &"inspector_id": &"inspector.runtime.fine", + &"entrance_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + }}, + &"s5.fine.schedule" + ).is_success(), "inspection schedules through Runtime Command") + _expect(_submit( + &"start_authority_inspection", + {&"inspection_id": &"inspection.runtime.fine"}, + &"s5.fine.start" + ).is_success(), "inspection starts through Runtime Command") + _expect(_submit( + &"arrive_authority_inspection", + {&"inspection_id": &"inspection.runtime.fine"}, + &"s5.fine.arrive" + ).is_success(), "inspector checks in through Runtime Command") + _expect(_submit( + &"plan_authority_inspection", + { + &"inspection_id": &"inspection.runtime.fine", + &"candidates": [{ + &"waypoint_id": &"waypoint.illegal.room", + &"cell": Vector3i(2, 0, WorldService.LAYER_FRONT), + &"suspect_score": 100, + &"evidence_potential": 100, + &"travel_minutes": 2, + &"inspection_minutes": 3, + }], + &"time_budget_minutes": 30, + }, + &"s5.fine.plan" + ).is_success(), "deterministic inspection patrol is planned") + var moved := _submit( + &"advance_authority_inspection", + {&"inspection_id": &"inspection.runtime.fine", &"context": {}}, + &"s5.fine.advance" + ) + _expect( + moved.is_success() and moved.details.get(&"reachable", false), + "PathService callback advances the inspector to one reachable waypoint" + ) + var mid_inspection: Dictionary = _runtime.create_snapshot() + var mid_digest := SimulationDigest.compute(mid_inspection) + var first_final := _run_resolution_timeline() + _expect(first_final.get(&"ok", false), "inspection resolution timeline completes") + var first_final_snapshot: Dictionary = _runtime.create_snapshot() + var first_final_digest := SimulationDigest.compute(first_final_snapshot) + _expect(_runtime.restore_snapshot(mid_inspection), "mid-inspection Runtime snapshot restores") + _expect( + SimulationDigest.compute(_runtime.create_snapshot()) == mid_digest, + "mid-inspection restore reproduces its exact digest" + ) + var replay_final := _run_resolution_timeline() + _expect(replay_final.get(&"ok", false), "restored inspection timeline completes again") + _expect( + SimulationDigest.compute(_runtime.create_snapshot()) == first_final_digest + and _runtime.create_snapshot() == first_final_snapshot, + "mid-inspection replay reproduces Authority, ledger and Domain Events exactly" + ) + + _expect(_run_unreachable_inspection(), "unreachable patrol target follows the safe exit path") + _expect( + _runtime.authority_service.validate_invariants()[&"ok"] + and _runtime.economy_service.validate_ledger()[&"ok"], + "resolved and unreachable inspections preserve Authority and ledger invariants" + ) + var horizon_snapshot: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(1_440) + var first_horizon_digest: String = _runtime.latest_digest + var first_horizon_snapshot: Dictionary = _runtime.create_snapshot() + _expect( + _runtime.authority_service.current_minute == _clock.absolute_minute, + "Authority stays aligned through a 24-hour simulation horizon" + ) + _expect(_runtime.restore_snapshot(horizon_snapshot), "24-hour Authority replay origin restores") + _clock.advance_minutes(1_440) + _expect( + _runtime.latest_digest == first_horizon_digest + and _runtime.create_snapshot() == first_horizon_snapshot, + "24-hour Runtime replay reproduces daily Heat and inspection evaluation exactly" + ) + _expect( + _runtime.authority_service.validate_invariants()[&"ok"] + and _runtime.economy_service.validate_ledger()[&"ok"], + "long replay ends with valid Authority and Economy aggregates" + ) + + +func _run_resolution_timeline() -> Dictionary: + var inspected := _submit( + &"inspect_authority_waypoint", + { + &"inspection_id": &"inspection.runtime.fine", + &"evidence_ids": [&"evidence.runtime.fine"], + &"context": {&"illegal_confirmed": true}, + }, + &"s5.fine.inspect" + ) + var reviewed := _submit( + &"advance_authority_inspection", + {&"inspection_id": &"inspection.runtime.fine", &"context": {}}, + &"s5.fine.review" + ) + var authority_before_failure: Dictionary = _runtime.authority_service.create_snapshot() + var economy_before_failure: Dictionary = _runtime.economy_service.create_snapshot() + var rejected := _submit( + &"resolve_authority_inspection", + { + &"inspection_id": &"inspection.runtime.fine", + &"resolution_id": &"resolution.runtime.unfunded", + &"options": {&"fine_per_point": 100}, + }, + &"s5.fine.resolve.unfunded" + ) + var rejection_atomic: bool = not rejected.is_success() \ + and _runtime.authority_service.create_snapshot() == authority_before_failure \ + and _runtime.economy_service.create_snapshot() == economy_before_failure + var resolved := _submit( + &"resolve_authority_inspection", + { + &"inspection_id": &"inspection.runtime.fine", + &"resolution_id": &"resolution.runtime.once", + &"options": {&"fine_per_point": 10}, + }, + &"s5.fine.resolve.once" + ) + var duplicate := _submit( + &"resolve_authority_inspection", + { + &"inspection_id": &"inspection.runtime.fine", + &"resolution_id": &"resolution.runtime.once", + &"options": {&"fine_per_point": 999}, + }, + &"s5.fine.resolve.retry" + ) + var departed := _submit( + &"depart_authority_inspection", + {&"inspection_id": &"inspection.runtime.fine"}, + &"s5.fine.depart" + ) + var fine_entries: Array = _runtime.economy_service.get_entries().filter( + func(entry: LedgerEntry) -> bool: return entry.category == &"AUTHORITY_FINE" + ) + var incident: AuthorityIncident = _runtime.authority_service.get_incident(&"incident.runtime.fine") + var inspection: AuthorityInspection = _runtime.authority_service.get_inspection(&"inspection.runtime.fine") + _expect(inspected.is_success(), "reached waypoint discovers only the supplied Evidence IDs") + _expect( + reviewed.is_success() + and reviewed.details.get(&"new_state", &"") == &"REVIEWING" + and inspection != null, + "completed patrol enters REVIEWING before resolution" + ) + _expect(rejection_atomic, "unfunded Authority fine leaves Authority and Economy snapshots unchanged") + _expect( + resolved.is_success() and resolved.details.get(&"fine_amount", -1) == 600 + and resolved.details.get(&"sanction", &"") == &"TEMPORARY_CLOSURE", + "evidence score projects the deterministic fine and sanction" + ) + _expect( + duplicate.is_success() and duplicate.is_replay + and duplicate.details.get(&"fine_amount", -1) == 600, + "duplicate resolution_id replays the canonical outcome" + ) + _expect( + fine_entries.size() == 1 + and _runtime.economy_service.get_balance(AUTHORITY_FINE_EXPENSE) == 600 + and _runtime.economy_service.get_balance(PLAYER_CASH) == 4_400 + and _state.get_resource(&"gold") == 4_400, + "fine posts exactly once to the ledger and synchronized Gold mirror" + ) + _expect( + incident != null and incident.state == AuthorityIncident.State.RESOLVED + and departed.is_success() and inspection.state == AuthorityInspection.State.CLOSED, + "successful resolution closes its Incident and inspection lifecycle" + ) + _expect( + _count_domain_events(&"INSPECTION_RESOLVED", &"inspection.runtime.fine") == 1, + "resolution replay emits no duplicate INSPECTION_RESOLVED event" + ) + return { + &"ok": inspected.is_success() + and reviewed.is_success() + and rejection_atomic + and resolved.is_success() + and duplicate.is_success() + and departed.is_success(), + } + + +func _run_unreachable_inspection() -> bool: + var inspection_id := &"inspection.runtime.blocked" + var target := Vector3i(0, 1, WorldService.LAYER_FRONT) + var scheduled := _submit( + &"schedule_authority_inspection", + {&"options": { + &"inspection_id": inspection_id, + &"inspector_id": &"inspector.runtime.blocked", + &"entrance_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + }}, + &"s5.blocked.schedule" + ) + var started := _submit( + &"start_authority_inspection", + {&"inspection_id": inspection_id}, + &"s5.blocked.start" + ) + var arrived := _submit( + &"arrive_authority_inspection", + {&"inspection_id": inspection_id}, + &"s5.blocked.arrive" + ) + var planned := _submit( + &"plan_authority_inspection", + { + &"inspection_id": inspection_id, + &"candidates": [{ + &"waypoint_id": &"waypoint.sealed", + &"cell": target, + &"suspect_score": 90, + &"evidence_potential": 90, + &"travel_minutes": 5, + &"inspection_minutes": 5, + }], + }, + &"s5.blocked.plan" + ) + var blocked := _submit( + &"advance_authority_inspection", + {&"inspection_id": inspection_id, &"context": {}}, + &"s5.blocked.advance" + ) + var inspection: AuthorityInspection = _runtime.authority_service.get_inspection(inspection_id) + var safe_departure: bool = blocked.is_success() \ + and not bool(blocked.details.get(&"reachable", true)) \ + and inspection != null \ + and inspection.state == AuthorityInspection.State.DEPARTING \ + and inspection.exit_reason == &"PATH_UNREACHABLE" \ + and inspection.visited_waypoint_ids.is_empty() \ + and inspection.inaccessible_waypoint_ids == [&"waypoint.sealed"] \ + and not inspection.result_reason.is_empty() \ + and _count_domain_events(&"INSPECTION_PATH_BLOCKED", inspection_id) == 1 + var departed := _submit( + &"depart_authority_inspection", + {&"inspection_id": inspection_id}, + &"s5.blocked.depart" + ) + return scheduled.is_success() \ + and started.is_success() \ + and arrived.is_success() \ + and planned.is_success() \ + and safe_departure \ + and departed.is_success() \ + and inspection.state == AuthorityInspection.State.CLOSED + + +func _submit(command_type: StringName, payload: Dictionary, idempotency_key: StringName) -> CommandResult: + return _runtime.submit_command( + command_type, + payload, + &"authority_controller", + &"", + 80, + -1, + idempotency_key + ) + + +func _evaluation_for_day(day_index: int) -> Dictionary: + var snapshot: Dictionary = _runtime.authority_service.create_snapshot() + for value: Variant in snapshot[&"evaluated_inspection_days"]: + if value is Dictionary and int(value.get(&"day_index", -1)) == day_index: + return (value[&"result"] as Dictionary).duplicate(true) + return {} + + +func _count_domain_events(event_type: StringName, entity_id: StringName = &"") -> int: + var count := 0 + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type != event_type: + continue + if entity_id.is_empty() or entity_id in event.entity_ids: + count += 1 + return count + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s5/test_runtime_authority_cycle.gd.uid b/tests/s5/test_runtime_authority_cycle.gd.uid new file mode 100644 index 0000000..2fda2be --- /dev/null +++ b/tests/s5/test_runtime_authority_cycle.gd.uid @@ -0,0 +1 @@ +uid://dxr8o0wmgfy0l diff --git a/tests/s5/test_runtime_defense_cycle.gd b/tests/s5/test_runtime_defense_cycle.gd new file mode 100644 index 0000000..c1f7249 --- /dev/null +++ b/tests/s5/test_runtime_defense_cycle.gd @@ -0,0 +1,1024 @@ +extends SceneTree + +const PLAYER_CASH := &"PLAYER_CASH" + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant +var _command_sequence := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S5: integrated Defense runtime cycle") + _test_all_commands_multiwave_and_terminal_replay() + _test_scheduled_command_time_alignment() + _test_manual_retreat_cancel_and_long_horizon() + _test_rng_skill_and_outcome_atomicity() + _test_cross_raid_locks() + _test_snapshot_replay_corruption_and_legacy_migration() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _reset_runtime(seed: int, hour := 6, minute := 0) -> void: + _cleanup() + _command_sequence = 0 + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, hour, minute) + _state.reset() + var report: Dictionary = _runtime.initialize(_clock, _state, seed) + _clock.set_paused(true) + _expect(report[&"valid"], "runtime initializes with the Defense aggregate") + _expect( + _runtime.defense_service.current_minute == _clock.absolute_minute, + "Defense time begins aligned with SimulationKernel" + ) + _expect( + _runtime.get_rng_stream(&"defense") != null + and _runtime.get_rng_stream(&"defense").get_stream_id() == &"defense", + "runtime owns a dedicated Defense RNG stream" + ) + + +func _cleanup() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _test_all_commands_multiwave_and_terminal_replay() -> void: + _reset_runtime(7_101) + _create_staff_and_facility(&"guard.multi", &"objective.multi", 94, 12, 9_750) + var created := _submit(&"create_defense_raid", { + &"raid_id": &"raid.multi", + &"definition": _raid_definition(&"objective.multi", [ + _wave(&"wave.alpha", 15, 1, [&"gate.north"]), + _wave(&"wave.beta", 20, 1, [&"gate.south"]), + ]), + }) + var defender := _submit(&"register_defense_combatant", { + &"raid_id": &"raid.multi", + &"actor_id": &"guard.multi", + &"side": &"defender", + &"combat": 90, + &"max_health": 100, + &"options": {&"armor": 3}, + }) + var attacker_alpha := _submit(&"register_defense_combatant", { + &"raid_id": &"raid.multi", + &"actor_id": &"raider.alpha", + &"side": &"ATTACKER", + &"combat": 15, + &"max_health": 20, + &"options": { + &"wave_id": &"wave.alpha", + &"entry_point_id": &"gate.north", + }, + }) + var attacker_beta := _submit(&"register_defense_combatant", { + &"raid_id": &"raid.multi", + &"actor_id": &"raider.beta", + &"side": &"ATTACKER", + &"combat": 20, + &"max_health": 20, + &"options": { + &"wave_id": &"wave.beta", + &"entry_point_id": &"gate.south", + }, + }) + var asset := _submit(&"register_defense_asset", { + &"raid_id": &"raid.multi", + &"asset_id": &"objective.multi", + &"kind": &"objective", + &"max_integrity": 10_000, + &"defense": 40, + &"options": {&"target_order": 1, &"armor": 2}, + }) + var policy := _submit(&"set_defense_retreat_policy", { + &"raid_id": &"raid.multi", + &"policy": &"threshold", + &"threshold": 5, + }) + var started := _submit(&"start_defense_raid", {&"raid_id": &"raid.multi"}) + var skill := _submit(&"queue_defense_skill", { + &"raid_id": &"raid.multi", + &"action_id": &"skill.multi.rally", + &"actor_id": &"guard.multi", + &"kind": &"rally", + &"options": {&"potency": 7, &"metadata": {&"label": "free"}}, + }) + _expect( + created.is_success() and defender.is_success() and attacker_alpha.is_success() + and attacker_beta.is_success() and asset.is_success() and policy.is_success() + and started.is_success() and skill.is_success(), + "create, register, policy, start and skill Commands complete through Runtime" + ) + _expect( + created.command_id > 0 and created.target_id == &"raid.multi" + and defender.command_id > created.command_id, + "Defense Command results preserve Runtime command_id and target conventions" + ) + var early := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.early", + }) + _expect(not early.is_success(), "a round before its configured interval is rejected") + _advance(5) + var first_round := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.1", + }) + var after_first: Dictionary = _runtime.defense_service.get_raid(&"raid.multi") + _expect( + first_round.is_success() + and int(after_first[&"state"]) == DefenseService.RaidState.BETWEEN_WAVES + and int(after_first[&"current_round"]) == 1, + "first max-round Wave resolves and waits for an explicit transition" + ) + _expect( + _runtime.facility_service.get_instance(&"objective.multi").durability == 9_750, + "non-terminal round damage stays inside Defense until terminal reconciliation" + ) + var next_wave := _submit(&"begin_next_defense_wave", {&"raid_id": &"raid.multi"}) + _expect( + next_wave.is_success() + and int(_runtime.defense_service.get_raid(&"raid.multi")[&"state"]) == DefenseService.RaidState.ACTIVE, + "begin_next_defense_wave is the required explicit Wave transition" + ) + var same_minute := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.same-minute", + }) + _expect(not same_minute.is_success(), "a different round ID cannot resolve again in the same minute") + _advance(5) + var terminal := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2", + }) + var terminal_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.multi") + var linked_staff: StaffRuntime = _runtime.staff_service.get_staff(&"guard.multi") + var linked_facility: FacilityInstance = _runtime.facility_service.get_instance(&"objective.multi") + _expect( + terminal.is_success() + and int(terminal_raid[&"state"]) == DefenseService.RaidState.DEFENDED, + "second Wave reaches a terminal Raid result through resolve_defense_round" + ) + _expect( + linked_staff.health == int(terminal_raid[&"combatants"][&"guard.multi"][&"health"]) + and linked_staff.fatigue == int(terminal_raid[&"combatants"][&"guard.multi"][&"fatigue"]) + and linked_staff.state == StaffRuntime.State.AVAILABLE, + "terminal combatant outcome reconciles Staff health, fatigue and state" + ) + _expect( + linked_facility.durability == int(terminal_raid[&"assets"][&"objective.multi"][&"integrity"]), + "terminal asset outcome reconciles Facility durability" + ) + var rng_after: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var staff_after: Dictionary = _runtime.staff_service.create_snapshot() + var facility_after: Dictionary = _runtime.facility_service.create_snapshot() + var resolved_events := _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.multi") + var replay := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2", + }) + _expect(replay.is_success() and replay.is_replay, "duplicate round resolution replays its canonical result") + _expect( + _runtime.get_rng_stream(&"defense").snapshot() == rng_after + and _runtime.staff_service.create_snapshot() == staff_after + and _runtime.facility_service.create_snapshot() == facility_after, + "resolution replay does not consume RNG or reapply external outcomes" + ) + _expect( + _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.multi") == resolved_events, + "resolution replay emits no duplicate Defense resolved event" + ) + var wrong_operation := _submit(&"retreat_defenders", { + &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2", + }) + _expect(not wrong_operation.is_success(), "an explicit resolution ID cannot cross operation namespaces") + _expect( + _defense_terminal_event_order(&"raid.multi", &"round.multi.2"), + "terminal Defense DomainEvents are structured and emitted in causal order" + ) + + +func _test_manual_retreat_cancel_and_long_horizon() -> void: + _reset_runtime(7_202) + _create_staff_and_facility(&"guard.retreat", &"objective.retreat") + _prepare_started_raid( + &"raid.retreat", &"guard.retreat", &"objective.retreat", + [_wave(&"wave.retreat", 150, 8, [&"gate.north"])] + ) + var retreat := _submit(&"retreat_defenders", {&"raid_id": &"raid.retreat"}) + _expect( + retreat.is_success() + and String(retreat.details.get(&"resolution_id", "")).begins_with("defense_retreat_resolution.") + and int(_runtime.defense_service.get_raid(&"raid.retreat")[&"state"]) + == DefenseService.RaidState.DEFENDERS_RETREATED, + "manual retreat uses its stable operation-specific default resolution ID" + ) + _expect( + _runtime.staff_service.get_staff(&"guard.retreat").state == StaffRuntime.State.FLEEING, + "manual retreat projects the Defender state to Staff" + ) + + _create_staff_and_facility(&"guard.cancel", &"objective.cancel", 100, 3, 9_900) + _prepare_started_raid( + &"raid.cancel", &"guard.cancel", &"objective.cancel", + [_wave(&"wave.cancel", 600, 10, [&"gate.north"])], + 25, + 0, + {&"target_order": 1} + ) + _advance(5) + var partial := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.cancel", &"resolution_id": &"round.cancel.partial", + }) + var partial_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.cancel") + _expect( + partial.is_success() + and int(partial_raid[&"state"]) == DefenseService.RaidState.ACTIVE, + "a long Wave can retain accumulated round state before cancellation" + ) + _expect( + _runtime.facility_service.get_instance(&"objective.cancel").durability == 9_900, + "accumulated active-Raid damage is not prematurely projected" + ) + var cancel := _submit(&"cancel_defense_raid", {&"raid_id": &"raid.cancel"}) + var cancelled_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.cancel") + _expect( + cancel.is_success() + and String(cancel.details.get(&"resolution_id", "")).begins_with("defense_cancel_resolution.") + and int(cancelled_raid[&"state"]) == DefenseService.RaidState.CANCELLED, + "active Raid cancellation uses the terminal transaction boundary" + ) + _expect( + _runtime.staff_service.get_staff(&"guard.cancel").health + == int(cancelled_raid[&"combatants"][&"guard.cancel"][&"health"]) + and _runtime.facility_service.get_instance(&"objective.cancel").durability + == int(cancelled_raid[&"assets"][&"objective.cancel"][&"integrity"]), + "cancel reconciles all damage accumulated in earlier rounds" + ) + + _create_staff_and_facility(&"guard.long", &"objective.long") + var long_definition := _raid_definition( + &"objective.long", + [_wave(&"wave.long", 20_000, 400, [&"gate.north"])], + 5, + 5 + ) + _prepare_started_raid( + &"raid.long", &"guard.long", &"objective.long", + long_definition[&"waves"], 1, 0, {}, 100, long_definition + ) + var long_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + _advance(1_440) + var long_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.long") + _expect( + _runtime.defense_service.current_minute == _clock.absolute_minute + and int(long_raid[&"current_round"]) == 0 + and int(long_raid[&"state"]) == DefenseService.RaidState.ACTIVE, + "24-hour ticking advances Defense time without automatically resolving rounds" + ) + _expect( + _runtime.get_rng_stream(&"defense").snapshot() == long_rng_before, + "Defense ticking alone consumes no round RNG" + ) + var duration_terminal := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.long", &"resolution_id": &"round.long.duration", + }) + _expect( + duration_terminal.is_success() + and int(_runtime.defense_service.get_raid(&"raid.long")[&"state"]) + == DefenseService.RaidState.DEFENDED + and duration_terminal.details.get(&"reason", &"") == &"RAID_DURATION_EXHAUSTED", + "the next explicit round applies max-duration terminal behavior" + ) + + _create_staff_and_facility(&"guard.breach", &"objective.breach", 100, 0, 1) + _prepare_started_raid( + &"raid.breach", &"guard.breach", &"objective.breach", + [_wave(&"wave.breach", 1_000, 1, [&"gate.north"])], + 0, + 0, + {&"target_order": 1}, + 1_000 + ) + _advance(5) + var breached := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.breach", &"resolution_id": &"round.breach.terminal", + }) + _expect( + breached.is_success() + and int(_runtime.defense_service.get_raid(&"raid.breach")[&"state"]) + == DefenseService.RaidState.BREACHED + and _runtime.facility_service.get_instance(&"objective.breach").durability == 0 + and _runtime.facility_service.get_instance(&"objective.breach").state + == FacilityInstance.State.BROKEN, + "destroyed Defense objective atomically projects Facility durability and state" + ) + + +func _test_scheduled_command_time_alignment() -> void: + _reset_runtime(7_202) + _create_staff_and_facility(&"guard.scheduled", &"objective.scheduled") + _prepare_started_raid( + &"raid.scheduled", &"guard.scheduled", &"objective.scheduled", + [_wave(&"wave.scheduled", 1, 1, [&"gate.north"])], + 100, + 100, + {&"target_order": 1}, + 0 + ) + var execute_at: int = int(_clock.absolute_minute) + 5 + var enqueued: CommandResult = _runtime.submit_command( + &"resolve_defense_round", + {&"raid_id": &"raid.scheduled", &"resolution_id": &"round.scheduled.1"}, + &"defense_controller", + &"raid.scheduled", + 80, + execute_at, + &"runtime.defense.scheduled.round" + ) + _clock.set_paused(false) + _clock.advance_minutes(4) + _expect( + enqueued.is_success() + and _runtime.command_queue.get_processed_result(enqueued.command_id) == null + and _runtime.defense_service.current_minute == execute_at - 1, + "scheduled Defense round remains pending until its exact configured minute" + ) + _clock.advance_minutes(1) + _clock.set_paused(true) + var completed: CommandResult = _runtime.command_queue.get_processed_result(enqueued.command_id) + _expect( + completed != null and completed.is_success() + and int(completed.details.get(&"absolute_minute", -1)) == execute_at + and _clock.absolute_minute == execute_at + and _runtime.defense_service.current_minute == execute_at, + "scheduled Defense command resolves against the same absolute minute as the completed tick" + ) + var event_minutes: Dictionary = {} + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if &"raid.scheduled" not in event.entity_ids: + continue + if event.event_type in [ + &"DEFENSE_ROUND_RESOLVED", + &"DEFENSE_RAID_STATE_CHANGED", + &"DEFENSE_RAID_RESOLVED", + &"COMMAND_COMPLETED", + ]: + event_minutes[event.event_type] = event.absolute_minute + _expect( + event_minutes.size() == 4 + and event_minutes.values().all(func(value: Variant) -> bool: return int(value) == execute_at), + "scheduled Defense outcome and Command DomainEvents share the canonical execution minute" + ) + + +func _test_rng_skill_and_outcome_atomicity() -> void: + _reset_runtime(7_303) + _create_staff_and_facility(&"guard.atomic", &"objective.atomic", 100, 4, 9_800) + _expect( + bool(_runtime.inventory_service.add_stack( + &"defense.inventory", &"mana_bomb", 5, 100, -1, _clock.absolute_minute, 1, &"stack.atomic" + ).get(&"ok", false)), + "skill-cost Inventory fixture is available" + ) + _prepare_started_raid( + &"raid.atomic", &"guard.atomic", &"objective.atomic", + [_wave(&"wave.atomic", 500, 1, [&"gate.north"])], + 10, + 0, + {&"target_order": 1} + ) + var queued := _submit(&"queue_defense_skill", { + &"raid_id": &"raid.atomic", + &"actor_id": &"guard.atomic", + &"kind": &"POWER_STRIKE", + &"options": { + &"potency": 10, + &"metadata": {&"cost": { + &"gold": 125, + &"mana": 9, + &"items": [{ + &"inventory_id": &"defense.inventory", + &"item_id": &"mana_bomb", + &"quantity": 2, + }], + }}, + }, + }) + _expect( + queued.is_success() + and String(queued.details.get(&"action_id", "")).begins_with("defense_skill_action."), + "queued skill receives a stable generated action namespace" + ) + _advance(5) + var defense_before: Dictionary = _runtime.defense_service.create_snapshot() + var defense_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var authority_rng_before: Dictionary = _runtime.get_rng_stream(&"authority").snapshot() + var economy_before: Dictionary = _runtime.economy_service.create_snapshot() + var state_before: Dictionary = _state.create_snapshot() + var inventory_before: Dictionary = _runtime.inventory_service.create_snapshot() + var staff_before: Dictionary = _runtime.staff_service.create_snapshot() + var facilities_before: Dictionary = _runtime.facility_service.create_snapshot() + var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng + expected_rng.restore(defense_rng_before) + var expected_defense_roll := expected_rng.randi_range(0, 100) + var expected_attack_roll := expected_rng.randi_range(0, 100) + var failure_switch := {&"armed": true} + var runtime_outcome := Callable(_runtime, "_apply_defense_outcome_boundary") + var failing_outcome := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + var report: Dictionary = runtime_outcome.call(payload, phase, token) + if phase == &"apply" and bool(report.get(&"ok", false)) and bool(failure_switch[&"armed"]): + failure_switch[&"armed"] = false + report = report.duplicate(true) + report[&"ok"] = false + report[&"error"] = "Injected post-apply outcome failure." + return report + _runtime.defense_service.configure_callbacks( + Callable(_runtime, "_roll_defense_round"), + Callable(_runtime, "_apply_defense_skill_boundary"), + failing_outcome, + Callable(_runtime, "_apply_defense_rng_boundary") + ) + var failed := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.atomic", &"resolution_id": &"round.atomic.retry", + }) + _expect(not failed.is_success(), "an injected post-apply terminal failure rejects the round") + _expect( + _runtime.defense_service.create_snapshot() == defense_before + and _runtime.get_rng_stream(&"defense").snapshot() == defense_rng_before + and _runtime.get_rng_stream(&"authority").snapshot() == authority_rng_before, + "failed outcome restores Defense state and its RNG without touching other streams" + ) + _expect( + _runtime.economy_service.create_snapshot() == economy_before + and _state.create_snapshot() == state_before + and _runtime.inventory_service.create_snapshot() == inventory_before, + "failed outcome restores Gold, Mana, item reservations and consumption" + ) + _expect( + _runtime.staff_service.create_snapshot() == staff_before + and _runtime.facility_service.create_snapshot() == facilities_before, + "failed outcome restores Staff and Facility changes atomically" + ) + _expect( + _count_domain_events(&"LEDGER_TRANSACTION_POSTED") == 0 + and _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.atomic") == 0, + "rolled-back skill and outcome effects leave no committed success events" + ) + _runtime._configure_defense_boundaries() + var retried := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.atomic", &"resolution_id": &"round.atomic.retry", + }) + _expect( + retried.is_success() + and int(retried.details[&"defense_roll"]) == expected_defense_roll + and int(retried.details[&"attack_roll"]) == expected_attack_roll, + "retry consumes the exact rolls predicted before the failed transaction" + ) + _expect( + _runtime.economy_service.get_balance(PLAYER_CASH) == 5_000 - 125 + and _state.get_resource(&"gold") == 5_000 - 125 + and _state.get_resource(&"mana") == 100 - 9 + and _runtime.inventory_service.available_quantity( + &"defense.inventory", &"mana_bomb", _clock.absolute_minute + ) == 3, + "successful retry commits explicit Gold, Mana and item costs once" + ) + _expect( + _runtime.get_rng_stream(&"authority").snapshot() == authority_rng_before + and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), + "successful round advances only the Defense RNG to its exact predicted state" + ) + + _reset_runtime(7_304) + _create_staff_and_facility(&"guard.reserve", &"objective.reserve") + _expect( + bool(_runtime.inventory_service.add_stack( + &"reserve.inventory", &"a_available", 1, 100, -1, + _clock.absolute_minute, 1, &"stack.reserve.available" + ).get(&"ok", false)), + "partial-reservation rollback fixture is available" + ) + _prepare_started_raid( + &"raid.reserve", &"guard.reserve", &"objective.reserve", + [_wave(&"wave.reserve", 100, 1, [&"gate.north"])] + ) + var reservation_skill := _submit(&"queue_defense_skill", { + &"raid_id": &"raid.reserve", + &"action_id": &"skill.reserve.partial", + &"actor_id": &"guard.reserve", + &"kind": &"RALLY", + &"options": {&"potency": 1, &"metadata": {&"cost": {&"items": [ + {&"inventory_id": &"reserve.inventory", &"item_id": &"a_available", &"quantity": 1}, + {&"inventory_id": &"reserve.inventory", &"item_id": &"z_missing", &"quantity": 1}, + ]}}}, + }) + _expect(reservation_skill.is_success(), "multi-item skill cost queues before boundary validation") + _advance(5) + var reserve_defense_before: Dictionary = _runtime.defense_service.create_snapshot() + var reserve_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var reserve_inventory_before: Dictionary = _runtime.inventory_service.create_snapshot() + var reserve_economy_before: Dictionary = _runtime.economy_service.create_snapshot() + var reserve_state_before: Dictionary = _state.create_snapshot() + var reservation_failed := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.reserve", &"resolution_id": &"round.reserve.failed", + }) + _expect(not reservation_failed.is_success(), "a later unavailable item rejects the whole skill-cost batch") + _expect( + _runtime.inventory_service.create_snapshot() == reserve_inventory_before + and _runtime.economy_service.create_snapshot() == reserve_economy_before + and _state.create_snapshot() == reserve_state_before, + "partial item reservation failure rolls every external resource back" + ) + _expect( + _runtime.defense_service.create_snapshot() == reserve_defense_before + and _runtime.get_rng_stream(&"defense").snapshot() == reserve_rng_before, + "skill validation failure leaves Defense and round RNG untouched" + ) + + +func _test_cross_raid_locks() -> void: + _reset_runtime(7_404) + _create_staff_and_facility(&"guard.lock", &"objective.lock") + for raid_id: StringName in [&"raid.lock.a", &"raid.lock.b"]: + var created := _submit(&"create_defense_raid", { + &"raid_id": raid_id, + &"definition": _raid_definition( + &"objective.lock", [_wave(StringName("wave.%s" % raid_id), 50, 4, [&"gate.north"])] + ), + }) + _expect(created.is_success(), "lock fixture Raid is created: %s" % raid_id) + var mismatched_defender := _submit(&"register_defense_combatant", { + &"raid_id": &"raid.lock.a", + &"actor_id": &"guard.lock", + &"side": &"DEFENDER", + &"combat": 50, + &"max_health": 100, + &"options": {&"health": 99}, + }) + var mismatched_asset := _submit(&"register_defense_asset", { + &"raid_id": &"raid.lock.a", + &"asset_id": &"objective.lock", + &"kind": &"OBJECTIVE", + &"max_integrity": 9_999, + &"defense": 20, + &"options": {}, + }) + _expect( + not mismatched_defender.is_success() and not mismatched_asset.is_success(), + "registration rejects values that differ from Staff and Facility baselines" + ) + var first_defender := _register_defender(&"raid.lock.a", &"guard.lock", 50) + var first_asset := _register_objective(&"raid.lock.a", &"objective.lock", 20) + var second_defender := _register_defender(&"raid.lock.b", &"guard.lock", 50) + var second_asset := _register_objective(&"raid.lock.b", &"objective.lock", 20) + _expect(first_defender.is_success() and first_asset.is_success(), "first non-terminal Raid acquires Staff and Facility locks") + _expect( + not second_defender.is_success() and not second_asset.is_success(), + "a second non-terminal Raid cannot reuse locked Defender or Facility IDs" + ) + var demolish := _submit(&"demolish_facility", {&"instance_id": &"objective.lock"}) + _expect(not demolish.is_success(), "a Defense-locked Facility cannot be demolished") + var cancelled := _submit(&"cancel_defense_raid", { + &"raid_id": &"raid.lock.a", &"resolution_id": &"cancel.lock.release", + }) + _expect(cancelled.is_success(), "terminal cancellation releases derived Raid locks") + second_defender = _register_defender(&"raid.lock.b", &"guard.lock", 50) + second_asset = _register_objective(&"raid.lock.b", &"objective.lock", 20) + _expect( + second_defender.is_success() and second_asset.is_success(), + "released locks are deterministically acquired by the remaining Raid" + ) + var snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "snapshot restore rebuilds Defense locks without a mutable lock table") + var health_corruption: Dictionary = snapshot.duplicate(true) + var health_raids: Array = health_corruption[&"systems"][&"runtime"][&"defense"][&"raids"] + for raw_raid: Variant in health_raids: + if String(raw_raid.get(&"raid_id", "")) != "raid.lock.b": + continue + for raw_combatant: Variant in raw_raid[&"combatants"]: + if String(raw_combatant.get(&"actor_id", "")) == "guard.lock": + raw_combatant[&"health"] = 99 + _expect( + not _runtime.restore_snapshot(health_corruption) + and _runtime.create_snapshot() == snapshot, + "restore rejects a PREPARING Defender health baseline mismatch atomically" + ) + var integrity_corruption: Dictionary = snapshot.duplicate(true) + var integrity_raids: Array = integrity_corruption[&"systems"][&"runtime"][&"defense"][&"raids"] + for raw_raid: Variant in integrity_raids: + if String(raw_raid.get(&"raid_id", "")) != "raid.lock.b": + continue + for raw_asset: Variant in raw_raid[&"assets"]: + if String(raw_asset.get(&"asset_id", "")) == "objective.lock": + raw_asset[&"integrity"] = 9_999 + _expect( + not _runtime.restore_snapshot(integrity_corruption) + and _runtime.create_snapshot() == snapshot, + "restore rejects a PREPARING Facility integrity baseline mismatch atomically" + ) + var duplicate_after_restore := _submit(&"create_defense_raid", { + &"raid_id": &"raid.lock.c", + &"definition": _raid_definition( + &"objective.lock", [_wave(&"wave.lock.c", 30, 3, [&"gate.north"])] + ), + }) + var locked_after_restore := _register_defender(&"raid.lock.c", &"guard.lock", 20) + _expect( + duplicate_after_restore.is_success() and not locked_after_restore.is_success(), + "restored non-terminal ownership continues to enforce Staff exclusivity" + ) + var second_cancel := _submit(&"cancel_defense_raid", { + &"raid_id": &"raid.lock.b", &"resolution_id": &"cancel.lock.second", + }) + _runtime.staff_service.get_staff(&"guard.lock").fatigue = 17 + var repaired: CommandResult = _runtime.facility_service.set_operational_status( + &"objective.lock", {&"durability": 9_850}, _clock.absolute_minute + ) + var reused_defender := _register_defender(&"raid.lock.c", &"guard.lock", 30) + var reused_asset := _register_objective(&"raid.lock.c", &"objective.lock", 10) + var evolved_snapshot: Dictionary = _runtime.create_snapshot() + _expect( + second_cancel.is_success() and repaired.is_success() + and reused_defender.is_success() and reused_asset.is_success(), + "terminal Raid history does not block later Staff reuse or Facility repair" + ) + _expect( + _runtime.restore_snapshot(evolved_snapshot), + "terminal outcomes remain historical while the newer live ownership restores" + ) + + +func _test_snapshot_replay_corruption_and_legacy_migration() -> void: + _reset_runtime(7_505) + _create_staff_and_facility(&"guard.save", &"objective.save", 97, 6, 9_600) + _prepare_started_raid( + &"raid.save", &"guard.save", &"objective.save", + [_wave(&"wave.save", 2_000, 5, [&"gate.north"])], + 80, + 0, + {&"target_order": 1} + ) + _advance(5) + var first := _submit(&"resolve_defense_round", { + &"raid_id": &"raid.save", &"resolution_id": &"round.save.1", + }) + _expect( + first.is_success() + and int(_runtime.defense_service.get_raid(&"raid.save")[&"state"]) + == DefenseService.RaidState.ACTIVE, + "mid-Raid fixture preserves an active Wave after its first round" + ) + var queued := _submit(&"queue_defense_skill", { + &"raid_id": &"raid.save", + &"action_id": &"skill.save.second", + &"actor_id": &"guard.save", + &"kind": &"FORTIFY", + &"options": { + &"potency": 8, + &"target_id": &"objective.save", + &"metadata": {&"cost": {&"gold": 0, &"mana": 0}}, + }, + }) + _expect(queued.is_success(), "queued next-round skill is included in the mid-Raid snapshot") + var origin: Dictionary = _runtime.create_snapshot() + _advance(5) + var first_branch := _submit_with_key(&"resolve_defense_round", { + &"raid_id": &"raid.save", &"resolution_id": &"round.save.2", + }, &"runtime.defense.save.resolve") + var branch_snapshot: Dictionary = _runtime.create_snapshot() + _expect(first_branch.is_success(), "first post-save Defense branch resolves") + _expect(_runtime.restore_snapshot(origin), "mid-round Runtime snapshot restores atomically") + _advance(5) + var replay_branch := _submit_with_key(&"resolve_defense_round", { + &"raid_id": &"raid.save", &"resolution_id": &"round.save.2", + }, &"runtime.defense.save.resolve") + _expect( + replay_branch.is_success() and replay_branch.details == first_branch.details, + "restored mid-round branch reproduces the exact Defense resolution" + ) + _expect( + _runtime.create_snapshot() == branch_snapshot, + "save/load reproduces Defense, RNG, costs, events and command state exactly" + ) + var retiring_service: DefenseService = _runtime.defense_service + var detach_origin: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(detach_origin), "same-state restore replaces the public Defense aggregate") + var runtime_before_stale_mutation: Dictionary = _runtime.create_snapshot() + var ghost_created := retiring_service.create_raid( + &"raid.stale.ghost", + retiring_service.current_minute, + _raid_definition(&"objective.ghost", [_wave(&"wave.ghost", 10, 1, [&"gate.north"])]) + ) + retiring_service.advance_time(retiring_service.current_minute + 5) + var stale_round := retiring_service.resolve_next_round( + &"raid.save", &"round.save.stale", retiring_service.current_minute + ) + _expect( + ghost_created.is_success() and stale_round.is_success() + and _runtime.create_snapshot() == runtime_before_stale_mutation, + "retired Defense references cannot consume Runtime boundaries or emit ghost events" + ) + + var live_before_corruption: Dictionary = _runtime.create_snapshot() + var missing_current_defense: Dictionary = live_before_corruption.duplicate(true) + missing_current_defense[&"systems"][&"runtime"].erase(&"defense") + missing_current_defense[&"systems"][&"runtime"][&"rng_streams"].erase("defense") + _expect( + not _runtime.restore_snapshot(missing_current_defense) + and _runtime.create_snapshot() == live_before_corruption, + "current Runtime format requires Defense state and rejects silent data loss" + ) + var corrupt_minute: Dictionary = live_before_corruption.duplicate(true) + corrupt_minute[&"systems"][&"runtime"][&"defense"][&"current_minute"] = _clock.absolute_minute - 1 + _expect( + not _runtime.restore_snapshot(corrupt_minute) + and _runtime.create_snapshot() == live_before_corruption, + "corrupt Defense clock is rejected without changing any live Domain" + ) + var corrupt_rng_stream: Dictionary = live_before_corruption.duplicate(true) + corrupt_rng_stream[&"systems"][&"runtime"][&"rng_streams"]["defense"]["stream_id"] = "authority" + _expect( + not _runtime.restore_snapshot(corrupt_rng_stream) + and _runtime.create_snapshot() == live_before_corruption, + "Defense RNG stream identity mismatch is rejected atomically" + ) + var corrupt_rng_seed: Dictionary = live_before_corruption.duplicate(true) + corrupt_rng_seed[&"systems"][&"runtime"][&"rng_streams"]["defense"]["root_seed"] = 999_999 + _expect( + not _runtime.restore_snapshot(corrupt_rng_seed) + and _runtime.create_snapshot() == live_before_corruption, + "Defense RNG root-seed mismatch is rejected atomically" + ) + var corrupt_staff: Dictionary = live_before_corruption.duplicate(true) + var saved_staff: Array = corrupt_staff[&"systems"][&"runtime"][&"staff"][&"staff"] + for index: int in range(saved_staff.size()): + if String(saved_staff[index].get(&"staff_id", "")) == "guard.save": + saved_staff.remove_at(index) + break + _expect( + not _runtime.restore_snapshot(corrupt_staff) + and _runtime.create_snapshot() == live_before_corruption, + "missing external Staff crosslink rejects the snapshot atomically" + ) + var corrupt_facility: Dictionary = live_before_corruption.duplicate(true) + var saved_facilities: Array = corrupt_facility[&"systems"][&"runtime"][&"facilities"][&"instances"] + for index: int in range(saved_facilities.size()): + if String(saved_facilities[index].get(&"instance_id", "")) == "objective.save": + saved_facilities.remove_at(index) + break + _expect( + not _runtime.restore_snapshot(corrupt_facility) + and _runtime.create_snapshot() == live_before_corruption, + "missing external Facility crosslink rejects the snapshot atomically" + ) + + _reset_runtime(7_606, 9, 17) + var legacy: Dictionary = _runtime.create_snapshot() + legacy[&"systems"][&"runtime"].erase(&"runtime_schema_version") + legacy[&"systems"][&"runtime"].erase(&"operations") + legacy[&"systems"][&"runtime"].erase(&"defense") + legacy[&"systems"][&"runtime"][&"rng_streams"].erase("defense") + for infrastructure_key: StringName in [ + &"excavation", &"spatial_subslots", &"route_projection", + &"facility_infrastructure", + ]: + legacy[&"systems"][&"runtime"].erase(infrastructure_key) + _expect(_runtime.restore_snapshot(legacy), "pre-Defense Runtime snapshot migrates without synthetic Raid state") + _expect( + _runtime.defense_service.get_raid_ids().is_empty() + and _runtime.defense_service.current_minute == _clock.absolute_minute, + "legacy migration installs an empty clock-aligned Defense aggregate" + ) + var migrated_rng: DeterministicRng = _runtime.get_rng_stream(&"defense") + var expected_rng := DeterministicRng.from_root_seed(7_606, &"defense") as DeterministicRng + _expect( + migrated_rng.snapshot() == expected_rng.snapshot(), + "legacy migration derives the missing Defense RNG from the saved world seed" + ) + var after_legacy: Dictionary = _runtime.create_snapshot() + _expect( + after_legacy[&"systems"][&"runtime"].has(&"defense") + and after_legacy[&"systems"][&"runtime"][&"rng_streams"].has("defense"), + "the next current-format snapshot persists migrated Defense state" + ) + + +func _create_staff_and_facility( + staff_id: StringName, + facility_id: StringName, + health := 100, + fatigue := 0, + durability := 10_000 +) -> void: + var staff := _submit(&"register_staff", { + &"staff_id": staff_id, + &"role_id": &"security", + &"options": { + &"state": StaffRuntime.State.AVAILABLE, + &"health": health, + &"fatigue": fatigue, + &"skills": {&"combat": 100}, + }, + }) + var facility: CommandResult = _runtime.facility_service.create_instance({ + &"instance_id": facility_id, + &"definition_id": &"defense_fixture", + &"durability": durability, + &"created_at": _clock.absolute_minute, + }) + _expect(staff.is_success(), "external Staff fixture is registered: %s" % staff_id) + _expect(facility.is_success(), "external Facility fixture is registered: %s" % facility_id) + + +func _prepare_started_raid( + raid_id: StringName, + staff_id: StringName, + facility_id: StringName, + waves: Array, + defender_combat := 50, + asset_defense := 20, + asset_options: Dictionary = {}, + attacker_combat := 0, + definition_override: Dictionary = {} +) -> void: + var definition := definition_override.duplicate(true) if not definition_override.is_empty() \ + else _raid_definition(facility_id, waves) + var created := _submit(&"create_defense_raid", { + &"raid_id": raid_id, + &"definition": definition, + }) + var defender := _register_defender(raid_id, staff_id, defender_combat) + var asset := _register_objective(raid_id, facility_id, asset_defense, asset_options) + var wave_index := 0 + for wave_value: Variant in waves: + var wave: Dictionary = wave_value + var wave_entries: Array = wave.get(&"entry_points", [&"gate.north"]) + var attacker := _submit(&"register_defense_combatant", { + &"raid_id": raid_id, + &"actor_id": StringName("attacker.%s.%d" % [raid_id, wave_index]), + &"side": &"ATTACKER", + &"combat": attacker_combat, + &"max_health": 100, + &"options": { + &"wave_id": StringName(wave[&"wave_id"]), + &"entry_point_id": StringName(wave_entries[0]), + }, + }) + _expect(attacker.is_success(), "synthetic attacker is registered for %s" % String(wave[&"wave_id"])) + wave_index += 1 + var started := _submit(&"start_defense_raid", {&"raid_id": raid_id}) + _expect( + created.is_success() and defender.is_success() and asset.is_success() and started.is_success(), + "Raid fixture starts with validated external links: %s" % raid_id + ) + + +func _register_defender(raid_id: StringName, staff_id: StringName, combat: int) -> CommandResult: + return _submit(&"register_defense_combatant", { + &"raid_id": raid_id, + &"actor_id": staff_id, + &"side": &"DEFENDER", + &"combat": combat, + &"max_health": 100, + &"options": {&"armor": 2}, + }) + + +func _register_objective( + raid_id: StringName, + facility_id: StringName, + defense: int, + options: Dictionary = {} +) -> CommandResult: + return _submit(&"register_defense_asset", { + &"raid_id": raid_id, + &"asset_id": facility_id, + &"kind": &"OBJECTIVE", + &"max_integrity": 10_000, + &"defense": defense, + &"options": options.duplicate(true), + }) + + +func _raid_definition( + objective_id: StringName, + waves: Array, + round_minutes := 5, + max_duration_minutes := 180 +) -> Dictionary: + return { + &"source_id": &"runtime_test", + &"entry_points": [&"gate.north", &"gate.south"], + &"objective_ids": [objective_id], + &"waves": waves.duplicate(true), + &"round_minutes": round_minutes, + &"max_duration_minutes": max_duration_minutes, + &"retreat_policy": &"HOLD", + &"defender_retreat_threshold": 0, + } + + +func _wave( + wave_id: StringName, + threat_strength: int, + max_rounds: int, + entry_points: Array +) -> Dictionary: + return { + &"wave_id": wave_id, + &"threat_strength": threat_strength, + &"retreat_threshold": 0, + &"max_rounds": max_rounds, + &"entry_points": entry_points.duplicate(), + } + + +func _submit(command_type: StringName, payload: Dictionary) -> CommandResult: + _command_sequence += 1 + return _submit_with_key( + command_type, + payload, + StringName("runtime.defense.%04d" % _command_sequence) + ) + + +func _submit_with_key( + command_type: StringName, + payload: Dictionary, + idempotency_key: StringName +) -> CommandResult: + return _runtime.submit_command( + command_type, + payload, + &"defense_controller", + StringName(payload.get(&"raid_id", payload.get("raid_id", ""))), + 80, + -1, + idempotency_key + ) + + +func _advance(minutes: int) -> void: + _clock.advance_minutes(minutes) + _clock.set_paused(true) + + +func _count_domain_events(event_type: StringName, entity_id: StringName = &"") -> int: + var count := 0 + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type != event_type: + continue + if entity_id.is_empty() or entity_id in event.entity_ids: + count += 1 + return count + + +func _defense_terminal_event_order(raid_id: StringName, resolution_id: StringName) -> bool: + var ordered_types: Array[StringName] = [] + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if raid_id not in event.entity_ids: + continue + if event.event_type in [ + &"DEFENSE_SKILL_APPLIED", + &"DEFENSE_ROUND_RESOLVED", + &"DEFENSE_RAID_STATE_CHANGED", + &"DEFENSE_RAID_RESOLVED", + &"COMMAND_COMPLETED", + ]: + if event.event_type == &"DEFENSE_ROUND_RESOLVED" \ + and event.payload.get(&"resolution_id", &"") != resolution_id: + continue + ordered_types.append(event.event_type) + var round_index := ordered_types.rfind(&"DEFENSE_ROUND_RESOLVED") + var state_index := ordered_types.rfind(&"DEFENSE_RAID_STATE_CHANGED") + var resolved_index := ordered_types.rfind(&"DEFENSE_RAID_RESOLVED") + var command_index := ordered_types.rfind(&"COMMAND_COMPLETED") + return round_index >= 0 and state_index > round_index \ + and resolved_index > state_index and command_index > resolved_index + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s5/test_runtime_defense_cycle.gd.uid b/tests/s5/test_runtime_defense_cycle.gd.uid new file mode 100644 index 0000000..a867513 --- /dev/null +++ b/tests/s5/test_runtime_defense_cycle.gd.uid @@ -0,0 +1 @@ +uid://ct3xctiqfuurj diff --git a/tests/s6/test_performance_budget.gd b/tests/s6/test_performance_budget.gd new file mode 100644 index 0000000..bf6ea34 --- /dev/null +++ b/tests/s6/test_performance_budget.gd @@ -0,0 +1,86 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S6: performance budgets") + var profiler := SimulationPerformanceBudget.new(4) + _expect(not profiler.record_sample(&"", 10)[&"ok"], "empty metric ID is rejected") + _expect(not profiler.record_sample(&"path_query", -1)[&"ok"], "negative duration is rejected") + _expect(not profiler.set_budget(&"bad", 0), "non-positive budget is rejected") + _expect(profiler.set_budget(&"custom", 100, SimulationPerformanceBudget.Aggregation.P95), "custom budget registers") + + for value: int in [10, 20, 30, 40, 50]: + profiler.record_sample(&"custom", value) + var custom := profiler.get_report(&"custom") + _expect(custom[&"window_sample_count"] == 4, "sample window remains bounded") + _expect(custom[&"lifetime_sample_count"] == 5, "lifetime count survives window eviction") + _expect(custom[&"average_us"] == 30, "lifetime average uses every recorded sample") + _expect(custom[&"maximum_us"] == 50, "window maximum is exact") + _expect(custom[&"p95_us"] == 50, "nearest-rank P95 is deterministic") + _expect(not custom[&"budget_exceeded"], "measurement below limit passes") + + var exceeded: Array[StringName] = [] + profiler.budget_exceeded.connect(func(metric_id: StringName, _report: Dictionary) -> void: + exceeded.append(metric_id) + ) + profiler.record_sample(&"custom", 101) + _expect(profiler.get_report(&"custom")[&"budget_exceeded"], "P95 above limit fails") + _expect(exceeded == [&"custom"], "budget violation emits its exact metric once") + + profiler.record_simulation_tick(3_000, 1) + profiler.record_simulation_tick(11_000, 4) + _expect(profiler.get_report(&"simulation_x1")[&"limit_us"] == 4_000, "x1 uses the MVP average budget") + _expect(profiler.get_report(&"simulation_x4")[&"limit_us"] == 12_000, "x4 uses the MVP P95 budget") + _expect(not profiler.get_report(&"simulation_x4")[&"budget_exceeded"], "x4 sample within budget passes") + profiler.record_sample(&"path_query", 2_001) + _expect(profiler.get_report(&"path_query")[&"budget_exceeded"], "path P95 enforces two milliseconds") + + var reports := profiler.get_all_reports() + var ids: Array[String] = [] + for report: Dictionary in reports: + ids.append(String(report[&"metric_id"])) + var sorted_ids := ids.duplicate() + sorted_ids.sort() + _expect(ids == sorted_ids, "all reports are returned in stable metric order") + profiler.clear(&"custom") + _expect(profiler.get_report(&"custom")[&"lifetime_sample_count"] == 0, "single metric can be cleared") + profiler.clear() + _expect(profiler.get_report(&"path_query")[&"lifetime_sample_count"] == 0, "all diagnostic samples can be cleared") + + var clock = load("res://autoload/game_clock.gd").new() + clock.reset(1, 6, 0) + clock.process_real_delta(clock.REAL_SECONDS_PER_GAME_MINUTE) + _expect( + clock.performance_budget.get_report(&"simulation_x1")[&"lifetime_sample_count"] == 1, + "real-delta Kernel ticks feed the diagnostic profiler" + ) + _expect(not clock.get_performance_reports().is_empty(), "Kernel exposes read-only performance reports") + clock.reset(1, 6, 0) + clock.set_speed(4) + var accelerated_ticks = clock.process_real_delta(clock.REAL_SECONDS_PER_GAME_MINUTE) + _expect(accelerated_ticks == 4, "x4 process frame advances the expected four game-minute ticks") + _expect(clock.performance_budget.get_report(&"simulation_x4")[&"lifetime_sample_count"] == 1, "x4 performance measures the aggregate frame once rather than four isolated ticks") + _expect(clock.performance_budget.get_report(&"main_thread_stall")[&"lifetime_sample_count"] == 1, "active simulation frames feed the main-thread stall budget") + clock.free() + + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + else: + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s6/test_performance_budget.gd.uid b/tests/s6/test_performance_budget.gd.uid new file mode 100644 index 0000000..6b3d465 --- /dev/null +++ b/tests/s6/test_performance_budget.gd.uid @@ -0,0 +1 @@ +uid://c4bgfjyaobl8i diff --git a/tests/s6/test_runtime_save_lifecycle.gd b/tests/s6/test_runtime_save_lifecycle.gd new file mode 100644 index 0000000..14aed1e --- /dev/null +++ b/tests/s6/test_runtime_save_lifecycle.gd @@ -0,0 +1,697 @@ +extends SceneTree + +const TEST_PREFIX := "user://saves/s6_runtime_lifecycle" +const PRODUCTION_AUTOSAVE_PATH := "user://saves/autosave" + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S6: runtime save lifecycle and recovery safety") + await _test_autoload_isolation_and_main_opt_in() + _test_disabled_policy_and_path_timing() + _test_queued_checkpoint_at_tick_boundary() + _test_daily_checkpoint_dedup_generations_and_failure_preservation() + _test_exact_round_trip_recovery_pause_and_performance() + _test_domain_invalid_formal_falls_back_to_backup() + _test_multiple_domain_invalid_candidates_fall_back_to_autosave() + _test_all_domain_invalid_candidates_preserve_complete_live_state() + _test_content_filter_cannot_be_weakened() + _test_whitelisted_recovery_repairs() + _test_non_whitelisted_corruption_is_rejected_atomically() + _cleanup_runtime() + _cleanup_test_saves() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_autoload_isolation_and_main_opt_in() -> void: + var app_clock := root.get_node("GameClock") + var app_state := root.get_node("GameState") + var app_runtime := root.get_node("SimulationRuntime") + app_runtime.configure_save_policy({&"enabled": false}) + var default_generation := SaveService.get_autosave_path( + PRODUCTION_AUTOSAVE_PATH, + 0 + ) + var before := _package_fingerprint(default_generation) + app_clock.reset(2, 5, 59) + app_state.reset() + app_runtime.initialize(app_clock, app_state, 60_001) + _expect(not app_runtime.get_save_policy()[&"enabled"], "autoload test runtime starts with disk autosave disabled") + app_clock.advance_one_minute() + _expect(_package_fingerprint(default_generation) == before, "disabled autoload reaches 06:00 without touching the production autosave") + + var main := load("res://scenes/main/main.tscn").instantiate() as Control + root.add_child(main) + await process_frame + _expect( + app_runtime.get_save_policy()[&"enabled"] + and app_runtime.get_save_policy()[&"autosave_path"] == PRODUCTION_AUTOSAVE_PATH, + "playable main scene explicitly opts into the production autosave policy" + ) + app_runtime.configure_save_policy({&"enabled": false}) + main.queue_free() + await process_frame + + +func _test_disabled_policy_and_path_timing() -> void: + _reset_runtime(60_002, 1, 5, 59) + var path := TEST_PREFIX + "_disabled" + SaveService.delete_save(path) + _expect( + not _runtime.get_save_policy()[&"enabled"] + and _runtime.get_save_policy()[&"autosave_path"] == PRODUCTION_AUTOSAVE_PATH, + "direct Runtime instances remain isolated until explicitly enabled" + ) + _clock.advance_one_minute() + _expect( + not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(SaveService.get_autosave_path(path, 0))), + "disabled policy writes no implicit autosave" + ) + var before_count := int( + _clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"] + ) + _runtime.path_service.find_path( + Vector3i(-2, 0, WorldService.LAYER_FRONT), + Vector3i(2, 0, WorldService.LAYER_BACK), + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], + [&"staff"] + ) + var after_count := int( + _clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"] + ) + _expect(after_count == before_count + 1, "one public path query records one Runtime performance sample") + var snapshot = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "Runtime restores before reconnecting the Path timing boundary") + before_count = int(_clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"]) + _runtime.path_service.find_path(Vector3i(-1, 0, 0), Vector3i(1, 0, 0)) + after_count = int(_clock.performance_budget.get_report(&"path_query")[&"lifetime_sample_count"]) + _expect(after_count == before_count + 1, "restore reconnects Path timing without duplicate signal delivery") + + +func _test_queued_checkpoint_at_tick_boundary() -> void: + _reset_runtime(60_003) + var path := TEST_PREFIX + "_queued" + SaveService.delete_save(path) + var queued = _runtime.queue_manual_checkpoint(path, {&"display_name": "Queued Runtime"}) + _expect(queued[&"ok"] and _runtime.get_pending_checkpoint_count() == 1, "manual checkpoint is queued for the next fixed tick boundary") + _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(path)), "queued checkpoint does not write before its boundary") + _clock.advance_one_minute() + var loaded := SaveService.load_snapshot(path) + _expect(loaded[&"ok"] and _runtime.get_pending_checkpoint_count() == 0, "queued checkpoint writes exactly once at PUBLISH_SNAPSHOT") + _expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == 361, "queued package captures the completed minute boundary") + _expect(_has_domain_event(&"SAVE_CHECKPOINT_SUCCEEDED"), "successful manual checkpoint publishes a DomainEvent") + SaveService.delete_save(path) + + +func _test_daily_checkpoint_dedup_generations_and_failure_preservation() -> void: + _reset_runtime(60_004, 1, 5, 59) + var path := TEST_PREFIX + "_daily" + SaveService.delete_save(path) + var configured = _runtime.configure_save_policy({ + &"enabled": true, + &"daily_checkpoint_enabled": true, + &"autosave_path": path, + &"autosave_generations": 3, + }) + _expect(configured[&"ok"], "daily Runtime save policy accepts a safe explicit slot") + _clock.advance_one_minute() + _expect(_runtime.get_last_daily_checkpoint_day() == 1, "day 1 checkpoint runs at exactly 06:00") + var generation_zero := SaveService.load_snapshot(SaveService.get_autosave_path(path, 0)) + _expect(generation_zero[&"ok"] and int(generation_zero[&"snapshot"][&"clock"][&"absolute_minute"]) == 360, "first daily checkpoint becomes autosave generation zero") + _runtime.call("_process_save_checkpoints", 360) + _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(SaveService.get_autosave_path(path, 1))), "same-day 06:00 callback is deduplicated") + + _clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY) + _clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY) + var expected_minutes := [3240, 1800, 360] + var all_generations_match := true + for generation in range(3): + var loaded := SaveService.load_snapshot(SaveService.get_autosave_path(path, generation)) + all_generations_match = all_generations_match \ + and loaded[&"ok"] \ + and int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_minutes[generation] + _expect(all_generations_match, "daily checkpoints rotate three deterministic newest-first generations") + + var newest_world := ProjectSettings.globalize_path( + SaveService.get_autosave_path(path, 0) + ).path_join(SaveService.WORLD_FILE) + var newest_before := FileAccess.get_md5(newest_world) + var pending_path := ProjectSettings.globalize_path(path + ".autosave.pending") + DirAccess.make_dir_recursive_absolute(pending_path) + var sentinel := FileAccess.open(pending_path.path_join("not_a_package.txt"), FileAccess.WRITE) + sentinel.store_string("block promotion") + sentinel = null + _clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY) + _expect( + not _runtime.latest_save_operation_result[&"ok"] + and _runtime.get_last_daily_checkpoint_day() == 3, + "failed day 4 checkpoint remains eligible for retry instead of being marked complete" + ) + _expect(FileAccess.get_md5(newest_world) == newest_before, "failed autosave leaves the previous newest generation byte-identical") + _expect(_has_domain_event(&"AUTOSAVE_CHECKPOINT_FAILED"), "autosave failure publishes a structured DomainEvent") + DirAccess.remove_absolute(pending_path.path_join("not_a_package.txt")) + DirAccess.remove_absolute(pending_path) + SaveService.delete_save(path) + + +func _test_exact_round_trip_recovery_pause_and_performance() -> void: + _reset_runtime(60_005, 3, 7, 15) + var path := TEST_PREFIX + "_round_trip" + SaveService.delete_save(path) + _clock.set_speed(4) + var expected_snapshot = _runtime.create_snapshot() + var expected_digest := SimulationDigest.compute(expected_snapshot) + var first_save = _runtime.save_to_path(path, {&"display_name": "Runtime Exact"}) + _expect(first_save[&"ok"] and not _clock.paused, "save pauses I/O temporarily and restores the running clock") + _state.add_resource(&"faith", 77) + _clock.advance_minutes(5) + var loaded = _runtime.load_from_path(path) + _expect(loaded[&"ok"] and _runtime.latest_digest == expected_digest, "ordinary Runtime load restores the exact persisted snapshot") + _expect(_clock.absolute_minute == int(expected_snapshot[&"clock"][&"absolute_minute"]) and not _clock.paused, "load preserves the saved clock minute and pause state") + var minute_before_process = _clock.absolute_minute + _expect(_clock.process_real_delta(10.0) == 0 and _clock.absolute_minute == minute_before_process, "first process frame after load never advances an extra minute") + + var second_save = _runtime.save_to_path(path, {&"display_name": "Runtime New Formal"}) + _expect(second_save[&"ok"], "second formal Runtime save creates a verified prior-generation backup") + _corrupt_world(path) + _state.add_resource(&"faith", 5) + var inventory = _runtime.inspect_recovery_candidates(path) + _expect(inventory[&"ok"] and inventory[&"selected"][&"id"] == &"backup", "explicit candidate inventory identifies the verified backup") + var recovered = _runtime.load_with_recovery(path) + _expect(recovered[&"ok"] and recovered[&"selected"][&"id"] == &"backup", "Runtime recovery skips a corrupt formal package") + _expect(_runtime.latest_digest == expected_digest, "recovery loads the exact prior formal snapshot without promotion") + _expect(recovered[&"recovery_report"][&"restore_committed"] and _has_domain_event(&"SAVE_RECOVERY_LOADED"), "successful recovery returns a structured report and publishes a DomainEvent") + var explicit = _runtime.load_recovery_candidate(path, &"backup") + _expect(explicit[&"ok"] and explicit[&"read_only"], "caller can explicitly load one named recovery candidate") + _expect(int(_clock.performance_budget.get_report(&"save")[&"lifetime_sample_count"]) >= 2, "Runtime records actual save operation timing") + _expect(int(_clock.performance_budget.get_report(&"load")[&"lifetime_sample_count"]) >= 3, "Runtime records load, inventory and recovery timing") + SaveService.delete_save(path) + + +func _test_domain_invalid_formal_falls_back_to_backup() -> void: + _reset_runtime(60_009, 2, 8, 0) + var path := TEST_PREFIX + "_domain_backup" + SaveService.delete_save(path) + var backup_snapshot: Dictionary = _runtime.create_snapshot() + var backup_digest := SimulationDigest.compute(backup_snapshot) + _expect( + SaveService.save_snapshot(path, backup_snapshot)[&"ok"], + "domain fallback fixture writes a valid prior formal package" + ) + var invalid_formal: Dictionary = backup_snapshot.duplicate(true) + invalid_formal[&"game_state"][&"resources"][&"gold"] = int( + invalid_formal[&"game_state"][&"resources"][&"gold"] + ) + 1 + _expect( + SaveService.save_snapshot(path, invalid_formal)[&"ok"], + "checksum-valid but ledger-invalid formal package is preserved as a package" + ) + _state.add_resource(&"faith", 17) + var strict_guard := _authoritative_live_guard() + var strict_load: Dictionary = _runtime.load_from_path(path) + _expect( + not strict_load[&"ok"] and strict_load[&"code"] == &"RESTORE_FAILED", + "ordinary load remains strict and does not fall through to a valid backup" + ) + _expect( + _authoritative_live_guard() == strict_guard, + "strict ordinary-load rejection preserves authoritative gameplay state" + ) + var explicit_formal: Dictionary = _runtime.load_recovery_candidate(path, &"formal") + _expect( + not explicit_formal[&"ok"] and explicit_formal[&"code"] == &"RESTORE_FAILED", + "explicit candidate load remains strict to the requested domain-invalid package" + ) + var recovered: Dictionary = _runtime.load_with_recovery(path) + _expect( + recovered[&"ok"] and recovered[&"selected"][&"id"] == &"backup", + "Runtime rejects a domain-invalid formal package and automatically selects its valid backup" + ) + _expect( + recovered[&"candidates"][0][&"package_status"] == &"VALID" + and recovered[&"candidates"][0][&"domain_status"] == &"REJECTED" + and recovered[&"candidates"][0][&"domain_rejection_code"] == &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED" + and recovered[&"candidates"][0][&"failed_attempt_live_state_preserved"], + "formal candidate report separates successful package verification from domain rejection" + ) + _expect( + recovered[&"candidates"][1][&"domain_status"] == &"VALID" + and recovered[&"candidates"][1][&"selected"], + "backup candidate report identifies the first domain-valid committed candidate" + ) + _expect( + _runtime.latest_digest == backup_digest, + "domain fallback commits the exact backup snapshot" + ) + _expect( + recovered[&"package_candidate_report"][&"selected"][&"id"] == &"formal" + and recovered[&"package_recovery_report"][&"source"][&"candidate_id"] == &"backup" + and recovered[&"restore_safety_report"][&"recovery_mode"], + "result preserves package-level selection evidence and final Runtime restore-safety evidence" + ) + SaveService.delete_save(path) + + +func _test_multiple_domain_invalid_candidates_fall_back_to_autosave() -> void: + _reset_runtime(60_010, 2, 9, 0) + var path := TEST_PREFIX + "_domain_autosave" + SaveService.delete_save(path) + var valid_autosave: Dictionary = _runtime.create_snapshot() + var valid_digest := SimulationDigest.compute(valid_autosave) + var invalid_backup: Dictionary = valid_autosave.duplicate(true) + invalid_backup[&"game_state"][&"resources"][&"gold"] = int( + invalid_backup[&"game_state"][&"resources"][&"gold"] + ) + 1 + var invalid_formal: Dictionary = valid_autosave.duplicate(true) + invalid_formal[&"game_state"][&"resources"][&"gold"] = int( + invalid_formal[&"game_state"][&"resources"][&"gold"] + ) + 2 + _expect( + SaveService.save_snapshot(path, invalid_backup)[&"ok"] + and SaveService.save_snapshot(path, invalid_formal)[&"ok"], + "multiple domain-invalid formal generations remain checksum-valid recovery candidates" + ) + _expect( + SaveService.save_autosave(path, valid_autosave, {}, 3)[&"ok"], + "valid autosave is written behind both invalid formal generations" + ) + var package_guard := { + &"formal": _package_fingerprint(path), + &"backup": _package_fingerprint(path + ".bak"), + &"autosave": _package_fingerprint(SaveService.get_autosave_path(path, 0)), + } + _state.add_resource(&"faith", 23) + var recovered: Dictionary = _runtime.load_with_recovery(path) + _expect( + recovered[&"ok"] and recovered[&"selected"][&"id"] == &"autosave_0", + "Runtime walks past two domain-invalid candidates to the newest valid autosave" + ) + _expect( + recovered[&"candidates"][0][&"domain_status"] == &"REJECTED" + and recovered[&"candidates"][1][&"domain_status"] == &"REJECTED" + and recovered[&"candidates"][0][&"failed_attempt_live_state_preserved"] + and recovered[&"candidates"][1][&"failed_attempt_live_state_preserved"] + and recovered[&"candidates"][4][&"domain_status"] == &"VALID", + "candidate attempts retain both ordered domain rejections and the autosave success" + ) + _expect( + recovered[&"recovery_report"][&"domain_rejections"].size() == 2 + and recovered[&"recovery_report"][&"selected_candidate_id"] == &"autosave_0", + "combined recovery report records every rejected domain candidate and final selection" + ) + _expect(_runtime.latest_digest == valid_digest, "autosave fallback restores the exact valid snapshot") + _expect( + package_guard == { + &"formal": _package_fingerprint(path), + &"backup": _package_fingerprint(path + ".bak"), + &"autosave": _package_fingerprint(SaveService.get_autosave_path(path, 0)), + }, + "domain fallback remains read-only and never promotes or rewrites any package" + ) + SaveService.delete_save(path) + + +func _test_all_domain_invalid_candidates_preserve_complete_live_state() -> void: + _reset_runtime(60_011, 2, 10, 0) + var path := TEST_PREFIX + "_all_domain_invalid" + SaveService.delete_save(path) + var base: Dictionary = _runtime.create_snapshot() + var invalid_backup: Dictionary = base.duplicate(true) + invalid_backup[&"game_state"][&"resources"][&"gold"] = int( + invalid_backup[&"game_state"][&"resources"][&"gold"] + ) + 1 + var invalid_formal: Dictionary = base.duplicate(true) + invalid_formal[&"game_state"][&"resources"][&"gold"] = int( + invalid_formal[&"game_state"][&"resources"][&"gold"] + ) + 2 + var invalid_autosave: Dictionary = base.duplicate(true) + invalid_autosave[&"game_state"][&"resources"][&"gold"] = int( + invalid_autosave[&"game_state"][&"resources"][&"gold"] + ) + 3 + _expect( + SaveService.save_snapshot(path, invalid_backup)[&"ok"] + and SaveService.save_snapshot(path, invalid_formal)[&"ok"] + and SaveService.save_autosave(path, invalid_autosave, {}, 3)[&"ok"], + "all-invalid fixture exposes three checksum-valid but domain-invalid candidates" + ) + _state.add_resource(&"faith", 31) + _runtime.event_service.configure_content_filter( + EventDefinition.FilterBehavior.DISABLE, + EventDefinition.ContentRating.GENERAL, + [&"local_atomic_filter"] + ) + _clock.set_speed(4) + _clock.set_paused(true) + _expect( + _runtime.queue_manual_checkpoint(TEST_PREFIX + "_never_written")[&"ok"], + "all-invalid atomicity fixture includes a pending Runtime checkpoint" + ) + var live_guard := _complete_live_guard() + var rejected: Dictionary = _runtime.load_with_recovery(path) + _expect( + not rejected[&"ok"] + and rejected[&"code"] == &"RESTORE_FAILED" + and rejected[&"recovery_report"][&"rejection_reason"] == &"NO_DOMAIN_VALID_RECOVERY_CANDIDATE", + "all domain-invalid candidates return one explicit terminal recovery failure" + ) + _expect( + rejected[&"recovery_report"][&"domain_rejections"].size() == 3 + and rejected[&"selected"].is_empty(), + "terminal failure reports all three domain rejections and no selected candidate" + ) + _expect( + _complete_live_guard() == live_guard and rejected[&"live_state_preserved"], + "all-candidate failure preserves the complete live snapshot, filter, pause, policy, and pending checkpoint" + ) + _expect( + rejected[&"restore_safety_report"] == rejected[&"recovery_report"] + and rejected[&"package_candidate_report"].has(&"candidates"), + "terminal failure preserves both package inventory and Runtime restore-safety reports" + ) + SaveService.delete_save(path) + + +func _test_content_filter_cannot_be_weakened() -> void: + _reset_runtime(60_006) + var weak_path := TEST_PREFIX + "_weak_filter" + var exact_path := TEST_PREFIX + "_exact_filter" + SaveService.delete_save(weak_path) + SaveService.delete_save(exact_path) + _runtime.event_service.configure_content_filter( + EventDefinition.FilterBehavior.NONE, + EventDefinition.ContentRating.RESTRICTED, + [&"saved_tag"] + ) + _expect(_runtime.save_to_path(weak_path)[&"ok"], "weaker saved content profile fixture is valid") + _runtime.event_service.configure_content_filter( + EventDefinition.FilterBehavior.DISABLE, + EventDefinition.ContentRating.GENERAL, + [&"local_tag"] + ) + var loaded = _runtime.load_from_path(weak_path) + var profile = _runtime.event_service.get_content_filter_profile() + _expect( + loaded[&"ok"] + and profile[&"filter_mode"] == EventDefinition.FilterBehavior.DISABLE + and profile[&"maximum_content_rating"] == EventDefinition.ContentRating.GENERAL, + "loading a weaker save cannot reduce behavior severity or raise the rating ceiling" + ) + _expect( + profile[&"disabled_content_tags"] == [&"local_tag", &"saved_tag"], + "load safety unions local and saved disabled content tags" + ) + var filter_report: Dictionary = loaded[&"restore_safety_report"][&"content_filter"] + _expect(filter_report[&"saved_profile_was_weaker"] and filter_report[&"strict_profile_preserved"], "load result explains that the stricter local filter won") + + var exact_snapshot = _runtime.create_snapshot() + var exact_digest := SimulationDigest.compute(exact_snapshot) + _expect(_runtime.save_to_path(exact_path)[&"ok"], "effective strict profile saves normally") + _runtime.event_service.configure_content_filter( + EventDefinition.FilterBehavior.NONE, + EventDefinition.ContentRating.RESTRICTED, + [] + ) + var exact_loaded = _runtime.load_from_path(exact_path) + _expect(exact_loaded[&"ok"] and _runtime.latest_digest == exact_digest, "ordinary content-profile round trip remains snapshot-exact") + SaveService.delete_save(weak_path) + SaveService.delete_save(exact_path) + + +func _test_whitelisted_recovery_repairs() -> void: + _reset_runtime(60_007) + var path := TEST_PREFIX + "_allowed_repairs" + SaveService.delete_save(path) + var snapshot = _runtime.create_snapshot() + var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] + var staff := StaffRuntime.new().configure(&"repair_staff", &"worker", { + &"state": StaffRuntime.State.MOVING_TO_TASK, + &"current_task_id": &"repair_task", + }) + var task := StaffTask.new().configure(&"repair_task", &"cleanup", 50, _clock.absolute_minute, { + &"state": StaffTask.State.RESERVED, + &"assigned_staff_id": &"missing_staff", + }) + runtime_data[&"staff"][&"staff"].append(staff.create_snapshot()) + runtime_data[&"staff"][&"tasks"].append(task.create_snapshot()) + _append_staff_path_crosslink(runtime_data, &"repair_staff", Vector3i.ZERO) + var terminal := EventInstance.new().configure( + &"terminal_foreground", + &"relationship.threshold", + &"relationship.threshold", + [&"player"], + 100, + _clock.absolute_minute, + -1, + true, + &"" + ) + terminal.state = EventInstance.State.COMPLETED + runtime_data[&"events"][&"instances"].append(terminal.create_snapshot()) + runtime_data[&"events"][&"foreground_instance_id"] = "terminal_foreground" + runtime_data[&"events"][&"actor_locks"] = {"player": "terminal_foreground"} + _expect(SaveService.save_snapshot(path, snapshot)[&"ok"], "checksum-valid orphan repair fixture writes as a package") + var recovered = _runtime.load_with_recovery(path) + _expect(recovered[&"ok"], "recovery accepts only the whitelisted Task and Event Lock damage") + var repaired_task = _runtime.staff_service.get_task(&"repair_task") + var repaired_staff = _runtime.staff_service.get_staff(&"repair_staff") + _expect( + repaired_task != null and repaired_task.state == StaffTask.State.OPEN and repaired_task.assigned_staff_id.is_empty() + and repaired_staff != null and repaired_staff.current_task_id.is_empty(), + "StaffService audit reopens the orphan Task and releases its stale Staff pointer" + ) + _expect(_runtime.event_service.get_actor_locks().is_empty() and _runtime.event_service.get_foreground_instance() == null, "terminal foreground recovery releases only its stale Event Locks") + var report: Dictionary = recovered[&"recovery_report"] + _expect( + report[&"repairs"][&"orphan_tasks"][&"reopened_task_ids"] == [&"repair_task"] + and report[&"repairs"][&"orphan_tasks"][&"released_staff_ids"] == [&"repair_staff"], + "repair report names every repaired Task and Staff exactly" + ) + _expect( + report[&"repairs"][&"orphan_event_locks"][&"released_actor_ids"] == [&"player"] + and report[&"repairs"][&"orphan_event_locks"][&"released_foreground_instance_id"] == &"terminal_foreground", + "repair report names the released Actor Lock and terminal foreground exactly" + ) + _expect(report[&"automatic_repairs_applied"] and not report[&"forbidden_repairs_applied"], "recovery report confirms its authoritative whitelist boundary") + SaveService.delete_save(path) + + +func _test_non_whitelisted_corruption_is_rejected_atomically() -> void: + _reset_runtime(60_008) + var built = _runtime.submit_command( + &"build_facility", + {&"definition_id": &"reception", &"origin": Vector3i(-6, 0, 0), &"rotation": 0}, + &"player", &"", 100, -1, &"s6.invalid.fixture.facility" + ) + _expect(built.is_success(), "invalid recovery fixtures start from a valid constructed facility") + _clock.advance_minutes(120) + var base = _runtime.create_snapshot() + + var missing_facility = base.duplicate(true) + missing_facility[&"systems"][&"runtime"][&"facilities"][&"instances"] = [] + _test_rejected_candidate( + TEST_PREFIX + "_reject_missing_facility", + missing_facility, + "recovery never deletes or invents a missing operational facility" + ) + + var ledger_mutation = base.duplicate(true) + ledger_mutation[&"game_state"][&"resources"][&"gold"] = int( + ledger_mutation[&"game_state"][&"resources"][&"gold"] + ) + 1 + _test_rejected_candidate( + TEST_PREFIX + "_reject_ledger", + ledger_mutation, + "recovery never synthesizes Gold or rewrites the ledger projection" + ) + + var route_mismatch = base.duplicate(true) + var invalid_relationship := RelationshipState.new().configure( + &"invalid_route", + &"actor_a", + &"actor_b", + { + &"route_lock": RelationshipState.RouteLock.EXCLUSIVE_LOCKED, + &"route_id": &"", + } + ) + route_mismatch[&"systems"][&"runtime"][&"relationships"][&"relationships"].append( + invalid_relationship.create_snapshot() + ) + _test_rejected_candidate( + TEST_PREFIX + "_reject_route", + route_mismatch, + "recovery never guesses an exclusive Relationship route lock" + ) + + var unreleasable = base.duplicate(true) + var bad_staff := StaffRuntime.new().configure(&"blocked_staff", &"worker", { + &"state": StaffRuntime.State.MOVING_TO_TASK, + &"current_task_id": &"blocked_task", + }) + var bad_task := StaffTask.new().configure(&"blocked_task", &"cleanup", 50, _clock.absolute_minute, { + &"state": StaffTask.State.RESERVED, + &"assigned_staff_id": &"missing_staff", + &"reservation_ids": [&"missing_reservation"], + }) + unreleasable[&"systems"][&"runtime"][&"staff"][&"staff"].append(bad_staff.create_snapshot()) + unreleasable[&"systems"][&"runtime"][&"staff"][&"tasks"].append(bad_task.create_snapshot()) + _append_staff_path_crosslink( + unreleasable[&"systems"][&"runtime"], + &"blocked_staff", + Vector3i.ZERO + ) + _test_rejected_candidate( + TEST_PREFIX + "_reject_reservation", + unreleasable, + "recovery rejects an orphan Task whose inventory Reservation cannot be released" + ) + + +func _test_rejected_candidate(path: String, snapshot: Dictionary, label: String) -> void: + SaveService.delete_save(path) + _expect(SaveService.save_snapshot(path, snapshot)[&"ok"], "%s fixture is checksum-valid" % label) + var guard := _authoritative_live_guard() + var result = _runtime.load_with_recovery(path) + _expect(not result[&"ok"] and result[&"code"] == &"RESTORE_FAILED", label) + _expect(_authoritative_live_guard() == guard, "%s and leaves authoritative live state unchanged" % label) + _expect( + result[&"recovery_report"][&"restore_committed"] == false + and not result[&"recovery_report"][&"forbidden_repairs_applied"], + "%s reports atomic rejection without forbidden repair" % label + ) + SaveService.delete_save(path) + + +func _append_staff_path_crosslink( + runtime_data: Dictionary, + staff_id: StringName, + cell: Vector3i +) -> void: + var actor_id := StringName("staff:%s" % staff_id) + runtime_data[&"path"][&"actor_cells"].append({ + &"actor_id": actor_id, + &"cell": cell, + }) + runtime_data[&"path"][&"actor_progress"].append({ + &"actor_id": actor_id, + &"last_cell": cell, + &"last_progress_at": _clock.absolute_minute, + &"last_observed_at": _clock.absolute_minute, + &"stuck_reported_at": -1, + }) + runtime_data[&"movement_credits"].append({ + &"actor_id": String(actor_id), + &"credit": 0, + &"updated_at": _clock.absolute_minute, + }) + for traffic: Dictionary in runtime_data[&"path"][&"cell_traffic"]: + if traffic[&"cell"] == cell: + traffic[&"current_density"] = int(traffic[&"current_density"]) + 1 + break + + +func _authoritative_live_guard() -> Dictionary: + return { + &"clock": _clock.clock_snapshot(), + &"game_state": _state.create_snapshot(), + &"economy": _runtime.economy_service.create_snapshot(), + &"inventory": _runtime.inventory_service.create_snapshot(), + &"facilities": _runtime.facility_service.create_snapshot(), + &"construction": _runtime.construction_service.create_snapshot(), + &"staff": _runtime.staff_service.create_snapshot(), + &"relationships": _runtime.relationship_service.create_snapshot(), + &"events": _runtime.event_service.create_snapshot(), + } + + +func _complete_live_guard() -> Dictionary: + return { + &"snapshot": _runtime.create_snapshot(), + &"clock_paused": _clock.paused, + &"save_policy": _runtime.get_save_policy(), + &"pending_checkpoint_count": _runtime.get_pending_checkpoint_count(), + &"last_daily_checkpoint_day": _runtime.get_last_daily_checkpoint_day(), + &"content_filter": _runtime.event_service.get_content_filter_profile(), + } + + +func _reset_runtime(seed: int, day := 1, hour := 6, minute := 0) -> void: + _cleanup_runtime() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(day, hour, minute) + _state.reset() + var report = _runtime.initialize(_clock, _state, seed) + _expect(report[&"valid"], "Runtime fixture initializes deterministic definitions") + + +func _cleanup_runtime() -> void: + if _runtime != null: + _runtime.free() + if _clock != null: + _clock.free() + if _state != null: + _state.free() + _runtime = null + _clock = null + _state = null + + +func _cleanup_test_saves() -> void: + for suffix: String in [ + "_disabled", "_queued", "_daily", "_round_trip", "_weak_filter", + "_exact_filter", "_allowed_repairs", "_reject_missing_facility", + "_reject_ledger", "_reject_route", "_reject_reservation", + "_domain_backup", "_domain_autosave", "_all_domain_invalid", + "_never_written", + ]: + SaveService.delete_save(TEST_PREFIX + suffix) + + +func _has_domain_event(event_type: StringName) -> bool: + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == event_type: + return true + return false + + +func _package_fingerprint(save_path: String) -> Dictionary: + var absolute_path := ProjectSettings.globalize_path(save_path) + var result := {&"directory_exists": DirAccess.dir_exists_absolute(absolute_path)} + for file_name: String in [SaveService.MANIFEST_FILE, SaveService.WORLD_FILE, SaveService.THUMBNAIL_FILE]: + var file_path := absolute_path.path_join(file_name) + result[file_name] = FileAccess.get_md5(file_path) if FileAccess.file_exists(file_path) else "" + return result + + +func _corrupt_world(save_path: String) -> void: + var world_path := ProjectSettings.globalize_path(save_path).path_join(SaveService.WORLD_FILE) + var bytes := FileAccess.get_file_as_bytes(world_path) + if bytes.is_empty(): + return + bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0x5a + var file := FileAccess.open(world_path, FileAccess.WRITE) + file.store_buffer(bytes) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s6/test_runtime_save_lifecycle.gd.uid b/tests/s6/test_runtime_save_lifecycle.gd.uid new file mode 100644 index 0000000..1ee4aa0 --- /dev/null +++ b/tests/s6/test_runtime_save_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://bamjaic1m17jn diff --git a/tests/s6/test_save_operations.gd b/tests/s6/test_save_operations.gd new file mode 100644 index 0000000..f9c9236 --- /dev/null +++ b/tests/s6/test_save_operations.gd @@ -0,0 +1,584 @@ +extends SceneTree + +const SaveServiceType = preload("res://scripts/core/save_service.gd") +const MigrationRegistryType = preload("res://scripts/core/save_migration_registry.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("S6: save rotation, recovery and migration") + _test_path_safety() + _test_package_internal_symlink_load_rejection() + _test_package_contract_identity_and_timing() + _test_autosave_rotation_and_formal_atomicity() + _test_interrupted_formal_save_recovery() + _test_interrupted_autosave_rotation_recovery() + _test_autosave_retention_shrink_prunes_verified_generations() + _test_recovery_priority_checksum_and_read_only_boundary() + _test_migration_registry_continuity_and_purity() + _test_current_format_snapshot_validation() + _test_save_package_migration_and_pre_migration_backup() + _test_migration_failure_preserves_source() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_path_safety() -> void: + var snapshot := _snapshot(10, &"safe") + var traversal := SaveServiceType.save_snapshot("user://../s6_escape", snapshot) + _expect(not traversal[&"ok"] and traversal[&"code"] == &"UNSAFE_PATH", "parent traversal is rejected before any write") + var relative := SaveServiceType.save_snapshot("relative/s6_save", snapshot) + _expect(not relative[&"ok"] and relative[&"code"] == &"INVALID_PATH", "ambiguous relative save paths are rejected") + var external := SaveServiceType.save_snapshot("/tmp/the_tree_s6_escape", snapshot) + _expect(not external[&"ok"] and external[&"code"] == &"UNSAFE_PATH", "absolute paths outside the Godot user directory are rejected") + _expect(not SaveServiceType.delete_save("user://"), "delete refuses the broad user data root") + _expect(not SaveServiceType.delete_save("/"), "delete refuses the filesystem root") + + var non_package_path := ProjectSettings.globalize_path("user://saves/s6_non_package_parent") + DirAccess.make_dir_recursive_absolute(non_package_path) + var keep_file := FileAccess.open(non_package_path.path_join("keep.txt"), FileAccess.WRITE) + keep_file.store_string("must remain") + keep_file = null + _expect(not SaveServiceType.delete_save("user://saves/s6_non_package_parent"), "delete refuses an ordinary parent directory that is not a save package") + _expect(FileAccess.file_exists(non_package_path.path_join("keep.txt")), "refused parent deletion preserves unrelated user data") + _expect(not SaveServiceType.save_snapshot("user://saves/s6_non_package_parent", snapshot)[&"ok"], "save refuses to rotate an ordinary directory into a package backup") + DirAccess.remove_absolute(non_package_path.path_join("keep.txt")) + DirAccess.remove_absolute(non_package_path) + + var external_path := "/tmp/the_tree_s6_symlink_%d" % OS.get_process_id() + var link_path := ProjectSettings.globalize_path("user://saves/s6_symlink_parent") + DirAccess.make_dir_recursive_absolute(external_path) + var sentinel := FileAccess.open(external_path.path_join("outside.txt"), FileAccess.WRITE) + sentinel.store_string("outside") + sentinel = null + DirAccess.remove_absolute(link_path) + var link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, link_path])) + _expect(link_exit == 0, "symlink escape fixture is available") + if link_exit == 0: + var linked_save := SaveServiceType.save_snapshot("user://saves/s6_symlink_parent/escaped", snapshot) + _expect(not linked_save[&"ok"] and linked_save[&"code"] == &"UNSAFE_PATH", "write refuses a save directory reached through a symbolic link") + _expect(not SaveServiceType.delete_save("user://saves/s6_symlink_parent/escaped"), "delete refuses a directory reached through a symbolic link") + _expect(FileAccess.get_file_as_string(external_path.path_join("outside.txt")) == "outside", "symlink rejection never touches data outside user storage") + DirAccess.remove_absolute(link_path) + var recursive_save_path := "user://saves/s6_recursive_symlink" + SaveServiceType.delete_save(recursive_save_path) + SaveServiceType.save_snapshot(recursive_save_path, snapshot) + var embedded_link := ProjectSettings.globalize_path(recursive_save_path).path_join("external_link") + var embedded_link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, embedded_link])) + _expect(embedded_link_exit == 0, "recursive symlink fixture is available") + if embedded_link_exit == 0: + var recursive_write := SaveServiceType.save_snapshot(recursive_save_path, snapshot) + _expect(not recursive_write[&"ok"] and recursive_write[&"code"] == &"UNSAFE_PATH", "overwrite refuses a symbolic link nested anywhere inside the package") + _expect(not SaveServiceType.delete_save(recursive_save_path), "recursive delete refuses a package containing a symbolic link") + _expect(FileAccess.file_exists(external_path.path_join("outside.txt")), "recursive symlink guard preserves its external target") + DirAccess.remove_absolute(embedded_link) + SaveServiceType.delete_save(recursive_save_path) + DirAccess.remove_absolute(external_path.path_join("outside.txt")) + DirAccess.remove_absolute(external_path) + + +func _test_package_internal_symlink_load_rejection() -> void: + var save_path := "user://saves/s6_internal_world_link" + var external_package := "user://saves/s6_external_valid_world" + SaveServiceType.delete_save(save_path) + SaveServiceType.delete_save(external_package) + var shared_snapshot := _snapshot(44, &"external_valid") + _expect(SaveServiceType.save_snapshot(save_path, shared_snapshot)[&"ok"], "internal-link fixture target package is valid before substitution") + _expect(SaveServiceType.save_snapshot(external_package, shared_snapshot)[&"ok"], "external world fixture is a separately verified package") + var target_world := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE) + var external_world := ProjectSettings.globalize_path(external_package).path_join(SaveServiceType.WORLD_FILE) + DirAccess.remove_absolute(target_world) + var link_exit := OS.execute("ln", PackedStringArray(["-s", external_world, target_world])) + _expect(link_exit == 0, "known package-file symlink fixture is available") + if link_exit == 0: + var direct := SaveServiceType.load_snapshot(save_path) + _expect(not direct[&"ok"] and direct[&"code"] == &"UNSAFE_PATH", "direct load rejects a world.bin symlink even when its external bytes are valid") + var explicit := SaveServiceType.load_recovery_candidate(save_path, &"formal") + _expect(not explicit[&"ok"] and explicit[&"code"] == &"UNSAFE_PATH", "explicit recovery-candidate load rejects an internal package symlink") + var inspected := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(not inspected[&"ok"] and inspected[&"code"] == &"UNSAFE_PATH", "recovery inspection rejects an internal package symlink before opening it") + _expect(FileAccess.file_exists(external_world), "load rejection leaves the external valid world untouched") + DirAccess.remove_absolute(target_world) + SaveServiceType.delete_save(save_path) + SaveServiceType.delete_save(external_package) + + +func _test_package_contract_identity_and_timing() -> void: + var save_path := "user://saves/s6_contract" + SaveServiceType.delete_save(save_path) + SaveServiceType.clear_performance_reports() + var first := SaveServiceType.save_snapshot(save_path, _snapshot(60, &"first")) + _expect(first[&"ok"] and int(first[&"duration_us"]) >= 0, "save result reports actual elapsed time") + var package_path := ProjectSettings.globalize_path(save_path) + var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE))) + _expect(str(manifest["save_id"]) == "s6_contract" and str(manifest["display_name"]) == "s6_contract", "default identity comes from the logical slot rather than its temporary directory") + var contract_keys := ["format_version", "game_version", "save_id", "display_name", "created_at_real", "updated_at_real", "absolute_minute", "day", "seed", "checksum_world", "content_pack_ids", "enabled_filter_profile"] + var all_contract_keys := true + for key: String in contract_keys: + all_contract_keys = all_contract_keys and manifest.has(key) + _expect(all_contract_keys, "manifest generates every required package-contract field") + var thumbnail_bytes := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.THUMBNAIL_FILE)) + var thumbnail := Image.new() + _expect(not thumbnail_bytes.is_empty() and thumbnail.load_png_from_buffer(thumbnail_bytes) == OK, "save package always contains a valid thumbnail PNG") + var created_at := str(manifest["created_at_real"]) + + var second := SaveServiceType.save_snapshot(save_path, _snapshot(61, &"second"), { + &"display_name": "Chosen Name", + &"content_pack_ids": ["base"], + &"custom_note": "preserved", + }) + _expect(second[&"ok"], "package overwrite accepts supplied metadata") + var overwritten := SaveServiceType.load_snapshot(save_path) + var second_manifest: Dictionary = overwritten[&"manifest"] + _expect(str(second_manifest["save_id"]) == "s6_contract" and str(second_manifest["display_name"]) == "Chosen Name", "overwrite preserves prior ID while honoring an explicit display name") + _expect(str(second_manifest["created_at_real"]) == created_at and second_manifest["content_pack_ids"] == ["base"] and str(second_manifest["custom_note"]) == "preserved", "overwrite preserves creation identity and custom contract metadata") + _expect(FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.THUMBNAIL_FILE)) == thumbnail_bytes, "overwrite preserves the previous thumbnail when none is supplied") + + var third := SaveServiceType.save_snapshot(save_path, _snapshot(62, &"third")) + _expect(third[&"ok"] and str(third[&"manifest"]["display_name"]) == "Chosen Name", "later overwrite retains prior display identity by default") + _expect(SaveServiceType.get_performance_report(&"save")[&"lifetime_sample_count"] == 3, "every completed save attempt feeds the save performance budget") + _expect(SaveServiceType.get_performance_report(&"load")[&"lifetime_sample_count"] == 1 and int(overwritten[&"duration_us"]) >= 0, "load operations feed the load performance budget and report elapsed time") + SaveServiceType.delete_save(save_path) + + +func _test_autosave_rotation_and_formal_atomicity() -> void: + var save_path := "user://saves/s6_rotation" + SaveServiceType.delete_save(save_path) + _expect(SaveServiceType.save_snapshot(save_path, _snapshot(10, &"formal_old"))[&"ok"], "initial formal save succeeds") + _expect(SaveServiceType.save_snapshot(save_path, _snapshot(20, &"formal_current"))[&"ok"], "formal save keeps a verified backup") + for minute in [101, 102, 103, 104]: + var result := SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute))) + _expect(result[&"ok"] and result[&"formal_save_preserved"], "autosave %d rotates without replacing the formal save" % minute) + + var expected_minutes := [104, 103, 102] + for generation in range(3): + var loaded := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, generation)) + _expect(loaded[&"ok"], "autosave generation %d remains checksum-valid" % generation) + if loaded[&"ok"]: + _expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_minutes[generation], "generation %d has deterministic newest-first order" % generation) + _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(save_path + ".autosave.3")), "rotation retains exactly three generations") + + var formal_world_path := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE) + var formal_before := FileAccess.get_file_as_bytes(formal_world_path) + var invalid := _snapshot(999, &"invalid") + invalid[&"schema_version"] = 999 + var failed := SaveServiceType.save_autosave(save_path, invalid) + _expect(not failed[&"ok"] and failed[&"code"] == &"INVALID_SNAPSHOT", "invalid autosave candidate fails before rotation") + _expect(failed[&"formal_save_preserved"] and FileAccess.get_file_as_bytes(formal_world_path) == formal_before, "failed autosave leaves the formal package byte-identical") + var formal_loaded := SaveServiceType.load_snapshot(save_path) + _expect(formal_loaded[&"ok"] and _marker(formal_loaded[&"snapshot"]) == &"formal_current", "formal save remains loadable after autosave failure") + var newest := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0)) + _expect(newest[&"ok"] and _marker(newest[&"snapshot"]) == &"auto_104", "failed candidate does not disturb the previous newest autosave") + SaveServiceType.delete_save(save_path) + + +func _test_interrupted_formal_save_recovery() -> void: + var save_path := "user://saves/s6_formal_transaction" + var candidate_path := "user://saves/s6_formal_transaction_candidate" + SaveServiceType.delete_save(save_path) + SaveServiceType.delete_save(candidate_path) + _expect(SaveServiceType.save_snapshot(save_path, _snapshot(10, &"durable_old"))[&"ok"], "formal crash fixture writes its durable predecessor") + _expect(SaveServiceType.save_snapshot(candidate_path, _snapshot(20, &"durable_new"))[&"ok"], "formal crash fixture writes a verified replacement candidate") + var target_absolute := ProjectSettings.globalize_path(save_path) + var candidate_absolute := ProjectSettings.globalize_path(candidate_path) + _copy_fixture_tree(candidate_absolute, target_absolute + ".tmp") + DirAccess.rename_absolute(target_absolute, target_absolute + ".bak") + _write_transaction_marker(target_absolute) + var promoted := SaveServiceType.load_snapshot(save_path) + _expect(promoted[&"ok"] and promoted[&"interrupted_save_reconciled"], "load reconciles a crash between formal-to-backup and candidate promotion") + _expect(_marker(promoted[&"snapshot"]) == &"durable_new", "reconciliation promotes only the verified temporary package") + var preserved_backup := SaveServiceType.load_snapshot(save_path + ".bak") + _expect(preserved_backup[&"ok"] and _marker(preserved_backup[&"snapshot"]) == &"durable_old", "candidate promotion retains the verified prior formal backup") + _expect(not FileAccess.file_exists(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX), "successful formal reconciliation clears its durable marker") + + SaveServiceType.delete_save(save_path) + _expect(SaveServiceType.save_snapshot(save_path, _snapshot(30, &"rollback_old"))[&"ok"], "corrupt-candidate fixture writes its verified backup source") + _copy_fixture_tree(candidate_absolute, target_absolute + ".tmp") + _corrupt_world(save_path + ".tmp") + DirAccess.rename_absolute(target_absolute, target_absolute + ".bak") + _write_transaction_marker(target_absolute) + var rolled_back := SaveServiceType.load_snapshot(save_path) + _expect(rolled_back[&"ok"] and _marker(rolled_back[&"snapshot"]) == &"rollback_old", "corrupt temporary candidate is never promoted and verified backup is restored") + _expect(not DirAccess.dir_exists_absolute(target_absolute + ".tmp"), "verified backup recovery discards only the marker-owned corrupt temporary package") + _expect(not FileAccess.file_exists(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX), "verified backup recovery clears the completed transaction marker") + + SaveServiceType.delete_save(save_path) + _expect(SaveServiceType.save_snapshot(save_path, _snapshot(40, &"backup_only"))[&"ok"], "backup-only crash fixture writes a verified formal source") + DirAccess.rename_absolute(target_absolute, target_absolute + ".bak") + _write_transaction_marker(target_absolute) + var inspected := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(inspected[&"ok"] and inspected[&"reconciliation"][&"formal_save"][&"action"] == &"restored_verified_backup", "candidate inspection restores a verified backup when no temporary candidate exists") + _expect(_marker(inspected[&"snapshot"]) == &"backup_only" and inspected[&"selected"][&"id"] == &"formal", "backup-only reconciliation recreates the formal package before candidate selection") + SaveServiceType.delete_save(save_path) + SaveServiceType.delete_save(candidate_path) + + +func _test_interrupted_autosave_rotation_recovery() -> void: + var save_path := "user://saves/s6_rotation_recovery" + SaveServiceType.delete_save(save_path) + SaveServiceType.save_snapshot(save_path, _snapshot(5, &"formal")) + for minute in [10, 20, 30]: + SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute))) + var target_path := ProjectSettings.globalize_path(save_path) + var pending_path := target_path + ".autosave.pending" + _copy_fixture_tree(target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0", pending_path) + for generation in range(3): + var generation_path := target_path + SaveServiceType.AUTOSAVE_SUFFIX + str(generation) + DirAccess.rename_absolute(generation_path, generation_path + ".rotate_tmp") + var after_rollback := SaveServiceType.save_autosave(save_path, _snapshot(40, &"auto_40")) + _expect(after_rollback[&"ok"] and after_rollback[&"interrupted_rotation_reconciled"], "next autosave rolls back an interrupted pre-promotion rotation before proceeding") + var expected_after_rollback := [40, 30, 20] + for generation in range(3): + var loaded := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, generation)) + _expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_after_rollback[generation], "rollback recovery preserves generation %d order" % generation) + + # Simulate a crash after the new generation zero was promoted but before old + # generations were shifted out of their transaction holds. + for generation in range(3): + var generation_path := target_path + SaveServiceType.AUTOSAVE_SUFFIX + str(generation) + DirAccess.rename_absolute(generation_path, generation_path + ".rotate_tmp") + _copy_fixture_tree(target_path, target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0") + var recovery := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(recovery[&"ok"] and recovery[&"reconciliation"][&"autosave"][&"action"] == &"completed", "recovery inspection completes an interrupted post-promotion rotation") + var completed_zero := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0)) + var completed_one := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 1)) + var completed_two := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 2)) + _expect(_marker(completed_zero[&"snapshot"]) == &"formal", "post-promotion recovery keeps the already-promoted newest package") + _expect(_marker(completed_one[&"snapshot"]) == &"auto_40" and _marker(completed_two[&"snapshot"]) == &"auto_30", "post-promotion recovery completes the remaining generation shifts exactly once") + _expect(not DirAccess.dir_exists_absolute(target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0.rotate_tmp"), "rotation recovery removes durable hold markers after verification") + SaveServiceType.delete_save(save_path) + + +func _test_autosave_retention_shrink_prunes_verified_generations() -> void: + var save_path := "user://saves/s6_retention_shrink" + SaveServiceType.delete_save(save_path) + for minute in [10, 20, 30, 40, 50]: + _expect(SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("wide_%d" % minute)), {}, 4)[&"ok"], "four-generation autosave fixture accepts minute %d" % minute) + var shrink := SaveServiceType.save_autosave(save_path, _snapshot(60, &"narrow_60"), {}, 2) + _expect(shrink[&"ok"] and shrink[&"pruned_generations"] == [2, 3], "successful 4-to-2 retention change reports every pruned verified generation") + var newest := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0)) + var previous := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 1)) + _expect(_marker(newest[&"snapshot"]) == &"narrow_60" and _marker(previous[&"snapshot"]) == &"wide_50", "retention shrink preserves deterministic newest-first generations") + var target_absolute := ProjectSettings.globalize_path(save_path) + _expect(not DirAccess.dir_exists_absolute(target_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "2") and not DirAccess.dir_exists_absolute(target_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3"), "retention shrink removes all verified generations above the new limit") + SaveServiceType.delete_save(save_path) + + var conflict_path := "user://saves/s6_retention_conflict" + SaveServiceType.delete_save(conflict_path) + _expect(SaveServiceType.save_autosave(conflict_path, _snapshot(10, &"conflict_base"), {}, 4)[&"ok"], "retention conflict fixture starts with a valid owned generation") + var conflict_absolute := ProjectSettings.globalize_path(conflict_path) + var foreign_generation := conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3" + DirAccess.make_dir_recursive_absolute(foreign_generation) + var sentinel := FileAccess.open(foreign_generation.path_join("foreign.txt"), FileAccess.WRITE) + sentinel.store_string("preserve") + sentinel = null + var newest_before := FileAccess.get_file_as_bytes((conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "0").path_join(SaveServiceType.WORLD_FILE)) + var foreign_rejected := SaveServiceType.save_autosave(conflict_path, _snapshot(20, &"must_not_commit"), {}, 2) + _expect(not foreign_rejected[&"ok"] and foreign_rejected[&"code"] == &"AUTOSAVE_PRUNE_CONFLICT", "retention shrink refuses a foreign non-package generation") + _expect(FileAccess.get_file_as_string(foreign_generation.path_join("foreign.txt")) == "preserve" and FileAccess.get_file_as_bytes((conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "0").path_join(SaveServiceType.WORLD_FILE)) == newest_before, "foreign prune conflict preserves both foreign data and the prior newest autosave") + DirAccess.remove_absolute(foreign_generation.path_join("foreign.txt")) + DirAccess.remove_absolute(foreign_generation) + + var external_path := ProjectSettings.globalize_path("user://saves/s6_retention_external") + DirAccess.make_dir_recursive_absolute(external_path) + var external_sentinel := FileAccess.open(external_path.path_join("outside.txt"), FileAccess.WRITE) + external_sentinel.store_string("outside") + external_sentinel = null + var linked_generation := conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3" + var link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, linked_generation])) + _expect(link_exit == 0, "retention prune symlink fixture is available") + if link_exit == 0: + var linked_rejected := SaveServiceType.save_autosave(conflict_path, _snapshot(30, &"must_not_follow"), {}, 2) + _expect(not linked_rejected[&"ok"] and linked_rejected[&"code"] == &"UNSAFE_PATH", "retention shrink refuses a symlink generation before prune") + _expect(FileAccess.get_file_as_string(external_path.path_join("outside.txt")) == "outside", "prune rejection never follows or deletes the symlink target") + DirAccess.remove_absolute(linked_generation) + SaveServiceType.delete_save(conflict_path) + DirAccess.remove_absolute(external_path.path_join("outside.txt")) + DirAccess.remove_absolute(external_path) + + +func _test_recovery_priority_checksum_and_read_only_boundary() -> void: + var save_path := "user://saves/s6_recovery" + SaveServiceType.delete_save(save_path) + SaveServiceType.save_snapshot(save_path, _snapshot(10, &"backup")) + SaveServiceType.save_snapshot(save_path, _snapshot(20, &"formal")) + for minute in [101, 102, 103, 104]: + SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute))) + + var all_valid := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(all_valid[&"ok"] and all_valid[&"selected"][&"id"] == &"formal", "recovery prefers a valid formal save") + var ids: Array[StringName] = [] + for candidate: Dictionary in all_valid[&"candidates"]: + ids.append(StringName(candidate[&"id"])) + _expect(ids == [&"formal", &"backup", &"migration_original", &"pre_migration_backup", &"autosave_0", &"autosave_1", &"autosave_2"], "recovery candidate order explicitly includes migration safety packages") + + _corrupt_world(save_path) + var formal_corrupt := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(formal_corrupt[&"ok"] and formal_corrupt[&"selected"][&"id"] == &"backup", "checksum failure in formal save selects the verified formal backup") + _expect(formal_corrupt[&"candidates"][0][&"code"] == &"CHECKSUM_MISMATCH", "corrupt formal world is reported as checksum mismatch") + + _corrupt_world(save_path + ".bak") + _corrupt_world(SaveServiceType.get_autosave_path(save_path, 0)) + var observed_manifest_path := ProjectSettings.globalize_path(SaveServiceType.get_autosave_path(save_path, 1)).path_join(SaveServiceType.MANIFEST_FILE) + var manifest_before := FileAccess.get_file_as_string(observed_manifest_path) + var fallback := SaveServiceType.load_with_recovery(save_path) + var manifest_after := FileAccess.get_file_as_string(observed_manifest_path) + _expect(fallback[&"ok"] and fallback[&"selected"][&"id"] == &"autosave_1", "corrupt latest candidates fall back to the next valid autosave generation") + _expect(_marker(fallback[&"snapshot"]) == &"auto_103", "fallback returns the exact older generation snapshot") + _expect(manifest_before == manifest_after, "recovery inspection is read-only and does not promote or rewrite candidates") + + var explicit := SaveServiceType.load_recovery_candidate(save_path, &"autosave", 2) + _expect(explicit[&"ok"] and explicit[&"read_only"] and _marker(explicit[&"snapshot"]) == &"auto_102", "caller can explicitly select a valid recovery generation") + var report: Dictionary = explicit[&"recovery_report"] + _expect(not report[&"automatic_repairs_applied"] and report[&"repair_policy"] == &"REPORT_ONLY_AUTHORITATIVE_SERVICE_REQUIRED", "recovery report never performs implicit domain repairs") + _expect(report[&"findings"].has(&"orphan_tasks") and report[&"findings"].has(&"orphan_event_locks"), "recovery report exposes Task and Event-lock handoff boundaries") + SaveServiceType.delete_save(save_path) + + +func _test_migration_registry_continuity_and_purity() -> void: + var registry := MigrationRegistryType.new() + _expect(registry.register_step(1, &"add_first", Callable(self, "_migration_add_first"))[&"ok"], "adjacent migration step 1 -> 2 registers") + _expect(registry.register_step(2, &"add_second", Callable(self, "_migration_add_second"))[&"ok"], "adjacent migration step 2 -> 3 registers") + var source := {&"nested": {&"history": []}, &"stable": 7} + var source_guard := source.duplicate(true) + var migrated := registry.migrate(source, 1, 3) + _expect(migrated[&"ok"], "N -> N+2 migration succeeds through continuous adjacent steps") + _expect(migrated[&"applied_migration_ids"] == [&"add_first", &"add_second"], "migration registry applies every step in exact version order") + _expect(migrated[&"data"][&"nested"][&"history"] == [&"one", &"two"], "second step observes the first step output") + _expect(source == source_guard and source[&"nested"][&"history"].is_empty(), "migration callbacks cannot mutate the caller-owned source") + + var missing := MigrationRegistryType.new() + missing.register_step(1, &"only_first", Callable(self, "_migration_add_first")) + var missing_result := missing.migrate(source, 1, 3) + _expect(not missing_result[&"ok"] and missing_result[&"code"] == &"MIGRATION_STEP_MISSING", "registry refuses to skip a missing N+1 -> N+2 step") + _expect(missing_result[&"required_migration_id"] == &"format_2_to_3", "missing step returns its stable required migration ID") + _expect(missing_result[&"applied_migration_ids"] == [&"only_first"], "failure report preserves the exact completed-step audit trail") + + var failing := MigrationRegistryType.new() + failing.register_step(1, &"known_failure", Callable(self, "_migration_reject")) + var failed := failing.migrate(source, 1, 2) + _expect(not failed[&"ok"] and failed[&"failed_migration_id"] == &"known_failure", "failed migration reports the exact registered migration ID") + _expect(failed[&"code"] == &"FIXTURE_REJECTED" and source == source_guard, "failed migration propagates its reason and preserves source purity") + + +func _test_current_format_snapshot_validation() -> void: + var save_path := "user://saves/s6_current_invalid" + SaveServiceType.delete_save(save_path) + SaveServiceType.save_snapshot(save_path, _snapshot(180, &"current_invalid")) + _make_legacy_format_zero(save_path, SaveServiceType.FORMAT_VERSION) + var direct_load := SaveServiceType.load_snapshot(save_path) + _expect(not direct_load[&"ok"] and direct_load[&"code"] == &"INVALID_SNAPSHOT", "current-format load still enforces the current snapshot schema") + var migration_attempt := SaveServiceType.migrate_save(save_path, MigrationRegistryType.new(), SaveServiceType.FORMAT_VERSION) + _expect(not migration_attempt[&"ok"] and migration_attempt[&"code"] == &"INVALID_SNAPSHOT", "no-op migration cannot bypass current snapshot validation") + SaveServiceType.delete_save(save_path) + + +func _test_save_package_migration_and_pre_migration_backup() -> void: + var save_path := "user://saves/s6_migration" + SaveServiceType.delete_save(save_path) + SaveServiceType.save_snapshot(save_path, _snapshot(200, &"legacy"), {&"display_name": "Legacy fixture"}) + _make_legacy_format_zero(save_path) + var package_path := ProjectSettings.globalize_path(save_path) + var world_before := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) + var manifest_before := FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) + var unsupported := SaveServiceType.load_snapshot(save_path) + _expect(not unsupported[&"ok"] and unsupported[&"code"] == &"UNSUPPORTED_FORMAT", "legacy package is not loaded before explicit migration") + + var registry := MigrationRegistryType.new() + registry.register_step(0, &"legacy_zero_to_current", Callable(self, "_migration_package_zero_to_one")) + var result := SaveServiceType.migrate_save(save_path, registry, SaveServiceType.FORMAT_VERSION) + _expect(result[&"ok"] and result[&"backup_created"], "package migration verifies and promotes a current-format candidate") + _expect(result[&"applied_migration_ids"] == [&"legacy_zero_to_current"], "package migration returns its applied migration audit trail") + var current := SaveServiceType.load_snapshot(save_path) + _expect(current[&"ok"] and current[&"snapshot"][&"systems"][&"migrated_from"] == 0, "promoted package contains the pure migration output") + _expect(str(current[&"manifest"]["migration_note"]) == "zero-to-one", "migration preserves step-authored manifest metadata") + + var backup_path := ProjectSettings.globalize_path(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX) + _expect(DirAccess.dir_exists_absolute(backup_path), "migration creates a dedicated pre-migration backup package") + _expect(FileAccess.get_file_as_bytes(backup_path.path_join(SaveServiceType.WORLD_FILE)) == world_before, "pre-migration backup preserves world bytes exactly") + _expect(FileAccess.get_file_as_string(backup_path.path_join(SaveServiceType.MANIFEST_FILE)) == manifest_before, "pre-migration backup preserves the original manifest exactly") + var recovery_before_crash := SaveServiceType.inspect_recovery_candidates(save_path) + var migration_backup_info: Dictionary = recovery_before_crash[&"candidates"][3] + _expect(migration_backup_info[&"id"] == &"pre_migration_backup" and migration_backup_info[&"package_valid"] and migration_backup_info[&"requires_migration"], "recovery inventory retains a checksum-valid legacy migration backup without treating it as runtime-loadable") + var backup_old_path := backup_path + ".old" + _copy_fixture_tree(backup_path, backup_old_path) + _corrupt_world(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX) + var repaired_backup_inventory := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(repaired_backup_inventory[&"candidates"][3][&"package_valid"] and not DirAccess.dir_exists_absolute(backup_old_path), "recovery replaces a torn pre-migration backup from its verified durable predecessor") + + # Crash boundary: original was durably held and candidate was complete, but + # candidate had not yet been promoted to the formal name. + var candidate_path := package_path + ".migration.tmp" + var original_path := package_path + ".migration.original" + _copy_fixture_tree(package_path, candidate_path) + DirAccess.rename_absolute(package_path, original_path) + var recovered := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(recovered[&"ok"] and recovered[&"reconciliation"][&"migration"][&"action"] == &"completed_promotion", "recovery completes an interrupted migration promotion from durable markers") + _expect(SaveServiceType.load_snapshot(save_path)[&"ok"] and not DirAccess.dir_exists_absolute(original_path) and not DirAccess.dir_exists_absolute(candidate_path), "migration reconciliation verifies the formal package before clearing crash markers") + DirAccess.rename_absolute(package_path, original_path) + var rolled_back := SaveServiceType.inspect_recovery_candidates(save_path) + _expect(rolled_back[&"ok"] and rolled_back[&"reconciliation"][&"migration"][&"action"] == &"rolled_back_to_original", "recovery rolls back a held original when no promotable candidate exists") + _expect(SaveServiceType.load_snapshot(save_path)[&"ok"] and not DirAccess.dir_exists_absolute(original_path), "rollback restores the original formal name without losing the verified package") + SaveServiceType.delete_save(save_path) + + +func _test_migration_failure_preserves_source() -> void: + var save_path := "user://saves/s6_migration_failure" + SaveServiceType.delete_save(save_path) + SaveServiceType.save_snapshot(save_path, _snapshot(300, &"must_survive")) + _make_legacy_format_zero(save_path) + var package_path := ProjectSettings.globalize_path(save_path) + var world_before := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) + var manifest_before := FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) + var registry := MigrationRegistryType.new() + registry.register_step(0, &"reject_legacy", Callable(self, "_migration_reject")) + var result := SaveServiceType.migrate_save(save_path, registry, SaveServiceType.FORMAT_VERSION) + _expect(not result[&"ok"] and result[&"failed_migration_id"] == &"reject_legacy", "package migration exposes the failing step ID") + _expect(FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) == world_before, "failed step leaves the formal world byte-identical") + _expect(FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) == manifest_before, "failed step leaves the formal manifest byte-identical") + _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX)), "failed in-memory migration creates no misleading backup or partial promotion") + SaveServiceType.delete_save(save_path) + + +func _migration_add_first(data: Dictionary, from_version: int, to_version: int) -> Dictionary: + data[&"nested"][&"history"].append(&"one") + data[&"first_edge"] = [from_version, to_version] + return data + + +func _migration_add_second(data: Dictionary, from_version: int, to_version: int) -> Dictionary: + data[&"nested"][&"history"].append(&"two") + data[&"second_edge"] = [from_version, to_version] + return {&"ok": true, &"data": data} + + +func _migration_reject(data: Dictionary, _from_version: int, _to_version: int) -> Dictionary: + data[&"would_have_mutated"] = true + return {&"ok": false, &"code": &"FIXTURE_REJECTED", &"message": "intentional test rejection"} + + +func _migration_package_zero_to_one(data: Dictionary, from_version: int, to_version: int) -> Dictionary: + var legacy_snapshot: Dictionary = data[&"snapshot"] + var restored := _snapshot( + int(legacy_snapshot[&"legacy_absolute_minute"]), + StringName(legacy_snapshot[&"legacy_marker"]) + ) + restored[&"systems"][&"migrated_from"] = from_version + restored[&"systems"][&"migrated_to"] = to_version + data[&"snapshot"] = restored + data[&"manifest"][&"migration_note"] = "zero-to-one" + return data + + +func _make_legacy_format_zero(save_path: String, package_format_version := 0) -> void: + var package_path := ProjectSettings.globalize_path(save_path) + var current := SaveServiceType.load_snapshot(save_path) + var current_snapshot: Dictionary = current[&"snapshot"] + var legacy_snapshot := { + &"legacy_schema": 0, + &"legacy_absolute_minute": int(current_snapshot[&"clock"][&"absolute_minute"]), + &"legacy_marker": _marker(current_snapshot), + } + var raw_world := var_to_bytes(legacy_snapshot) + var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD) + var manifest_path := package_path.path_join(SaveServiceType.MANIFEST_FILE) + var manifest_value: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path)) + var manifest: Dictionary = manifest_value + manifest["format_version"] = package_format_version + manifest["checksum_world"] = _sha256(compressed_world) + manifest["uncompressed_size"] = raw_world.size() + var manifest_file := FileAccess.open(manifest_path, FileAccess.WRITE) + manifest_file.store_string(JSON.stringify(manifest, "\t")) + manifest_file = null + var world_path := package_path.path_join(SaveServiceType.WORLD_FILE) + var world_file := FileAccess.open(world_path, FileAccess.WRITE) + world_file.store_buffer(SaveServiceType.MAGIC.to_ascii_buffer()) + world_file.store_32(package_format_version) + world_file.store_64(raw_world.size()) + world_file.store_buffer(compressed_world) + world_file = null + + +func _copy_fixture_tree(source_path: String, destination_path: String) -> void: + DirAccess.make_dir_recursive_absolute(destination_path) + var source := DirAccess.open(source_path) + source.list_dir_begin() + var name := source.get_next() + while not name.is_empty(): + if name != "." and name != "..": + var source_child := source_path.path_join(name) + var destination_child := destination_path.path_join(name) + if source.current_is_dir(): + _copy_fixture_tree(source_child, destination_child) + else: + DirAccess.copy_absolute(source_child, destination_child) + name = source.get_next() + source.list_dir_end() + + +func _write_transaction_marker(target_absolute: String) -> void: + var marker := FileAccess.open(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX, FileAccess.WRITE) + marker.store_string(SaveServiceType.SAVE_TRANSACTION_MARKER) + marker = null + + +func _sha256(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(bytes) + return context.finish().hex_encode() + + +func _corrupt_world(save_path: String) -> void: + var world_path := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE) + var bytes := FileAccess.get_file_as_bytes(world_path) + if bytes.is_empty(): + return + bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0x5a + var file := FileAccess.open(world_path, FileAccess.WRITE) + file.store_buffer(bytes) + + +func _snapshot(absolute_minute: int, marker: StringName) -> Dictionary: + return { + &"schema_version": 1, + &"clock": { + &"day": 1 + absolute_minute / 1_440, + &"hour": (absolute_minute / 60) % 24, + &"minute": absolute_minute % 60, + &"absolute_minute": absolute_minute, + &"speed": 1, + &"paused": true, + }, + &"game_state": { + &"schema_version": 1, + &"revision": absolute_minute, + &"resources": {&"gold": 5_000}, + &"flags": {}, + }, + &"systems": { + &"marker": marker, + }, + } + + +func _marker(snapshot: Dictionary) -> StringName: + return StringName(snapshot[&"systems"][&"marker"]) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + return + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/s6/test_save_operations.gd.uid b/tests/s6/test_save_operations.gd.uid new file mode 100644 index 0000000..0bdbb18 --- /dev/null +++ b/tests/s6/test_save_operations.gd.uid @@ -0,0 +1 @@ +uid://davhixen8ndkv diff --git a/tests/tmp_operations_ui_diag.gd.uid b/tests/tmp_operations_ui_diag.gd.uid new file mode 100644 index 0000000..6225d6a --- /dev/null +++ b/tests/tmp_operations_ui_diag.gd.uid @@ -0,0 +1 @@ +uid://bm3pisyt1ry1m diff --git a/tests/ui/capture_main_dashboard.gd b/tests/ui/capture_main_dashboard.gd new file mode 100644 index 0000000..89da323 --- /dev/null +++ b/tests/ui/capture_main_dashboard.gd @@ -0,0 +1,54 @@ +extends SceneTree + +const DEFAULT_OUTPUT_PATH := "res://assets/mockups/the-tree-runtime-ui-v6-actors.png" + + +func _initialize() -> void: + call_deferred("_capture") + + +func _capture() -> void: + var capture_size := Vector2i( + maxi(int(OS.get_environment("THE_TREE_CAPTURE_WIDTH")), 1440), + maxi(int(OS.get_environment("THE_TREE_CAPTURE_HEIGHT")), 810) + ) + var output_path := OS.get_environment("THE_TREE_CAPTURE_PATH") + if output_path.is_empty(): + output_path = DEFAULT_OUTPUT_PATH + var capture_viewport := SubViewport.new() + capture_viewport.size = capture_size + capture_viewport.size_2d_override = Vector2i(1440, 810) + capture_viewport.size_2d_override_stretch = true + capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(capture_viewport) + var clock := root.get_node("GameClock") + var state := root.get_node("GameState") + var runtime := root.get_node("SimulationRuntime") + clock.reset(1, 6, 0) + state.reset() + runtime.initialize(clock, state, 424242) + clock.set_paused(true) + var main := load("res://scenes/main/main.tscn").instantiate() as Control + capture_viewport.add_child(main) + main.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + for frame: int in 8: + await process_frame + main.call("_hire_staff_candidate", &"staff_akane") + main.call("_select_build_definition", &"reception") + main.call("_on_map_cell_clicked", Vector3i(-6, 0, WorldService.LAYER_FRONT)) + clock.advance_minutes(120) + main.call("_clear_active_tool") + var facilities: Array[Dictionary] = runtime.construction_service.get_facilities() + if not facilities.is_empty(): + main.call("_on_facility_selected", StringName(facilities[0].get(&"instance_id", &""))) + for frame: int in 4: + await process_frame + var image := capture_viewport.get_texture().get_image() + if image == null: + push_error("Could not read the capture viewport texture.") + quit(ERR_CANT_ACQUIRE_RESOURCE) + return + var result := image.save_png(output_path) + if result != OK: + push_error("Could not save UI capture: %s" % error_string(result)) + quit(result) diff --git a/tests/ui/capture_main_dashboard.gd.uid b/tests/ui/capture_main_dashboard.gd.uid new file mode 100644 index 0000000..966d243 --- /dev/null +++ b/tests/ui/capture_main_dashboard.gd.uid @@ -0,0 +1 @@ +uid://denib08mi7jxr diff --git a/tests/ui/capture_pixel_tower.gd b/tests/ui/capture_pixel_tower.gd new file mode 100644 index 0000000..42002e1 --- /dev/null +++ b/tests/ui/capture_pixel_tower.gd @@ -0,0 +1,184 @@ +extends SceneTree + +## godot --path . --script res://tests/ui/capture_pixel_tower.gd +## THE_TREE_PIXEL_PLAY=1 keeps an interactive, disposable scenario open instead. +## THE_TREE_CAPTURE_SMOKE=1 validates the real scenario with --headless. +## Optional: THE_TREE_CAPTURE_PATH, WIDTH, HEIGHT, MINUTES, GAP_MINUTES, LAYER. +## THE_TREE_CAPTURE_ZOOM=1 (0.5..4); FOCUS_ROOMS=1 centers the built room area. +## THE_TREE_CAPTURE_ANIMATION=1 also saves 72 live frames at 12 fps. +## PATH names the first PNG; the second PNG and a state JSON use its basename. + +const Scenario := preload("res://scripts/ui/pixel_tower_demo.gd") +const DEFAULT_PATH := "res://artifacts/pixel-tower/pixel-tower.png" + +var _scenario: RefCounted +var _runtime: Variant +var _clock: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _clock = root.get_node("GameClock") + _runtime = root.get_node("SimulationRuntime") + var state: Variant = root.get_node("GameState") + _clock.reset(1, 6, 0) + state.reset() + var initialized: Dictionary = _runtime.initialize(_clock, state, 424242) + _clock.set_paused(true) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + if not bool(initialized.get(&"valid", false)): + push_error("Could not initialize pixel viewing scenario.") + quit(1) + return + _scenario = Scenario.new() + if not await _scenario.prepare(_runtime, _clock, self): + quit(1) + return + if not await _scenario.advance(_env_int("THE_TREE_CAPTURE_MINUTES", 83, 0, 180)): + quit(1) + return + if OS.get_environment("THE_TREE_CAPTURE_SMOKE") == "1": + print("PIXEL_SCENARIO ", JSON.stringify(_scenario.describe())) + quit(0) + return + var capture_size := Vector2i(_env_int("THE_TREE_CAPTURE_WIDTH", 1440, 1024, 7680), + _env_int("THE_TREE_CAPTURE_HEIGHT", 810, 640, 4320)) + var play := OS.get_environment("THE_TREE_PIXEL_PLAY") == "1" + var container: Node = root + var capture_viewport: SubViewport + if play: + root.size = capture_size + root.title = "The Tree · ドット絵の見学(保存なし)" + else: + capture_viewport = SubViewport.new() + capture_viewport.size = capture_size + capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(capture_viewport) + container = capture_viewport + var main := load("res://scenes/main/main.tscn").instantiate() as Control + if main == null or not main.has_method("_set_layer"): + push_error("Main scene did not load; run godot --headless --editor --path . --import first.") + quit(ERR_CANT_ACQUIRE_RESOURCE) + return + container.add_child(main) + main.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + if main.get("world_preview") == null: + push_error("Main scene failed to create the live world preview.") + quit(ERR_CANT_ACQUIRE_RESOURCE) + return + # main enables its production save policy in _ready; immediately turn it off + # for this disposable scenario and make the manual save/load controls inert. + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + for button: Node in main.find_children("*", "Button", true, false): + var save_control := button as Button + for connection: Dictionary in save_control.pressed.get_connections(): + var callback: Callable = connection[&"callable"] + if callback.get_method() in [&"_save_manual", &"_load_manual"]: + save_control.disabled = true + save_control.tooltip_text = "この見学モードは保存しません。" + var layer := _env_int("THE_TREE_CAPTURE_LAYER", 0, 0, 1) + main.call("_set_layer", layer) + main.call("_clear_active_tool") + for frame: int in 8: + await process_frame + var preview: Variant = main.get("world_preview") + preview.set_zoom(_env_float("THE_TREE_CAPTURE_ZOOM", 1.0, 0.5, 4.0)) + if OS.get_environment("THE_TREE_CAPTURE_FOCUS_ROOMS") == "1": + preview.pan_offset += preview.size * 0.5 - preview.cell_to_canvas(Vector3i(-2, 1, layer)) + preview.call("_clamp_pan") + preview.queue_redraw() + if play: + main.call("_set_status", "ドット絵の見学:実際の客・従業員・建設済み施設で運営中(保存なし)", true, true) + _clock.set_paused(false) + print("PIXEL_PLAY: disposable scenario ready; normal saves are disabled.") + return + var path := OS.get_environment("THE_TREE_CAPTURE_PATH") + if path.is_empty(): + path = DEFAULT_PATH + var absolute_path := ProjectSettings.globalize_path(path) + var directory_result := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) + if directory_result != OK: + push_error("Could not create capture directory.") + quit(directory_result) + return + var records: Array[Dictionary] = [] + for index: int in 2: + for frame: int in 8: + await process_frame + await RenderingServer.frame_post_draw + var image := capture_viewport.get_texture().get_image() + if image == null or image.is_empty(): + push_error("Capture needs a rendering display; use CAPTURE_SMOKE for headless validation.") + quit(ERR_CANT_ACQUIRE_RESOURCE) + return + var output := absolute_path if index == 0 else absolute_path.get_basename() + "-later.png" + var result := image.save_png(output) + if result != OK: + push_error("Could not write %s: %s" % [output, error_string(result)]) + quit(result) + return + var description: Dictionary = _scenario.describe() + description["image"] = output + description["tracked_actor_count"] = (preview.get("_actor_tracks") as Dictionary).size() + description["viewport_size"] = [capture_size.x, capture_size.y] + records.append(description) + print("PIXEL_CAPTURE: %s at %s" % [output, _clock.formatted_time()]) + if index == 0 and not await _scenario.advance(_env_int("THE_TREE_CAPTURE_GAP_MINUTES", 18, 1, 180)): + quit(1) + return + var animation: Dictionary = {} + if OS.get_environment("THE_TREE_CAPTURE_ANIMATION") == "1": + var frames_path := absolute_path.get_basename() + "-frames" + var frames_directory_result := DirAccess.make_dir_recursive_absolute(frames_path) + if frames_directory_result != OK: + push_error("Could not create animation frames directory.") + quit(frames_directory_result) + return + animation = {"directory": frames_path, "fps": 12, "frame_count": 72, + "start": _scenario.describe()} + _clock.set_speed(1) + _clock.set_paused(false) + main.call("_update_pause_and_speed") + # Normal real-time processing owns the clock and actor movement here. + # Never manually advance simulation ticks or animation time for a frame. + for frame: int in 72: + await create_timer(1.0 / 12.0).timeout + await RenderingServer.frame_post_draw + var frame_image := capture_viewport.get_texture().get_image() + if frame_image == null or frame_image.is_empty(): + _clock.set_paused(true) + push_error("Could not read a live animation frame.") + quit(ERR_CANT_ACQUIRE_RESOURCE) + return + var frame_output := frames_path.path_join("%03d.png" % frame) + var frame_result := frame_image.save_png(frame_output) + if frame_result != OK: + _clock.set_paused(true) + push_error("Could not write %s: %s" % [frame_output, error_string(frame_result)]) + quit(frame_result) + return + _clock.set_paused(true) + animation["end"] = _scenario.describe() + print("PIXEL_ANIMATION: %s (72 frames at 12 fps)" % frames_path) + var manifest := FileAccess.open(absolute_path.get_basename() + "-state.json", FileAccess.WRITE) + if manifest == null: + quit(FileAccess.get_open_error()) + return + var manifest_data := {"scenario": "disposable production simulation", "captures": records} + if not animation.is_empty(): + manifest_data["animation"] = animation + manifest.store_string(JSON.stringify(manifest_data, "\t")) + quit(0) + + +func _env_int(name: String, fallback: int, minimum: int, maximum: int) -> int: + var value := OS.get_environment(name) + return clampi(int(value), minimum, maximum) if value.is_valid_int() else fallback + + +func _env_float(name: String, fallback: float, minimum: float, maximum: float) -> float: + var value := OS.get_environment(name) + return clampf(float(value), minimum, maximum) if value.is_valid_float() else fallback diff --git a/tests/ui/capture_pixel_tower.gd.uid b/tests/ui/capture_pixel_tower.gd.uid new file mode 100644 index 0000000..9ba5fb6 --- /dev/null +++ b/tests/ui/capture_pixel_tower.gd.uid @@ -0,0 +1 @@ +uid://cxh2wipu8gjrv diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd new file mode 100644 index 0000000..a72e191 --- /dev/null +++ b/tests/ui/test_main_dashboard.gd @@ -0,0 +1,482 @@ +extends SceneTree + +const MANUAL_SAVE_PATH := "user://saves/manual_ui" + +var _checks := 0 +var _failures := 0 +var _main: Control +var _preview: WorldPreview +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _runtime = root.get_node("SimulationRuntime") + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 424242) + _clock.set_paused(true) + root.size = Vector2i(1440, 810) + _expect( + ProjectSettings.get_setting("display/window/stretch/mode") == "canvas_items" + and ProjectSettings.get_setting("display/window/stretch/aspect") == "expand" + and bool(ProjectSettings.get_setting("display/window/dpi/allow_hidpi")), + "project uses DPI-aware canvas scaling for high-resolution displays" + ) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + root.add_child(_main) + await process_frame + await process_frame + _preview = _main.find_child("WorldPreview", true, false) as WorldPreview + + _expect(_preview != null, "dashboard creates the live world preview") + var visual_theme := _main.get("visual_theme") as VisualThemeProfile + _expect( + visual_theme != null + and visual_theme.world_background_for_layer(WorldPreview.LayerMode.FRONT) != null + and visual_theme.world_background_for_layer(WorldPreview.LayerMode.BACK) != null, + "replaceable visual theme supplies independent FRONT and BACK art" + ) + _expect( + _preview.visual_theme == visual_theme, + "world preview consumes the scene theme instead of hard-coded asset paths" + ) + _expect( + visual_theme.facility_icons.size() == 16 + and visual_theme.navigation_icons.size() == 6 + and visual_theme.resource_icons.size() == 4 + and visual_theme.control_icons.size() == 6, + "replaceable visual theme provides complete semantic icon assignments" + ) + _expect( + visual_theme.guest_actor_marker(&"mage_elf") != null + and visual_theme.guest_actor_marker(&"legacy_guest") != null + and visual_theme.staff_actor_marker(&"guard") != null + and visual_theme.staff_actor_marker(&"legacy_role") != null, + "visual theme resolves class, role, and fallback actor markers" + ) + _expect( + visual_theme.ui_skin_textures.size() == 16 + and visual_theme.ui_skin(&"panel_parchment") != null + and visual_theme.ui_skin(&"button_pressed") != null + and visual_theme.ui_skin(&"queue_badge") != null + and visual_theme.ui_skin(&"construction_hatch") != null, + "visual theme provides replaceable panel, button, badge, route, and construction skins" + ) + for texture_path: String in [ + "res://assets/themes/japanese_fantasy/world-front-v1.png.import", + "res://assets/themes/japanese_fantasy/world-back-v1.png.import", + "res://assets/themes/japanese_fantasy/facility-icons-v2.png.import", + "res://assets/themes/japanese_fantasy/system-icons-v2.png.import", + "res://assets/themes/japanese_fantasy/actor-markers-v2.png.import", + "res://assets/themes/japanese_fantasy/ui-skin-v1.png.import", + ]: + var import_config := ConfigFile.new() + _expect( + import_config.load(texture_path) == OK + and bool(import_config.get_value("params", "mipmaps/generate", false)), + "high-resolution texture enables mipmaps: %s" % texture_path.get_file() + ) + var operations: Dictionary = _runtime.get_operations_report() + _expect(bool(operations.get(&"enabled", false)), "dashboard enables the production Operations loop") + var summary := _main.get("dashboard_summary_label") as Label + _expect( + summary != null and "行動点" in summary.text and "未払給与" in summary.text, + "dashboard exposes action points and payroll arrears" + ) + var palette := _main.find_child("BuildPalette", true, false) as GridContainer + _expect(palette.get_child_count() == 16, "all sixteen MVP facilities appear in the build palette") + var iconized_build_buttons := 0 + for build_button_value: Variant in (_main.get("build_buttons") as Dictionary).values(): + if (build_button_value as Button).icon != null: + iconized_build_buttons += 1 + _expect(iconized_build_buttons == 16, "all facility catalog buttons consume theme-provided icons") + var palette_scroll := _main.find_child("BuildPaletteScroll", true, false) as ScrollContainer + _expect( + palette_scroll != null and palette_scroll.is_ancestor_of(palette), + "construction catalog lives in the persistent bottom palette" + ) + var navigation := _main.find_child("NavigationRail", true, false) as Control + _expect( + navigation != null and (_main.get("navigation_buttons") as Dictionary).size() == 6, + "dashboard exposes the six Japanese management categories" + ) + var iconized_navigation_buttons := 0 + for navigation_button_value: Variant in (_main.get("navigation_buttons") as Dictionary).values(): + if (navigation_button_value as Button).icon != null: + iconized_navigation_buttons += 1 + _expect(iconized_navigation_buttons == 6, "all management categories consume theme-provided icons") + var resource_icon_nodes := _main.get("resource_icon_nodes") as Dictionary + _expect( + resource_icon_nodes.size() == 4 + and (resource_icon_nodes.get(&"gold") as TextureRect).texture != null + and (resource_icon_nodes.get(&"mana") as TextureRect).texture != null, + "top HUD consumes replaceable resource icons" + ) + var alert_icon := _main.find_child("AlertIcon", true, false) as TextureRect + _expect(alert_icon != null and alert_icon.texture != null, "top HUD consumes the theme warning icon") + var selected_icon := _main.find_child("SelectedEntityIcon", true, false) as TextureRect + _expect(selected_icon != null, "facility inspector reserves a theme-driven identity icon") + var guest_count := _main.get("guest_count_label") as Label + var lodging_count := _main.get("lodging_count_label") as Label + var alert_count := _main.get("alert_count_label") as Label + _expect( + guest_count != null and "来客" in guest_count.text + and lodging_count != null and "宿泊" in lodging_count.text + and alert_count != null and "警戒" in alert_count.text, + "top HUD keeps visitor, lodging, and alert information visible" + ) + _expect((_main.get("build_buttons") as Dictionary).has(&"guest_elevator"), "connector proxy definitions are exposed for vertical-layout prototyping") + _expect((_main.get("build_buttons") as Dictionary).has(&"adult_private_room"), "the full MVP catalog is reachable from the scrolling palette") + await _check_layout(Vector2i(1440, 810)) + await _check_layout(Vector2i(1280, 720)) + await _check_layout(Vector2i(1920, 1080)) + await _check_layout(Vector2i(2560, 1440)) + await _check_layout(Vector2i(3840, 2160)) + _test_staff_candidate_hiring_ui() + _test_excavation_rank_gate_and_tool_exclusion() + _test_coordinate_round_trip() + _test_build_payload_and_hit_test() + _test_excavation_preview_payload_and_start() + _test_manual_save_and_load() + _test_event_choice_ui() + + if is_instance_valid(_main): + _main.queue_free() + SaveService.delete_save(MANUAL_SAVE_PATH) + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _check_layout(window_size: Vector2i) -> void: + root.size = window_size + await process_frame + await process_frame + var side := _main.find_child("SidePanel", true, false) as Control + var map_panel := _main.find_child("MapPanel", true, false) as Control + var bottom := _main.find_child("BottomBar", true, false) as Control + var navigation := _main.find_child("NavigationRail", true, false) as Control + var excavation_button := _main.find_child("ExcavationButton", true, false) as Button + _expect(side != null and side.size.x >= 300.0, "%dx%d keeps the operations sidebar readable" % [window_size.x, window_size.y]) + _expect(map_panel != null and map_panel.size.x >= 600.0 and map_panel.size.y >= 380.0, "%dx%d keeps a usable map area" % [window_size.x, window_size.y]) + _expect( + navigation != null and navigation.position.x < map_panel.position.x and side.position.x > map_panel.position.x, + "%dx%d keeps navigation left and facility inspection right of the living map" % [window_size.x, window_size.y] + ) + _expect(bottom != null and bottom.position.y + bottom.size.y <= _main.size.y + 1.0, "%dx%d keeps all controls inside the viewport" % [window_size.x, window_size.y]) + _expect( + excavation_button != null + and excavation_button.position.x + excavation_button.size.x <= bottom.size.x + 1.0, + "%dx%d keeps the excavation control inside the bottom bar" % [window_size.x, window_size.y] + ) + + +func _test_staff_candidate_hiring_ui() -> void: + var panel := _main.find_child("StaffCandidatePanel", true, false) as VBoxContainer + var list := _main.find_child("StaffCandidateList", true, false) as VBoxContainer + var toggle := _main.find_child("StaffCandidateToggleButton", true, false) as Button + var buttons := _main.get("staff_candidate_buttons") as Dictionary + _expect(panel != null and not panel.visible, "staff candidate screen starts collapsed") + _expect(list != null and list.get_child_count() == 20, "all twenty validated Staff candidates have UI cards") + _expect(toggle != null and "20" in toggle.text, "collapsed hiring control exposes the available candidate count") + _expect(buttons.size() == 20 and buttons.has(&"staff_akane"), "every candidate has a stable hire control") + var akane_detail := _main.find_child("CandidateDetail_staff_akane", true, false) as Label + _expect( + akane_detail != null + and "アカネ" in akane_detail.text + and "契約 G180" in akane_detail.text + and "日給 G120" in akane_detail.text, + "candidate card shows Japanese identity and both signing and payroll costs" + ) + + _main.call("_unhandled_key_input", _key_event(KEY_H)) + _expect(panel.visible and bool(_main.get("_staff_candidate_panel_open")), "H opens the Staff candidate screen") + var cash_before: int = int(_runtime.economy_service.get_balance(&"PLAYER_CASH")) + _main.call("_hire_staff_candidate", &"staff_akane") + _expect(_runtime.get_hired_staff_candidate_ids().has(&"staff_akane"), "paused UI hire commits the selected canonical candidate immediately") + _expect(_runtime.staff_service.has_staff(&"staff_akane"), "UI hire creates the Staff member through Runtime") + _expect( + _runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 180, + "UI hire charges the catalog signing cost exactly once" + ) + var akane_button := buttons.get(&"staff_akane") as Button + _expect(akane_button != null and akane_button.disabled and akane_button.text == "雇用済み", "hired candidate is visibly final and cannot be clicked again") + var after_first_hire: int = int(_runtime.economy_service.get_balance(&"PLAYER_CASH")) + _main.call("_hire_staff_candidate", &"staff_akane") + _expect( + _runtime.economy_service.get_balance(&"PLAYER_CASH") == after_first_hire + and _runtime.get_hired_staff_candidate_ids().count(&"staff_akane") == 1, + "repeated UI hire does not double-charge or duplicate the employee" + ) + + _clock.set_paused(false) + _main.call("_hire_staff_candidate", &"staff_bardo") + var pending := _runtime.command_queue.create_snapshot().get("pending", []) as Array + _expect(pending.size() == 1, "running-clock Staff hire is queued before mutation") + _main.call("_hire_staff_candidate", &"staff_bardo") + _expect( + (_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1, + "pending Staff hire suppresses repeated clicks" + ) + _clock.advance_minutes(1) + _expect( + _runtime.get_hired_staff_candidate_ids().has(&"staff_bardo") + and (_main.get("_pending_hire_commands") as Dictionary).is_empty(), + "queued Staff hire completes and clears its UI pending state" + ) + _clock.set_paused(true) + var candidate_summary := _main.find_child("StaffCandidateSummary", true, false) as Label + _expect(candidate_summary != null and "雇用済み 2人" in candidate_summary.text, "candidate screen refreshes its hired count from Runtime state") + _main.set("_hire_sequence", 1) + _main.call("_synchronize_ui_command_state") + _expect( + int(_main.get("_hire_sequence")) >= 3, + "saved Command metadata advances the UI hire-attempt sequence after reconstruction" + ) + _main.call("_unhandled_key_input", _key_event(KEY_ESCAPE)) + _expect(not panel.visible and not bool(_main.get("_staff_candidate_panel_open")), "Escape closes the Staff candidate screen") + + +func _test_excavation_rank_gate_and_tool_exclusion() -> void: + _main.call("_unhandled_key_input", _key_event(KEY_E)) + var status := _main.get("build_status_label") as Label + var excavation_button := _main.get("excavation_button") as Button + _expect(bool(_main.get("_excavation_mode")), "E enables the explicit excavation mode") + _expect(_preview.excavation_mode, "dashboard and map share the excavation-mode state") + _expect(excavation_button != null and excavation_button.button_pressed, "excavation toggle visibly reflects the keyboard shortcut") + _expect(status != null and "ランク2" in status.text, "Rank 1 excavation gate is explained honestly in Japanese") + + _main.call("_select_build_definition", &"reception") + _expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "choosing a facility exits excavation mode") + _main.call("_unhandled_key_input", _key_event(KEY_E)) + _expect(_main.get("_selected_definition") == null, "entering excavation mode clears the facility build selection") + var reception_button := (_main.get("build_buttons") as Dictionary).get(&"reception") as Button + _expect(reception_button != null and not reception_button.button_pressed, "exclusive excavation mode clears the palette toggle") + _main.call("_unhandled_key_input", _key_event(KEY_ESCAPE)) + _expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "Escape exits excavation mode") + + +func _test_coordinate_round_trip() -> void: + _preview.layer_mode = WorldPreview.LayerMode.FRONT + for cell: Vector3i in [Vector3i(-12, 0, 0), Vector3i(0, 3, 0), Vector3i(12, 6, 0)]: + var restored: Variant = _preview.canvas_to_cell(_preview.cell_to_canvas(cell)) + _expect(restored == cell, "FRONT cell-to-canvas round trip preserves %s" % cell) + _preview.layer_mode = WorldPreview.LayerMode.BACK + var back_cell := Vector3i(4, 0, 1) + _expect(_preview.canvas_to_cell(_preview.cell_to_canvas(back_cell)) == back_cell, "BACK layer coordinate round trip preserves the layer") + _expect(_preview.canvas_to_cell(Vector2(-10, -10)) == null, "points outside the map do not produce build cells") + + +func _test_build_payload_and_hit_test() -> void: + _main.call("_select_build_definition", &"reception") + var origin := Vector3i(-6, 0, WorldService.LAYER_FRONT) + var preview_report: Dictionary = _preview.get_build_preview_report(origin) + _expect(bool(preview_report.get(&"can_build", false)), "starter reception shows a valid authoritative placement preview") + var jobs_before: int = _runtime.construction_service.get_jobs().size() + _main.call("_on_map_cell_clicked", origin) + var jobs: Array = _runtime.construction_service.get_jobs() + _expect(jobs.size() == jobs_before + 1, "map click submits one build_facility command") + var job: Variant = jobs.back() + _expect(job.definition_id == &"reception" and job.origin == origin and job.rotation == 0, "build command payload preserves definition, cell, and rotation") + _main.call("_on_map_cell_clicked", origin) + _expect(_runtime.construction_service.get_jobs().size() == jobs.size(), "invalid overlap preview does not submit a second command") + _clock.advance_minutes(120) + var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities() + _expect(facilities.size() == 1, "dashboard-created construction completes in simulation time") + if facilities.is_empty(): + return + var instance_id := StringName(facilities[0].get(&"instance_id", &"")) + var facility_point := _preview.cell_to_canvas(origin) + _expect(_preview.facility_at_canvas(facility_point) == instance_id, "facility hit test resolves the completed world occupant") + _main.call("_on_facility_selected", instance_id) + var detail := _main.get("selected_detail_label") as Label + _expect(detail != null and "待機列" in detail.text and "配置人員" in detail.text, "facility selection exposes queue, service, and staffing state") + var selected_icon := _main.get("selected_entity_icon") as TextureRect + _expect(selected_icon != null and selected_icon.texture != null, "facility selection exposes its theme icon in the inspector") + + +func _test_excavation_preview_payload_and_start() -> void: + _expect(_promote_rank_two(), "UI fixture reaches Rank 2 through public progression commands") + _main.call("_set_layer", WorldPreview.LayerMode.FRONT) + _main.call("_set_excavation_mode", true) + var first_cell := Vector3i(0, -1, WorldPreview.LayerMode.FRONT) + _preview.call("_set_hovered_cell", first_cell) + var report := _preview.get_excavation_preview_report(first_cell) + _expect(bool(report.get(&"ok", false)), "underground hover uses the authoritative excavation validator") + var preview_cells: Variant = report.get(&"cells", []) + _expect( + preview_cells is Array + and preview_cells.has(Vector3i(0, -1, WorldPreview.LayerMode.FRONT)) + and preview_cells.has(Vector3i(0, -1, WorldPreview.LayerMode.BACK)), + "excavation preview identifies both FRONT and BACK target cells" + ) + var status := _main.get("build_status_label") as Label + _expect( + status != null and "掘削可能" in status.text and "費用" in status.text and "所要" in status.text, + "valid excavation preview shows Japanese cost and duration" + ) + _main.call("_set_layer", WorldPreview.LayerMode.BACK) + _expect( + (_preview.get("_last_excavation_preview_report") as Dictionary).get(&"ok", false), + "changing layer recomputes the active-layer excavation preview" + ) + _main.call("_set_layer", WorldPreview.LayerMode.FRONT) + _preview.call("_set_hovered_cell", first_cell) + + var jobs_before: int = _runtime.excavation_service.get_jobs().size() + _clock.set_paused(false) + _main.call("_on_map_cell_clicked", first_cell) + var pending_snapshot: Dictionary = _runtime.command_queue.create_snapshot() + var pending: Array = pending_snapshot.get("pending", []) + _expect(_runtime.excavation_service.get_jobs().size() == jobs_before, "running clock queues the excavation command before mutation") + _expect(pending.size() == 1, "map click queues exactly one start_excavation command") + if not pending.is_empty(): + var command: Dictionary = pending.back() + var payload: Variant = command.get("payload", {}) + _expect( + StringName(command.get("command_type", "")) == &"start_excavation" + and payload is Dictionary + and payload.get(&"cell", null) == first_cell, + "excavation command payload preserves the hovered layer cell" + ) + _expect( + StringName(command.get("idempotency_key", "")) == &"ui.excavation.0.-1.00000001", + "excavation uses a stable per-attempt idempotency key" + ) + _expect(status != null and "受理しました" in status.text, "queued excavation reports command acceptance") + _main.call("_on_map_cell_clicked", first_cell) + _expect( + (_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1, + "a repeated click cannot queue a second excavation attempt while the first is pending" + ) + _clock.advance_minutes(1) + var first_jobs: Array[Dictionary] = _runtime.excavation_service.get_jobs() + _expect(first_jobs.size() == jobs_before + 1, "queued excavation starts on the next Runtime command phase") + _expect((_main.get("_pending_excavation_commands") as Dictionary).is_empty(), "queued completion clears the UI pending-command entry") + _expect(status != null and "開始しました" in status.text, "queued command completion reports the actual start") + if first_jobs.is_empty(): + _clock.set_paused(true) + return + var first_job: Dictionary = first_jobs.back() + _expect( + first_job.get(&"position", Vector2i.ZERO) == Vector2i(0, -1) + and int(first_job.get(&"progress", 0)) > 0, + "started excavation is visible and progresses through simulation time" + ) + _clock.set_paused(true) + _clock.advance_minutes(int(first_job.get(&"build_minutes", 0))) + + var second_cell := Vector3i(0, -2, WorldPreview.LayerMode.FRONT) + _preview.call("_set_hovered_cell", second_cell) + var second_report := _preview.get_excavation_preview_report(second_cell) + _expect(bool(second_report.get(&"ok", false)), "completed excavation unlocks the next connected underground cell") + var jobs_before_immediate: int = _runtime.excavation_service.get_jobs().size() + _main.call("_on_map_cell_clicked", second_cell) + _expect( + _runtime.excavation_service.get_jobs().size() == jobs_before_immediate + 1, + "paused clock executes an accepted excavation command immediately" + ) + var detail := _main.get("selected_detail_label") as Label + _expect( + detail != null and "進捗" in detail.text and "FRONT / 表層・BACK / 裏層" in detail.text, + "started excavation exposes progress and both target layers in selection detail" + ) + _main.call("_unhandled_key_input", _key_event(KEY_ESCAPE)) + _expect(not bool(_main.get("_excavation_mode")), "Escape still exits excavation mode after starting a job") + + +func _promote_rank_two() -> bool: + var reputation: Variant = _runtime.submit_command( + &"update_progression_metrics", + {&"metrics": {&"reputation": 25}}, + &"player", &"", 100, -1, &"ui.excavation.rank.reputation" + ) + var capital: Variant = _runtime.submit_command( + &"change_resources", + {&"deltas": {&"gold": maxi(10_000 - _runtime.economy_service.get_balance(&"PLAYER_CASH"), 0)}}, + &"player", &"", 100, -1, &"ui.excavation.rank.capital" + ) + var promoted: Variant = _runtime.submit_command( + &"set_world_tree_rank", + {&"rank": 2}, + &"player", &"", 100, -1, &"ui.excavation.rank.promote" + ) + return reputation.is_success() and capital.is_success() and promoted.is_success() \ + and _state.world_tree_rank == 2 + + +func _test_manual_save_and_load() -> void: + SaveService.delete_save(MANUAL_SAVE_PATH) + _main.call("_save_manual") + var status := _main.get("build_status_label") as Label + _expect(status != null and "保存しました" in status.text, "manual save button writes the runtime snapshot") + var expected_gold: int = _state.get_resource(&"gold") + _state.set_resource(&"gold", expected_gold + 7) + _main.call("_load_manual") + _expect(_state.get_resource(&"gold") == expected_gold, "manual load button restores the saved simulation") + _expect(status != null and "読み込みました" in status.text, "manual load reports success in Japanese") + + +func _test_event_choice_ui() -> void: + var event_service: Variant = _runtime.event_service + var queued: Variant = event_service.enqueue_candidate( + &"relationship.threshold", + [], + _clock.absolute_minute, + {&"deduplication_key": &"ui-event-choice-test"} + ) + _expect(queued.is_success(), "UI fixture enqueues a foreground relationship Event") + var started: Variant = event_service.start_next_event(_clock.absolute_minute, {}) + _expect( + started.is_success() and bool(started.details.get(&"waiting_for_choice", false)), + "UI fixture starts an Event waiting for a Choice" + ) + _clock.set_paused(true) + _main.call("_refresh_dashboard") + var panel := _main.find_child("ForegroundEventPanel", true, false) as VBoxContainer + var choices := _main.find_child("ForegroundEventChoices", true, false) as VBoxContainer + _expect(panel != null and panel.visible, "foreground Event opens the decision panel") + _expect(choices != null and choices.get_child_count() == 2, "all two canonical Event choices are visible") + var pause_button := _main.get("pause_button") as Button + _expect( + pause_button != null and pause_button.disabled and "イベント" in pause_button.text, + "unresolved foreground Event cannot be bypassed with the pause control" + ) + if choices == null or choices.get_child_count() != 2: + return + var first_choice := choices.get_child(0) as Button + _expect( + first_choice != null and not first_choice.text.is_empty(), + "Event choice uses a readable Japanese button" + ) + first_choice.emit_signal(&"pressed") + _expect(event_service.get_foreground_instance() == null, "choice button resolves the foreground Event exactly once") + _expect(not _clock.paused, "resolved foreground Event releases the simulation pause") + _expect(not panel.visible and choices.get_child_count() == 0, "resolved Event removes the stale choice controls") + _expect(not pause_button.disabled, "resolved Event restores the normal pause control") + _clock.set_paused(true) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if condition: + return + _failures += 1 + push_error(message) + + +func _key_event(keycode: Key) -> InputEventKey: + var event := InputEventKey.new() + event.keycode = keycode + event.pressed = true + return event diff --git a/tests/ui/test_main_dashboard.gd.uid b/tests/ui/test_main_dashboard.gd.uid new file mode 100644 index 0000000..8b6ed32 --- /dev/null +++ b/tests/ui/test_main_dashboard.gd.uid @@ -0,0 +1 @@ +uid://c01w34xtnhfvm diff --git a/tests/ui/test_pixel_tower.gd b/tests/ui/test_pixel_tower.gd new file mode 100644 index 0000000..364335a --- /dev/null +++ b/tests/ui/test_pixel_tower.gd @@ -0,0 +1,284 @@ +extends SceneTree + +## godot --headless --path . --script res://tests/ui/test_pixel_tower.gd +## Camera coverage uses actual Runtime construction and World occupants: a +## misplaced pointer must not select the next cell, another floor, or BACK. + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant +var _preview: WorldPreview +var _last_facility: StringName = &"" +var _last_construction: StringName = &"" +var _last_cell: Variant = null + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _runtime = root.get_node("SimulationRuntime") + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 724242) + _clock.set_paused(true) + root.size = Vector2i(1280, 720) + _preview = WorldPreview.new() + _preview.size = Vector2(1100, 600) + _preview.set_runtime(_runtime) + var pixel_theme := load("res://assets/themes/japanese_fantasy/visual-theme-v1.tres") as VisualThemeProfile + _expect(pixel_theme != null and pixel_theme.pixel_dungeon_tiles != null + and pixel_theme.pixel_town_tiles != null, + "serialized theme loads both real Kenney atlases for actors and scenery") + if pixel_theme != null: + _preview.set_visual_theme(pixel_theme) + if pixel_theme.pixel_dungeon_tiles != null: + _expect(pixel_theme.pixel_dungeon_tiles.get_size() == Vector2(192, 176), + "packed actor atlas matches the 12-column source regions") + root.add_child(_preview) + _preview.set_process(false) + _preview.facility_selected.connect(_on_facility_selected) + _preview.construction_selected.connect(_on_construction_selected) + _preview.cell_clicked.connect(_on_cell_clicked) + await process_frame + await process_frame + + _test_zoom_anchor_and_reset() + _test_construction_and_facility_clicks() + _test_expanded_world_round_trips() + _test_grid_preserves_hit_testing() + _test_animation_pause_and_simulation_isolation() + + _preview.queue_free() + await process_frame + if _failures == 0: + print("PASS: %d pixel tower checks" % _checks) + else: + push_error("FAIL: %d of %d pixel tower checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_zoom_anchor_and_reset() -> void: + _preview.reset_view() + var overview := _preview.get_map_rect() + var anchor_cell := Vector3i(-4, 2, WorldPreview.LayerMode.FRONT) + var anchor := _preview.cell_to_canvas(anchor_cell) + _preview.set_zoom(2.0, anchor) + _expect(_preview.canvas_to_cell(anchor) == anchor_cell, + "zoom anchored at a room keeps that room under the pointer") + _expect(_preview.cell_to_canvas(anchor_cell).distance_to(anchor) <= 2.0, + "cursor-anchored zoom keeps the same location within the room") + _expect(_preview.get_cell_rect(anchor_cell).size.y > _preview.get_cell_rect(anchor_cell).size.x, + "tower floors retain the rectangular cell aspect used by hit testing") + _preview.pan_offset += Vector2(137, -73) + _preview.reset_view() + var restored := _preview.get_map_rect() + _expect(restored.position.is_equal_approx(overview.position) and restored.size.is_equal_approx(overview.size), + "reset returns to the original full overview after both pan and zoom") + _expect(_preview.canvas_to_cell(_preview.cell_to_canvas(anchor_cell)) == anchor_cell, + "reset keeps authoritative world coordinates selectable") + + +func _test_construction_and_facility_clicks() -> void: + var origin := Vector3i(-6, 0, WorldPreview.LayerMode.FRONT) + var result: Variant = _runtime.submit_command( + &"build_facility", {&"definition_id": &"reception", &"origin": origin, &"rotation": 0}, + &"player", &"", 50, -1, &"pixel.camera.reception" + ) + _expect(result != null and result.is_success(), "camera fixture builds a reception through Runtime") + var jobs: Array = _runtime.construction_service.get_jobs() + _expect(jobs.size() == 1, "fixture creates exactly one real construction site") + if jobs.size() != 1: + return + var job: Variant = jobs[0] + _preview.layer_mode = WorldPreview.LayerMode.FRONT + _preview.set_zoom(2.0) + _center_on(origin) + _click(_preview.cell_to_canvas(origin)) + _expect(_last_construction == StringName(job.job_id) and _last_facility.is_empty(), + "zoomed construction click resolves the real job rather than a finished facility") + _clock.advance_minutes(120) + var facilities: Array = _runtime.construction_service.get_facilities() + _expect(facilities.size() == 1, "the clicked construction completes in simulation time") + if facilities.size() != 1: + return + var facility_id := StringName(facilities[0].get(&"instance_id", &"")) + var occupied: Array[Vector3i] = [] + for x: int in range(4): + for y: int in range(2): + occupied.append(origin + Vector3i(x, y, 0)) + for zoom: float in [1.0, 2.0, 3.0]: + _preview.set_zoom(zoom) + _center_on(origin + Vector3i(1, 1, 0)) + for cell: Vector3i in occupied: + var rect := _preview.get_cell_rect(cell) + # Sample near opposite corners to catch square-cell conversions that + # look correct at the center but select a different floor near edges. + for fraction: Vector2 in [Vector2(0.15, 0.15), Vector2(0.85, 0.85)]: + var point := rect.position + rect.size * fraction + _expect(_preview.canvas_to_cell(point) == cell, + "zoom %.1f maps interior point to occupied cell %s" % [zoom, cell]) + _expect(_preview.facility_at_canvas(point) == facility_id, + "zoom %.1f resolves reception footprint at %s" % [zoom, cell]) + _click(point) + _expect(_last_facility == facility_id and _last_construction.is_empty(), + "zoom %.1f emits facility selection for the clicked interior" % zoom) + var empty_neighbor := origin + Vector3i(4, 0, 0) + var empty_point := _preview.cell_to_canvas(empty_neighbor) + _click(empty_point) + _expect(_last_facility.is_empty() and _last_cell == empty_neighbor, + "zoom %.1f does not extend the reception hit area into the adjacent cell" % zoom) + var front_point := _preview.cell_to_canvas(origin) + _preview.layer_mode = WorldPreview.LayerMode.BACK + _click(front_point) + _expect(_last_facility.is_empty() and _last_cell == Vector3i(origin.x, origin.y, WorldPreview.LayerMode.BACK), + "zoom %.1f cannot select the FRONT reception through the BACK layer" % zoom) + _preview.layer_mode = WorldPreview.LayerMode.FRONT + _click(_preview.cell_to_canvas(origin)) + _expect(_last_facility == facility_id, + "zoom %.1f restores reception selection after returning to FRONT" % zoom) + + +func _test_expanded_world_round_trips() -> void: + var reputation: Variant = _runtime.submit_command( + &"update_progression_metrics", {&"metrics": {&"reputation": 25}}, + &"player", &"", 100, -1, &"pixel.camera.rank.reputation" + ) + var capital: Variant = _runtime.submit_command( + &"change_resources", {&"deltas": {&"gold": 10000}}, + &"player", &"", 100, -1, &"pixel.camera.rank.capital" + ) + var promoted: Variant = _runtime.submit_command( + &"set_world_tree_rank", {&"rank": 2}, + &"player", &"", 100, -1, &"pixel.camera.rank.promote" + ) + _expect(reputation.is_success() and capital.is_success() and promoted.is_success(), + "public progression commands expand the camera fixture above and below ground") + if not promoted.is_success(): + return + var bounds: Dictionary = _runtime.world_service.get_bounds() + var cells: Array[Vector3i] = [ + Vector3i.ZERO, + Vector3i(0, -1, 0), + Vector3i(int(bounds[&"min_x"]), int(bounds[&"min_y"]), 0), + Vector3i(int(bounds[&"max_x"]), int(bounds[&"max_y"]), 0), + Vector3i(-3, 4, 0), + ] + for layer: int in [WorldPreview.LayerMode.FRONT, WorldPreview.LayerMode.BACK]: + _preview.layer_mode = layer as WorldPreview.LayerMode + for zoom: float in [1.0, 2.0, 3.0]: + _preview.set_zoom(zoom) + for cell: Vector3i in cells: + cell.z = layer + _center_on(cell) + _preview.pan_offset += Vector2(31, -17) + var point := _preview.cell_to_canvas(cell) + _expect(_preview.canvas_to_cell(point) == cell, + "zoom %.1f round trip survives pan, bounds, and layer at %s" % [zoom, cell]) + var rect := _preview.get_cell_rect(cell) + _expect(rect.has_point(point), "cell center remains inside its rendered rectangle") + _preview.reset_view() + var overview := _preview.get_map_rect() + _expect(_preview.canvas_to_cell(overview.position - Vector2.ONE * 2.0) == null, + "outside-world pixels cannot become buildable cells after overview reset") + + +func _test_grid_preserves_hit_testing() -> void: + _preview.layer_mode = WorldPreview.LayerMode.FRONT + var cell := Vector3i(-5, 1, 0) + _center_on(cell) + var point := _preview.cell_to_canvas(cell) + var expected := _preview.facility_at_canvas(point) + _expect(not expected.is_empty(), "grid fixture points at a real reception cell") + for show: bool in [false, true, false]: + _preview.show_grid = show + _click(point) + _expect(_last_facility == expected and _preview.canvas_to_cell(point) == cell, + "grid visibility never changes facility or floor hit testing") + + +func _test_animation_pause_and_simulation_isolation() -> void: + var before_digest := SimulationDigest.compute(_runtime.create_snapshot()) + var before_minute: int = _clock.absolute_minute + var stopped_at := float(_preview.get("_animation_time")) + _preview.call("_process", 0.5) + _expect(is_equal_approx(float(_preview.get("_animation_time")), stopped_at), + "paused simulation freezes actor animation even when rendering receives a frame") + _clock.set_paused(false) + _preview.call("_process", 0.25) + var animated_at := float(_preview.get("_animation_time")) + _expect(animated_at > stopped_at, "unpausing resumes the pixel actor animation clock") + _clock.set_paused(true) + _preview.call("_process", 0.5) + _expect(is_equal_approx(float(_preview.get("_animation_time")), animated_at), + "pausing again freezes the exact resumed animation position") + + var cell := Vector3i(-5, 1, 0) + _center_on(cell) + var anchor := _preview.cell_to_canvas(cell) + var wheel := InputEventMouseButton.new() + wheel.button_index = MOUSE_BUTTON_WHEEL_UP + wheel.pressed = true + wheel.position = anchor + _preview.call("_gui_input", wheel) + _expect(_preview.canvas_to_cell(anchor) == cell, + "wheel input preserves the room under the cursor while paused") + var press := InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_MIDDLE + press.pressed = true + _preview.call("_gui_input", press) + var drag := InputEventMouseMotion.new() + drag.relative = Vector2(43, 27) + _preview.call("_gui_input", drag) + press.pressed = false + _preview.call("_gui_input", press) + var moved_point := _preview.cell_to_canvas(cell) + _expect(moved_point.distance_to(anchor + drag.relative) <= 2.0, + "middle-button drag translates the visible room by the pointer movement") + _click(moved_point) + _expect(not _last_facility.is_empty(), "a room remains selectable immediately after dragging") + _preview.reset_view() + _expect(int(_clock.absolute_minute) == before_minute, + "animation frames and camera input do not advance simulation minutes") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == before_digest, + "animation, zoom, pan, and visual selection preserve the authoritative simulation digest") + + +func _center_on(cell: Vector3i) -> void: + _preview.pan_offset += _preview.size * 0.5 - _preview.cell_to_canvas(cell) + + +func _click(position: Vector2) -> void: + _last_facility = &"" + _last_construction = &"" + _last_cell = null + var event := InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = true + event.position = position + event.global_position = _preview.global_position + position + _preview.call("_gui_input", event) + + +func _on_facility_selected(instance_id: StringName) -> void: + _last_facility = instance_id + + +func _on_construction_selected(job_id: StringName) -> void: + _last_construction = job_id + + +func _on_cell_clicked(cell: Vector3i) -> void: + _last_cell = cell + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/ui/test_pixel_tower.gd.uid b/tests/ui/test_pixel_tower.gd.uid new file mode 100644 index 0000000..b035a7d --- /dev/null +++ b/tests/ui/test_pixel_tower.gd.uid @@ -0,0 +1 @@ +uid://btrbkobsgirwi