diff --git a/.github/workflows/production-recipe-observations.yml b/.github/workflows/production-recipe-observations.yml index 84b46792..a7ba37ed 100644 --- a/.github/workflows/production-recipe-observations.yml +++ b/.github/workflows/production-recipe-observations.yml @@ -23,6 +23,12 @@ on: - 'tests/schema/finite_bootstrap*' - 'data/production/catalog.v2.json' - 'data/production/catalog.v3.json' + - 'data/production/catalog.v4.json' + - 'src/app/first_playable_bootstrap.gd' + - 'src/core/access/access_control_system.gd' + - 'src/presentation/habitat/procedural_habitat_preview.gd' + - 'tests/godot/paid_chamber*' + - 'tests/schema/paid_chamber*' - 'data/production/manual-room-reinforcement*' - 'tests/godot/paid_room_reinforcement*' - 'tests/schema/room_reinforcement*' @@ -86,7 +92,7 @@ jobs: include-templates: false - name: Run bounded recipe, resource and maintenance checks shell: pwsh - run: ./tools/ci/run-godot-checks.ps1 -TestNames 'recipe_requirements_test.gd','workstation_resource_view_test.gd','repair_usability_test.gd','first_playable_task_guide_test.gd','machinery_runtime_test.gd','checkpoint_recovery_test.gd','timed_canister_controls_test.gd','paid_horizontal_panels_test.gd','paid_door_lifecycle_test.gd','paid_door_pressure_room_test.gd','finite_bootstrap_acquisition_test.gd','commissioned_room_equalization_test.gd','paid_room_reinforcement_test.gd','horizontal_panel_controls_test.gd','horizontal_floor_support_query_test.gd','paid_floor_support_adapter_test.gd','paid_panel_collision_descriptors_test.gd','paid_panel_contact_scene_test.gd' + run: ./tools/ci/run-godot-checks.ps1 -TestNames 'recipe_requirements_test.gd','workstation_resource_view_test.gd','repair_usability_test.gd','first_playable_task_guide_test.gd','machinery_runtime_test.gd','checkpoint_recovery_test.gd','timed_canister_controls_test.gd','paid_horizontal_panels_test.gd','paid_door_lifecycle_test.gd','paid_door_pressure_room_test.gd','finite_bootstrap_acquisition_test.gd','commissioned_room_equalization_test.gd','paid_room_reinforcement_test.gd','paid_chamber_test.gd','horizontal_panel_controls_test.gd','horizontal_floor_support_query_test.gd','paid_floor_support_adapter_test.gd','paid_panel_collision_descriptors_test.gd','paid_panel_contact_scene_test.gd' - name: Validate real emitted JSON against local versioned schemas shell: pwsh run: | @@ -104,6 +110,8 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Finite bootstrap schema checks failed.' } & $schemaPython tests/schema/paid_door_schema_test.py if ($LASTEXITCODE -ne 0) { throw 'Paid door schema checks failed.' } + & $schemaPython tests/schema/paid_chamber_schema_test.py + if ($LASTEXITCODE -ne 0) { throw 'Paid chamber schemas failed.' } & $schemaPython tests/schema/room_reinforcement_schema_test.py if ($LASTEXITCODE -ne 0) { throw 'Room reinforcement schemas failed.' } & $schemaPython tests/schema/room_equalization_schema_test.py @@ -137,6 +145,7 @@ jobs: var/paid-door-pressure-fixture.json var/room-equalization-fixture.json var/room-reinforcement-fixture.json + var/paid-chamber-fixture.json var/horizontal-panel-controls-fixture.json var/horizontal-floor-support-fixture.json var/paid-floor-locomotion-fixture.json diff --git a/data/production/catalog.v4.json b/data/production/catalog.v4.json new file mode 100644 index 00000000..6a72ddfb --- /dev/null +++ b/data/production/catalog.v4.json @@ -0,0 +1,133 @@ +{ + "schema_version": 1, + "materials": [ + "ore.iron", + "ice", + "regolith", + "iron", + "tailings", + "water", + "hydrogen", + "oxygen", + "salts", + "silicate", + "nutrient-solution", + "plate" + ], + "dispensable": [ + "water", + "hydrogen", + "oxygen", + "nutrient-solution" + ], + "deposits": { + "deposit.iron": { + "material": "ore.iron", + "mass_milligrams": 10000000 + }, + "deposit.ice": { + "material": "ice", + "mass_milligrams": 5000000 + }, + "deposit.regolith": { + "material": "regolith", + "mass_milligrams": 10000000 + }, + "deposit.iron.outcrop": { + "material": "ore.iron", + "mass_milligrams": 7000000 + }, + "deposit.iron.reinforcement": { + "material": "ore.iron", + "mass_milligrams": 7000000 + }, + "deposit.iron.chamber": { + "material": "ore.iron", + "mass_milligrams": 15000000 + } + }, + "recipes": { + "refine.iron": { + "station": "refinery", + "inputs": { + "ore.iron": 1000000 + }, + "outputs": { + "iron": 600000, + "tailings": 400000 + }, + "duration_ticks": 120, + "energy_joules": 1000 + }, + "refine.ice": { + "station": "refinery", + "inputs": { + "ice": 1000000 + }, + "outputs": { + "water": 1000000 + }, + "duration_ticks": 60, + "energy_joules": 500 + }, + "refine.regolith": { + "station": "refinery", + "inputs": { + "regolith": 1000000 + }, + "outputs": { + "salts": 200000, + "silicate": 400000, + "tailings": 400000 + }, + "duration_ticks": 120, + "energy_joules": 1000 + }, + "chemical.electrolysis": { + "station": "chemical", + "inputs": { + "water": 1000000 + }, + "outputs": { + "hydrogen": 111000, + "oxygen": 889000 + }, + "duration_ticks": 180, + "energy_joules": 2000 + }, + "chemical.nutrients": { + "station": "chemical", + "inputs": { + "water": 900000, + "salts": 100000 + }, + "outputs": { + "nutrient-solution": 1000000 + }, + "duration_ticks": 120, + "energy_joules": 500 + }, + "craft.plate": { + "station": "printer", + "inputs": { + "iron": 1000000 + }, + "outputs": { + "plate": 1000000 + }, + "duration_ticks": 120, + "energy_joules": 1000 + }, + "salvage.plate": { + "station": "recycler", + "inputs": { + "plate": 1000000 + }, + "outputs": { + "iron": 1000000 + }, + "duration_ticks": 60, + "energy_joules": 500 + } + } +} diff --git a/docs/architecture/production-gameplay-loop.md b/docs/architecture/production-gameplay-loop.md index b8a9fbf0..e6537fb2 100644 --- a/docs/architecture/production-gameplay-loop.md +++ b/docs/architecture/production-gameplay-loop.md @@ -284,3 +284,50 @@ Local UI request IDs include a random runtime-session prefix and a monotonic sequence. Saved production receipts retain their original IDs for idempotent retry; creating a runtime to reload a snapshot cannot restart those identities. The prefix is transport identity only and grants no simulation authority. + +The default first-playable factory now selects catalog4 explicitly. Its distinct +15 kg chamber iron seam extends the previous 24 kg ore origin to 39 kg; unchanged +ice/regolith make 54 kg total origin. Catalog1/2/3 saves and explicit old factory +modes receive no seam grant. The complete two-door route prints 21 kg plate, +installs 20.4 kg, fabricates the 2 kg iron crowbar and spends 71,750 J. It leaves +0.6 kg plate, 0.4 kg iron, 15.6 kg tailings and 28,250 J. + +`PaidChamber` owns synchronous pair validation and detached transaction planning; +Production owns its 6 kg paid kit and geometry. Access owns both original sealed +manual actuators. PreparedDoorChange validates both device comparisons before +the first write. Pressure owns a 3.6 m3 finite allocation split from habitat_main, +its sensor and two manual valve states. Each simulation tick exchanges inner +then outer with finite habitat_main/exterior_reservoir at conductance1, using +one path when leakage and an open valve coincide. This is logical prototype +pressure service, with no spatial atmosphere or EVA capability claim. + +Opening either boundary requires its own valve closed and the opposite boundary +closed, intact and valve closed. Closing retains only its own force/occupancy +guards so an unsafe opposite cannot prevent isolation. Both pressure signs use +the unchanged 1000 N crowbar limit over 1.71 m2. Closed intact kit boundaries +retain up to 110 kPa; seal/actuator damage or overrating leaks even with valves +closed. No extra health field or automatic repair is introduced. Decommissioning +requires both boundaries force-safe and both valves closed; ordinary vacuum +cycling does not supply this precondition. Recovery/refit preserves original +seals, actuator damage and finite gas without refill. + +Production15/observation17/paid-door6 and Pressure5/chamber1 persist the explicit +pair, bills, endpoints, geometry and sensor/volume links. Whole-world restore +validates detached Production/Pressure/Access/Inventory candidates before any +owner assignment; half pairs, orphan links and incompatible open states reject. +The collapsed paid-door controls show the pair, three pressures, opposite state, +valves and force load, and enqueue authenticated commands with current revision +and epoch. Rejections retain owner state and expose the host reason. Existing +bounded simulation counters remain available; no per-frame diagnostic tracing +or external service is added. See the +[chamber milestone](../development/paid-chamber-sequential-interlocks-milestone.md) +for pinned geometry, accepted physical corrections and verification scope. + +Source selectors derive choices from the current validated observation, rather +than a hard-coded latest catalog. Scheduled mining, single and timed tool work, +completion and machinery metadata use the candidate's pinned catalog. The +server-only `machine_recipe(id, source_state={})` may receive a detached restore +candidate; omitting it uses live state. This prevents restore validation from +consulting the fresh factory's different catalog and does not grant absent ore. +Timed construction, panel controls and panel previews accept observation17 and +continue rejecting unsupported18+. Their old schema paths remain supported. diff --git a/docs/development/paid-chamber-sequential-interlocks-milestone.md b/docs/development/paid-chamber-sequential-interlocks-milestone.md new file mode 100644 index 00000000..a68ef359 --- /dev/null +++ b/docs/development/paid-chamber-sequential-interlocks-milestone.md @@ -0,0 +1,197 @@ +# Paid two-door chamber: implementation proposal + +Status: Coordinator accepted design `608d49c60651221f405cb6896066750bb5c16649` +for bounded implementation on 2026-10-23. Runtime/schema implementation pending. +Base: `1f2e5a71d0a2a9b6c1ac56ac0c1354ea2b200d58`, following PR148 merge +`25293641ab9263f699768d7d585a42cd80a2ab2d`. +Exclusive implementation owner: chat `01a16aa0-d610-7e41-b5a4-0868e3742b28`. +Coordinator `01a1283c-45d6-7e61-bedd-905e8797f03d` owns scope review, exact-head +NO-PORT runtime leases and final CI acceptance/merge. Worktree `paid-chamber-successor`, +branch `paid-chamber-sequential-interlocks`. One implementation chat; no delegation. + +## Observable outcome and bounds + +A fresh playable world can acquire finite ore, refine and print its own materials, +fabricate and hold a real crowbar, build one 3.6 m3 chamber with two paid manual +doors, fit two seals/two recoverable bypasses and a distinct paid 6 kg containment +kit, then commission and deliberately operate it through hosted player controls. +The chamber is mutually exclusive with the existing commissioned one-door room. +Its inner endpoint is explicitly `habitat_main`; outer is `exterior_reservoir`. +These are logical connections, without spatial atmosphere mapping or implied EVA, +breathability, species, pumps, temperature/species mixing or general networks. + +Retain the geometry and independent source analysis in +[the prerequisite design](paid-two-door-chamber-milestone.md#full-successor-target-geometry-and-budget): +four walls, doors at [5050,0,-4000]/[6950,0,-4000] quarter-turn1, interior +x[5100,6900] z[-4500,-3500] y[0,2000], floor x[4100,7900]. Floor and ceiling +are tiled into existing legal panel sizes, never oversized single panels. Actual +topology, PhysicsServer contact, occupancy and clearance must establish usability. +Retain area 1.71 m2 and crowbar 1000 N. Explicit prototype containment tuning is +110 kPa, supported only by existing Access actuator integrity/seal condition and +validated paid geometry/kit links; no invented wall-health model or certification. + +## Rechecked finite acquisition budget + +At this base `data/production/catalog.v3.json` contains 10+7+7 = 24 kg ore. +`refine.iron` yields 0.6 kg iron/0.4 kg tails per 1 kg input for 1000 J; +`craft.plate` consumes 1 kg iron and 1000 J. Production starts at 100000 J, +charges 250 J per extraction and caps depot mass at 100 kg. The existing +fabrication path costs 2 kg iron and 2000 J for the held crowbar. + +| Bill | Mass / energy | +| --- | ---: | +| Four walls / two doors | 4 / 4 kg plate | +| Floor including swings / ceiling | 3.8 / 1.8 kg plate | +| Two seals / two bypasses | 0.4 / 0.4 kg plate | +| Distinct containment kit | 6 kg plate | +| Installed plate | 20.4 kg | +| 39 ore extractions | 9750 J | +| Refine 39 kg ore | 23.4 kg iron + 15.6 kg tails; 39000 J | +| Print 21 kg plate / fabricate crowbar | 21000 / 2000 J | +| Total / remaining electrical energy | 71750 / 28250 J | + +Final accounted matter: 20.4 kg installed + 2 kg crowbar + 0.6 kg plate + +0.4 kg iron + 15.6 kg tails = 39 kg. Untouched 5 kg ice/10 kg regolith make +54 kg world origin. One-kg extraction/unload batches fit the 2 kg carrier; +even all acquired material fits depot capacity. Existing conservative wear over +39 completed cycles leaves tools usable. Manual assembly/recovery and passive +gas transfer cost zero electricity; no new power, tool or stock grant. + +Propose catalog v4: preserve v1/v2/v3 bytes and add a distinct reachable +`deposit.iron.chamber` with exactly 15000000 mg ore, explicit fingerprint, +source position/reach and selectable UI label. New-world composition opts into +v4. Legacy catalogs, missing-Production migration and depleted sources receive +no added source or refill. Legacy players may only build from their own remaining +stock; this milestone does not promise the full route in existing v3 worlds. +The 6 kg kit is separately paid, with an immutable receipt. The earlier 4 kg +room kit must be recovered normally before reusing its material; it cannot +be interpreted as a chamber kit or pay for a free second door. + +## Owners, admission, ticking and recovery + +Production owns geometry, kit/seal/bypass materials, finite origin, reservation +claims and exactly-once debit/refund receipts. Access owns both actuator states, +seal condition, collision-safe opening/closing and the unchanged force check. +Pressure owns one separate chamber record, volume, sensors, endpoints and valve +states. Inventory/control owns body, session, held usable tool and reach. UI +owns selection/pending presentation only. Extend existing prepared-owner +transactions and whole-world detached validation, rather than a second authority. + +Commission only a complete owned closed intact pair with both bypasses closed, +no existing commissioned room, supported geometry and paid kit. Subdivide 3.6 m3 +from the current 1000 m3 habitat proportionally at its actual temperature/moles; +allocate nothing from an infinite reservoir. Decommission only with both doors +closed, both valves closed, intact geometry and both boundaries within the manual +pressure margin; merge chamber gas/volume back into habitat conservatively. +The UI must explain that this may require depressurizing the finite habitat; +routine lossless dismantling beside vacuum is not available in this slice. +Decommission itself neither moves the body nor opens geometry. Recover the kit +exactly once after decommission with reservation-aware receiving capacity; then +allow ordinary paid-part recovery subject to current occupant/support guards. +Recommission retained hardware explicitly without healing condition. + +Commands use explicit desired state, current Production revision and restore +epoch, authenticated session/body/owner and held crowbar/reach. Check both sides +again at apply, including low-level Access admission. Opposite door and bypass +must be closed/intact before opening a selected bypass or door; own bypass must +be closed before door operation. Opening both valves, both doors or a +door-plus-bypass is rejected. Opposite hardware damage must never block a safe +closing action; own actuator and occupancy rules still apply. +Request replay returns the same receipt; changed intent with reused ID rejects. + +Outbound: close inner, open outer bypass, wait for actual force-safe differential, +close bypass, open outer, pass through, close outer. Return: outer closed, inner +bypass open until finite pair equilibrium permits force, close bypass, open inner, +pass through, close inner. No automatic valve sequencing or target-pressure writes. +Damaged opposite boundary leaks and blocks unsafe admission; UI cannot advertise +isolation. Blocked transitions retain gas, stock, receipts and actuator state. + +At the fixed pressure tick, retain existing prototype transfers, then process +chamber inner followed by outer before sensors. Per boundary use max(passive +leak, open bypass) at existing conductance 1 mol/(s*kPa), not additive paths. +Open/degraded/overrated boundaries leak; closed valves never suppress leakage. +Use existing equal-temperature equilibrium-capped solver, nonnegative moles and +0.001 kPa epsilon. Persist ordering; bounded transfer/leak/block counters only. +Finite exterior and habitat change each cycle. Compare all-volume total moles +and volume at every lifecycle step; the unrelated prototype may also consume +these reservoirs. No constant101.325kPa refill assumption. + +## Save, observation and player control contract + +Propose Production save15, observation17, paid-door6 and Pressure save5, with +explicit chamber/kit v1 records. Keep existing room dictionaries and decoders +semantically unchanged. Preserve Access version unless its exact contract needs +new fields; enforce cross-owner pair rules through detached validation. New +chamber records require unique paired IDs, fixed endpoint identities, exact bills, +matching owner/body/world/region/geometry/area, sensor/volume links and valve +states. Validate full Production/Pressure/Access/Inventory candidates before any +assignment. Malformed half-restores must leave every owner, tick, receipt, +revision and epoch unchanged. No permissive union or repair-on-restore. + +Extend the existing collapsed paid-door controls with explicit chamber pair, +inner/outer selection, kit cost/fit/recover and commission/decommission actions. +Show three actual pressures, selected delta/load, opposite boundary state, +both valves, pending receipt and exact blocking reason. Actions enqueue existing +authenticated host commands; stale/disconnected/rebound state disables them +until a fresh authority observation. No client pressure writes or sequencing. +Exercise actual source selector, action queue, projection and control paths. + +## Acceptance and verification budget + +Positive: unseeded fresh first-playable acquire39kg/refine/print/fabricate/build +route, exact71750J and all mass accounted; actual body support/contact through +both apertures; both manual cycle directions; habitat isolated during outer vent; +finite pressure loss over repeated cycles; all-volume mole/volume conservation; +kit recover/refit and whole-world reload with open/closed valve resume. + +Negative controls: insufficient/reserved stock, carrier/depot/refund capacity, +power/tool/reach, foreign owner/body/session, stale revision/epoch, replay and +changed intent, malformed paired restore with all-owner equality, false bill/ +endpoint/kit/area/rating, missing/orphan links, unsupported/overlapping sweeps, +occupied closure, both-open doors/valves and door-plus-bypass, damaged opposite +seal/actuator, both pressure signs exceeding1000N, overrated leak, closed valve +not suppressing leak, unchanged old0.5kPa policy, depleted/exhausted source reload, +legacy no-grant and recovery with no healing/refill. Preserve finite bootstrap, +room/bypass/reinforcement, UI, snapshot and emitted-schema regressions. + +Bound work to this one usable slice and its documentation/schemas/tests. No new +agent chats, generalized networks, repairs, art, dependencies or env variables. +Static design/arithmetic/diff review first; runtime/schema work starts only after +Coordinator accepts this pinned scope. Then smallest meaningful authored checks, +freeze/push exact head, request one bounded exclusive NO-PORT lease with named +stages and time budget. Use versioned AST-extracted Invoke-GodotCheck, empty +preflight inventory, stop on first diagnostic, preserve logs, do not edit/retry +under lease, postflight inventory and explicit release. Required Windows/Linux +CI and emitted-schema negatives must pass on the final head; distinguish tested +merge revision. Capture only if material usability uncertainty remains after real +control/runtime checks. Final task-closeout and matching-head Coordinator review +precede merge. No deployment, publication, media, financial or outreach action. + +## Accepted support correction + +Coordinator accepted the support correction on 2026-10-23: the original x7000 +floor end leaves only50mm beyond the outer aperture atx6950 and fails the +existing600mm full-footprint support contract. Extend the paid floor tox7900 +using one legal900mm strip. The39kg/71750J/54kg-origin budget above supersedes +the earlier37kg/68250J/52kg proposal. Keep a negative control for the old short +floor and prove full body crossing onto both paid approaches. + +## Accepted handle and preview corrections + +Coordinator accepted two further physical integration corrections on 2026-10-23. +Paid doors use a fixed server-owned handle 900 mm above the floor anchor. Reach +is the existing 1500 mm limit measured from the authorized canonical body center +(`body_center` normalizes legacy y0 and actual capsule y900). All Inventory +authorization, voluntary action and available-hand checks remain mandatory. +Construction, chamber pair operations and low-level opening admission use this +same helper; no client handle or global Inventory reach change is introduced. +The safe inside position x5550 can operate the outer handle at x6950 without +occupying either sweep. Boundary controls cover y0/y900 parity and 1501 mm denial. + +Production focus hides and disables only the explicitly owned negative-side +legacy `DoorFrameWall` mesh/collider crossing the mapped inner aperture. Ordinary +preview keeps it. Surface, HabitatFloor, positive-side wall, prototype functional +doors, logical pressure and all paid geometry retain their existing ownership. +The actual LocalHabitatCharacter test begins at the observed factory spawn, +walks supported waypoints, proves a closed paid leaf blocks, crosses both paid +apertures and mirrors reached positions through canonical movement commands. diff --git a/docs/schema/paid-door-observation-v6.schema.json b/docs/schema/paid-door-observation-v6.schema.json new file mode 100644 index 00000000..41c4154e --- /dev/null +++ b/docs/schema/paid-door-observation-v6.schema.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:paid-door-observation:v6", + "title": "Own paid chamber doors v6", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "control_epoch", + "doors", + "can_operate" + ], + "properties": { + "schema_version": { + "const": 6 + }, + "control_epoch": { + "type": "integer", + "minimum": -1, + "maximum": 9007199254740000 + }, + "doors": { + "type": "array", + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "door_id", + "position", + "quarter_turn", + "opened", + "seal_installed", + "seal_condition", + "pressure_room", + "bypass_installed", + "bypass_open", + "reinforcement_installed", + "reinforcement_intact", + "chamber_kit_installed", + "chamber" + ], + "properties": { + "door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "position": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "type": "integer", + "minimum": -100000, + "maximum": 100000, + "multipleOf": 50 + }, + { + "const": 0 + }, + { + "type": "integer", + "minimum": -100000, + "maximum": 100000, + "multipleOf": 50 + } + ], + "items": false + }, + "quarter_turn": { + "enum": [ + 0, + 1 + ] + }, + "opened": { + "type": "boolean" + }, + "seal_installed": { + "type": "boolean" + }, + "seal_condition": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "pressure_room": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "volume_m3", + "pressure_kpa", + "ambient_pressure_kpa", + "leaking" + ], + "properties": { + "volume_m3": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 999 + }, + "pressure_kpa": { + "type": "number", + "minimum": 0 + }, + "ambient_pressure_kpa": { + "type": "number", + "minimum": 0 + }, + "leaking": { + "type": "boolean" + } + } + } + ] + }, + "bypass_installed": { + "type": "boolean" + }, + "bypass_open": { + "type": "boolean" + }, + "reinforcement_installed": { + "type": "boolean" + }, + "reinforcement_intact": { + "type": "boolean" + }, + "chamber_kit_installed": { + "type": "boolean" + }, + "chamber": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "inner_door_id", + "outer_door_id", + "side", + "habitat_pressure_kpa", + "chamber_pressure_kpa", + "exterior_pressure_kpa", + "inner_bypass_open", + "outer_bypass_open" + ], + "properties": { + "inner_door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "outer_door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "side": { + "enum": [ + "inner", + "outer" + ] + }, + "habitat_pressure_kpa": { + "type": "number", + "minimum": 0 + }, + "chamber_pressure_kpa": { + "type": "number", + "minimum": 0 + }, + "exterior_pressure_kpa": { + "type": "number", + "minimum": 0 + }, + "inner_bypass_open": { + "type": "boolean" + }, + "outer_bypass_open": { + "type": "boolean" + } + } + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "bypass_open": { + "const": true + } + } + }, + "then": { + "properties": { + "bypass_installed": { + "const": true + }, + "pressure_room": { + "minProperties": 1 + } + } + } + }, + { + "if": { + "properties": { + "reinforcement_intact": { + "const": true + } + } + }, + "then": { + "properties": { + "reinforcement_installed": { + "const": true + }, + "seal_installed": { + "const": true + }, + "seal_condition": { + "const": 1 + } + } + } + } + ] + } + }, + "can_operate": { + "type": "boolean" + } + } +} diff --git a/docs/schema/pressure-system-v5.schema.json b/docs/schema/pressure-system-v5.schema.json new file mode 100644 index 00000000..d9931f0a --- /dev/null +++ b/docs/schema/pressure-system-v5.schema.json @@ -0,0 +1,289 @@ +{ + "type": "object", + "required": [ + "schema_version", + "volumes", + "airlocks", + "sensors", + "total_transferred_moles", + "constructed_rooms", + "room_valves", + "paid_chamber" + ], + "properties": { + "schema_version": { + "const": 5 + }, + "volumes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/pressureVolume" + } + }, + "airlocks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/airlock" + } + }, + "sensors": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/pressureSensor" + } + }, + "total_transferred_moles": { + "type": "number", + "minimum": 0 + }, + "constructed_rooms": { + "type": "object", + "maxProperties": 0 + }, + "room_valves": { + "type": "object", + "minProperties": 2, + "maxProperties": 2, + "propertyNames": { + "pattern": "^door-", + "maxLength": 128 + }, + "additionalProperties": { + "type": "boolean" + } + }, + "paid_chamber": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "policy_id", + "volume_id", + "sensor_id", + "principal_id", + "body_id", + "world_id", + "region_id", + "geometry", + "inner_door_id", + "outer_door_id", + "inner_endpoint", + "outer_endpoint" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "policy_id": { + "const": "constructed-chamber.habitat-exterior.prototype.v1" + }, + "volume_id": { + "type": "string", + "pattern": "^paid-chamber-", + "maxLength": 128 + }, + "sensor_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "body_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "world_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "region_id": { + "const": "local-habitat" + }, + "geometry": { + "type": "object", + "additionalProperties": false, + "required": [ + "walls", + "panels", + "doors" + ], + "properties": { + "walls": { + "$ref": "urn:frontier-systems:schema:production-system:v11#/properties/walls" + }, + "panels": { + "$ref": "urn:frontier-systems:schema:production-system:v11#/properties/panels" + }, + "doors": { + "$ref": "urn:frontier-systems:schema:production-system:v11#/properties/doors" + } + } + }, + "inner_door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "outer_door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "inner_endpoint": { + "const": "habitat_main" + }, + "outer_endpoint": { + "const": "exterior_reservoir" + } + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:pressure-system:v5", + "title": "Finite paired paid chamber pressure state v5", + "$defs": { + "pressureVolume": { + "type": "object", + "required": [ + "schema_version", + "volume_id", + "volume_m3", + "temperature_kelvin", + "gas_moles" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "volume_id": { + "type": "string", + "minLength": 1 + }, + "volume_m3": { + "type": "number", + "exclusiveMinimum": 0 + }, + "temperature_kelvin": { + "type": "number", + "exclusiveMinimum": 0 + }, + "gas_moles": { + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "airlock": { + "type": "object", + "required": [ + "schema_version", + "airlock_id", + "habitat_volume_id", + "chamber_volume_id", + "exterior_volume_id", + "inner_door_open", + "outer_door_open", + "equalization_valve_open", + "vent_valve_open", + "equalization_conductance_mol_per_second_kpa", + "vent_conductance_mol_per_second_kpa" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "airlock_id": { + "type": "string", + "minLength": 1 + }, + "habitat_volume_id": { + "type": "string", + "minLength": 1 + }, + "chamber_volume_id": { + "type": "string", + "minLength": 1 + }, + "exterior_volume_id": { + "type": "string", + "minLength": 1 + }, + "inner_door_open": { + "type": "boolean" + }, + "outer_door_open": { + "type": "boolean" + }, + "equalization_valve_open": { + "type": "boolean" + }, + "vent_valve_open": { + "type": "boolean" + }, + "equalization_conductance_mol_per_second_kpa": { + "type": "number", + "minimum": 0 + }, + "vent_conductance_mol_per_second_kpa": { + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "pressureSensor": { + "type": "object", + "required": [ + "schema_version", + "sensor_id", + "volume_id", + "pressure_kpa_absolute", + "last_sampled_tick", + "sample_count" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "sensor_id": { + "type": "string", + "minLength": 1 + }, + "volume_id": { + "type": "string", + "minLength": 1 + }, + "pressure_kpa_absolute": { + "type": "number", + "minimum": 0 + }, + "last_sampled_tick": { + "type": "integer", + "minimum": 0 + }, + "sample_count": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + } +} diff --git a/docs/schema/production-intent-v1.schema.json b/docs/schema/production-intent-v1.schema.json index 131287aa..e517f1fe 100644 --- a/docs/schema/production-intent-v1.schema.json +++ b/docs/schema/production-intent-v1.schema.json @@ -68,7 +68,11 @@ "door_bypass_close", "door_bypass_recover", "door_reinforcement_install", - "door_reinforcement_recover" + "door_reinforcement_recover", + "door_chamber_kit_install", + "door_chamber_kit_recover", + "door_chamber_commission", + "door_chamber_decommission" ] }, "payload": { @@ -2075,6 +2079,178 @@ } } } + }, + { + "if": { + "properties": { + "operation": { + "const": "door_chamber_kit_install" + } + }, + "required": [ + "operation" + ] + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "expected_revision", + "control_epoch", + "door_id" + ], + "properties": { + "expected_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "control_epoch": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + } + } + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "door_chamber_kit_recover" + } + }, + "required": [ + "operation" + ] + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "expected_revision", + "control_epoch", + "door_id" + ], + "properties": { + "expected_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "control_epoch": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + } + } + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "door_chamber_commission" + } + }, + "required": [ + "operation" + ] + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "expected_revision", + "control_epoch", + "door_id" + ], + "properties": { + "expected_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "control_epoch": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + } + } + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "door_chamber_decommission" + } + }, + "required": [ + "operation" + ] + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "expected_revision", + "control_epoch", + "door_id" + ], + "properties": { + "expected_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "control_epoch": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + } + } + } + } + } } ], "$defs": { diff --git a/docs/schema/production-observation-v17.schema.json b/docs/schema/production-observation-v17.schema.json new file mode 100644 index 00000000..6de0a592 --- /dev/null +++ b/docs/schema/production-observation-v17.schema.json @@ -0,0 +1,960 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:production-observation:v17", + "title": "Own production paid chamber observation v17", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "catalog_revision", + "catalog_fingerprint", + "timebase_hz", + "last_tick", + "state_revision", + "deposits", + "store", + "vessel", + "wall_mass_milligrams", + "energy_joules", + "powered", + "job", + "cargo", + "components", + "recipes", + "dispensable", + "can_operate", + "vessel_pressure_kpa", + "extractable_sources", + "structures", + "blueprints", + "loose_parts", + "tool_extraction", + "machinery", + "walls", + "recipe_requirements", + "timed_canister", + "horizontal_panels", + "paid_doors" + ], + "properties": { + "schema_version": { + "const": 17 + }, + "catalog_revision": { + "enum": [ + 1, + 2, + 3, + 4 + ] + }, + "catalog_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "timebase_hz": { + "enum": [ + 60, + 72, + 90 + ] + }, + "last_tick": { + "$ref": "#/$defs/revision" + }, + "state_revision": { + "$ref": "#/$defs/revision" + }, + "deposits": {}, + "store": { + "$ref": "#/$defs/stock" + }, + "vessel": { + "$ref": "#/$defs/stock" + }, + "wall_mass_milligrams": { + "type": "integer", + "minimum": 0, + "maximum": 100000000, + "multipleOf": 1000000 + }, + "energy_joules": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "powered": { + "type": "object", + "additionalProperties": false, + "required": [ + "drill", + "refinery", + "chemical", + "printer", + "recycler", + "dispenser", + "construction" + ], + "properties": { + "drill": { + "type": "boolean" + }, + "refinery": { + "type": "boolean" + }, + "chemical": { + "type": "boolean" + }, + "printer": { + "type": "boolean" + }, + "recycler": { + "type": "boolean" + }, + "dispenser": { + "type": "boolean" + }, + "construction": { + "type": "boolean" + } + } + }, + "job": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "recipe_id", + "deposit_id", + "station", + "inputs", + "outputs", + "remaining_ticks" + ], + "properties": { + "recipe_id": { + "type": "string", + "maxLength": 128 + }, + "deposit_id": { + "type": "string", + "maxLength": 128 + }, + "station": { + "enum": [ + "drill", + "refinery", + "chemical", + "printer", + "recycler", + "dispenser", + "construction" + ] + }, + "inputs": { + "$ref": "#/$defs/stock" + }, + "outputs": { + "$ref": "#/$defs/stock" + }, + "remaining_ticks": { + "type": "integer", + "minimum": 1, + "maximum": 36000 + } + } + } + ] + }, + "cargo": { + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 128, + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/stock" + }, + "type": "object" + }, + "components": { + "maxItems": 32, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 1, + "maxLength": 128, + "type": "string" + } + }, + "recipes": { + "maxItems": 32, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 1, + "maxLength": 128, + "type": "string" + } + }, + "dispensable": { + "maxItems": 32, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 1, + "maxLength": 128, + "type": "string" + } + }, + "extractable_sources": { + "maxItems": 32, + "uniqueItems": true, + "type": "array", + "items": { + "minLength": 1, + "maxLength": 128, + "type": "string" + } + }, + "can_operate": { + "type": "boolean" + }, + "vessel_pressure_kpa": { + "maximum": 1000, + "minimum": 0, + "type": "number" + }, + "structures": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "object", + "required": [ + "blueprint_id", + "fingerprint", + "materials" + ], + "properties": { + "materials": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 100000000 + }, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "fingerprint": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "blueprint_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "additionalProperties": false + }, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "blueprints": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "object", + "required": [ + "name", + "definition_id", + "definition_revision", + "materials", + "fabrication_energy_joules", + "salvage_energy_joules", + "fingerprint", + "modular" + ], + "properties": { + "materials": { + "type": "object", + "maxProperties": 32, + "minProperties": 1, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 100000000 + }, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "fabrication_energy_joules": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "salvage_energy_joules": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "definition_revision": { + "const": 1 + }, + "definition_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "fingerprint": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "modular": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "loose_parts": { + "type": "object", + "additionalProperties": { + "minProperties": 1, + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/id" + }, + "additionalProperties": { + "maximum": 100000000, + "minimum": 1, + "type": "integer" + }, + "type": "object" + }, + "maxProperties": 128, + "propertyNames": { + "$ref": "#/$defs/id" + } + }, + "tool_extraction": { + "oneOf": [ + { + "maxProperties": 0, + "type": "object" + }, + { + "$ref": "urn:frontier-systems:schema:tool-extraction-work:v1" + } + ] + }, + "machinery": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "catalog_fingerprint", + "parts", + "mounts", + "placements", + "links", + "jobs", + "inventory_items", + "cargo" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "catalog_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "parts": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "enum": [ + "frame", + "socket", + "power", + "controller", + "printhead", + "refinery", + "extractor", + "solar", + "router", + "expander", + "cable", + "autolathe" + ] + } + } + } + }, + "mounts": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "placements": { + "type": "object", + "maxProperties": 20, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "mode", + "enabled", + "energy", + "generation_remainder" + ], + "properties": { + "position": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "integer", + "minimum": -10000000, + "maximum": 10000000 + } + }, + "mode": { + "enum": [ + "freeform", + "surface" + ] + }, + "enabled": { + "type": "boolean" + }, + "energy": { + "type": "integer", + "minimum": 0, + "maximum": 20000 + }, + "generation_remainder": { + "type": "integer", + "minimum": 0, + "maximum": 89 + } + } + } + }, + "links": { + "type": "object", + "maxProperties": 24, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "a", + "b" + ], + "properties": { + "a": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "b": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + }, + "jobs": { + "type": "object", + "maxProperties": 20, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "recipe", + "progress", + "energy_spent", + "paused" + ], + "properties": { + "recipe": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 36000 + }, + "energy_spent": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "paused": { + "type": "boolean" + } + } + } + }, + "inventory_items": { + "type": "array", + "maxItems": 512, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "item_id", + "definition_id", + "definition_revision", + "condition_permille", + "container_id" + ], + "properties": { + "item_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "definition_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "definition_revision": { + "enum": [ + 1, + 2 + ], + "type": "integer" + }, + "condition_permille": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "container_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "allOf": [ + { + "if": { + "properties": { + "definition_revision": { + "const": 2 + } + } + }, + "then": { + "properties": { + "definition_id": { + "const": "item.resource-canister.fabricated" + } + } + } + } + ] + } + }, + "cargo": { + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 128, + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/stock" + }, + "type": "object" + } + } + }, + "walls": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "quarter_turn" + ], + "properties": { + "quarter_turn": { + "type": "integer", + "minimum": 0, + "maximum": 1 + }, + "position": { + "type": "array", + "minItems": 3, + "items": false, + "prefixItems": [ + { + "type": "integer", + "multipleOf": 50, + "minimum": -100000, + "maximum": 100000 + }, + { + "const": 0 + }, + { + "type": "integer", + "multipleOf": 50, + "minimum": -100000, + "maximum": 100000 + } + ], + "maxItems": 3 + } + } + }, + "propertyNames": { + "$ref": "#/$defs/id" + } + }, + "recipe_requirements": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/id" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "station", + "inputs", + "energy_joules" + ], + "properties": { + "station": { + "enum": [ + "drill", + "refinery", + "chemical", + "printer", + "recycler", + "dispenser", + "construction" + ] + }, + "inputs": { + "type": "object", + "minProperties": 1, + "maxProperties": 32, + "propertyNames": { + "enum": [ + "ore.iron", + "ice", + "regolith", + "iron", + "tailings", + "water", + "hydrogen", + "oxygen", + "salts", + "silicate", + "nutrient-solution", + "plate" + ] + }, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 100000000 + } + }, + "energy_joules": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + } + } + } + }, + "timed_canister": { + "$ref": "timed-canister-observation-v1.schema.json" + }, + "horizontal_panels": { + "$ref": "horizontal-panel-observation-v1.schema.json" + }, + "paid_doors": { + "$ref": "urn:frontier-systems:schema:paid-door-observation:v6" + } + }, + "$defs": { + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "stock": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 128 + }, + "additionalProperties": { + "type": "integer", + "minimum": 0, + "maximum": 100000000 + } + }, + "id": { + "pattern": "\\S", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "description": "Own paid chamber controls and finite three-volume pressure observations.", + "allOf": [ + { + "if": { + "properties": { + "catalog_revision": { + "const": 1 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + } + } + } + } + } + }, + { + "if": { + "properties": { + "catalog_revision": { + "const": 2 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith", + "deposit.iron.outcrop" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.iron.outcrop": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + } + } + } + } + } + }, + { + "if": { + "properties": { + "catalog_revision": { + "const": 3 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith", + "deposit.iron.outcrop", + "deposit.iron.reinforcement" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.iron.outcrop": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + }, + "deposit.iron.reinforcement": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + } + } + } + } + } + }, + { + "if": { + "properties": { + "catalog_revision": { + "const": 4 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith", + "deposit.iron.outcrop", + "deposit.iron.reinforcement", + "deposit.iron.chamber" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.iron.outcrop": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + }, + "deposit.iron.reinforcement": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + }, + "deposit.iron.chamber": { + "type": "integer", + "minimum": 0, + "maximum": 15000000 + } + } + } + } + } + } + ] +} diff --git a/docs/schema/production-system-v15.schema.json b/docs/schema/production-system-v15.schema.json new file mode 100644 index 00000000..79cbb1a1 --- /dev/null +++ b/docs/schema/production-system-v15.schema.json @@ -0,0 +1,1208 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:production-system:v15", + "title": "Production paid chamber snapshot v15", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "catalog_revision", + "catalog_fingerprint", + "timebase_hz", + "last_tick", + "state_revision", + "deposits", + "store", + "vessel", + "wall_mass_milligrams", + "energy_joules", + "powered", + "job", + "receipts", + "cargo", + "structures", + "loose_parts", + "machinery", + "walls", + "construction", + "construction_bindings", + "construction_commands", + "panels", + "doors", + "room_bypasses", + "room_reinforcements", + "chamber_kits" + ], + "properties": { + "schema_version": { + "const": 15 + }, + "catalog_revision": { + "enum": [ + 1, + 2, + 3, + 4 + ] + }, + "catalog_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "timebase_hz": { + "enum": [ + 60, + 72, + 90 + ] + }, + "last_tick": { + "$ref": "#/$defs/revision" + }, + "state_revision": { + "$ref": "#/$defs/revision" + }, + "deposits": {}, + "store": { + "$ref": "#/$defs/stock" + }, + "vessel": { + "$ref": "#/$defs/stock" + }, + "wall_mass_milligrams": { + "type": "integer", + "minimum": 0, + "maximum": 100000000, + "multipleOf": 1000000 + }, + "energy_joules": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "powered": { + "type": "object", + "additionalProperties": false, + "required": [ + "drill", + "refinery", + "chemical", + "printer", + "recycler", + "dispenser", + "construction" + ], + "properties": { + "drill": { + "type": "boolean" + }, + "refinery": { + "type": "boolean" + }, + "chemical": { + "type": "boolean" + }, + "printer": { + "type": "boolean" + }, + "recycler": { + "type": "boolean" + }, + "dispenser": { + "type": "boolean" + }, + "construction": { + "type": "boolean" + } + } + }, + "job": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "recipe_id", + "deposit_id", + "station", + "inputs", + "outputs", + "remaining_ticks" + ], + "properties": { + "recipe_id": { + "type": "string", + "maxLength": 128 + }, + "deposit_id": { + "type": "string", + "maxLength": 128 + }, + "station": { + "enum": [ + "drill", + "refinery", + "chemical", + "printer", + "recycler", + "dispenser", + "construction" + ] + }, + "inputs": { + "$ref": "#/$defs/stock" + }, + "outputs": { + "$ref": "#/$defs/stock" + }, + "remaining_ticks": { + "type": "integer", + "minimum": 1, + "maximum": 36000 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "recipe_id", + "deposit_id", + "station", + "inputs", + "outputs", + "remaining_ticks", + "extraction" + ], + "properties": { + "recipe_id": { + "type": "string", + "maxLength": 128 + }, + "deposit_id": { + "type": "string", + "maxLength": 128 + }, + "station": { + "enum": [ + "drill", + "refinery", + "chemical", + "printer", + "recycler", + "dispenser", + "construction" + ] + }, + "inputs": { + "$ref": "#/$defs/stock" + }, + "outputs": { + "$ref": "#/$defs/stock" + }, + "remaining_ticks": { + "type": "integer", + "minimum": 1, + "maximum": 60 + }, + "extraction": { + "$ref": "urn:frontier-systems:schema:tool-extraction-work:v1" + } + } + } + ] + }, + "receipts": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "request_id", + "fingerprint", + "state_revision", + "tick_number" + ], + "properties": { + "request_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "state_revision": { + "$ref": "#/$defs/revision" + }, + "tick_number": { + "$ref": "#/$defs/revision" + } + } + } + }, + "cargo": { + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 128, + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/stock" + }, + "type": "object" + }, + "structures": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "object", + "required": [ + "blueprint_id", + "fingerprint", + "materials" + ], + "properties": { + "materials": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "integer", + "minimum": 1, + "maximum": 100000000 + }, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "fingerprint": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "blueprint_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "additionalProperties": false + }, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + } + }, + "loose_parts": { + "type": "object", + "additionalProperties": { + "minProperties": 1, + "maxProperties": 32, + "propertyNames": { + "$ref": "#/$defs/id" + }, + "additionalProperties": { + "maximum": 100000000, + "minimum": 1, + "type": "integer" + }, + "type": "object" + }, + "maxProperties": 128, + "propertyNames": { + "$ref": "#/$defs/id" + } + }, + "machinery": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "catalog_fingerprint", + "parts", + "mounts", + "placements", + "links", + "jobs" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "catalog_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "parts": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "enum": [ + "frame", + "socket", + "power", + "controller", + "printhead", + "refinery", + "extractor", + "solar", + "router", + "expander", + "cable", + "autolathe" + ] + } + } + } + }, + "mounts": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "placements": { + "type": "object", + "maxProperties": 20, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "mode", + "enabled", + "energy", + "generation_remainder" + ], + "properties": { + "position": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "integer", + "minimum": -10000000, + "maximum": 10000000 + } + }, + "mode": { + "enum": [ + "freeform", + "surface" + ] + }, + "enabled": { + "type": "boolean" + }, + "energy": { + "type": "integer", + "minimum": 0, + "maximum": 20000 + }, + "generation_remainder": { + "type": "integer", + "minimum": 0, + "maximum": 89 + } + } + } + }, + "links": { + "type": "object", + "maxProperties": 24, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "a", + "b" + ], + "properties": { + "a": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "b": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + }, + "jobs": { + "type": "object", + "maxProperties": 20, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "recipe", + "progress", + "energy_spent", + "paused" + ], + "properties": { + "recipe": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 36000 + }, + "energy_spent": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "paused": { + "type": "boolean" + } + } + } + } + } + }, + "walls": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "quarter_turn" + ], + "properties": { + "quarter_turn": { + "type": "integer", + "minimum": 0, + "maximum": 1 + }, + "position": { + "type": "array", + "minItems": 3, + "items": false, + "prefixItems": [ + { + "type": "integer", + "multipleOf": 50, + "minimum": -100000, + "maximum": 100000 + }, + { + "const": 0 + }, + { + "type": "integer", + "multipleOf": 50, + "minimum": -100000, + "maximum": 100000 + } + ], + "maxItems": 3 + } + } + }, + "propertyNames": { + "$ref": "#/$defs/id" + } + }, + "construction": { + "$ref": "construction-reservation-v1.schema.json" + }, + "construction_bindings": { + "type": "object", + "maxProperties": 16, + "propertyNames": { + "$ref": "#/$defs/id" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "actor_id", + "body_id", + "world_id", + "region_id", + "control_epoch", + "fingerprint" + ], + "properties": { + "actor_id": { + "$ref": "#/$defs/id" + }, + "body_id": { + "$ref": "#/$defs/id" + }, + "world_id": { + "$ref": "#/$defs/id" + }, + "region_id": { + "$ref": "#/$defs/id" + }, + "control_epoch": { + "$ref": "#/$defs/revision" + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + "construction_commands": { + "type": "object", + "maxProperties": 128, + "propertyNames": { + "$ref": "#/$defs/id" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "request_id", + "fingerprint", + "state_revision", + "tick_number" + ], + "properties": { + "request_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "state_revision": { + "$ref": "#/$defs/revision" + }, + "tick_number": { + "$ref": "#/$defs/revision" + } + } + } + }, + "panels": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "bounds_millimetres", + "height_millimetres", + "blueprint_id", + "blueprint_revision", + "principal_id", + "materials" + ], + "properties": { + "kind": { + "enum": [ + "floor", + "ceiling" + ] + }, + "bounds_millimetres": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "integer", + "minimum": -100500, + "maximum": 100500, + "multipleOf": 50 + } + }, + "height_millimetres": { + "enum": [ + 0, + 2000 + ] + }, + "blueprint_id": { + "const": "surface.horizontal-panel" + }, + "blueprint_revision": { + "const": 1 + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate" + ], + "properties": { + "plate": { + "type": "integer", + "minimum": 2500, + "maximum": 1000000, + "multipleOf": 2500 + } + } + } + } + } + }, + "doors": { + "type": "object", + "maxProperties": 16, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "quarter_turn", + "principal_id", + "body_id", + "blueprint_id", + "blueprint_revision", + "materials", + "seal" + ], + "properties": { + "position": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "type": "integer", + "minimum": -100000, + "maximum": 100000, + "multipleOf": 50 + }, + { + "const": 0 + }, + { + "type": "integer", + "minimum": -100000, + "maximum": 100000, + "multipleOf": 50 + } + ], + "items": false + }, + "quarter_turn": { + "enum": [ + 0, + 1 + ] + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "body_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "blueprint_id": { + "const": "door.manual.prototype" + }, + "blueprint_revision": { + "const": 1 + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate" + ], + "properties": { + "plate": { + "const": 2000000 + } + } + }, + "seal": { + "oneOf": [ + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "seal_id", + "blueprint_id", + "blueprint_revision", + "materials" + ], + "properties": { + "seal_id": { + "type": "string", + "pattern": "^door-seal-", + "minLength": 11, + "maxLength": 128 + }, + "blueprint_id": { + "const": "door.manual.seal.prototype" + }, + "blueprint_revision": { + "const": 1 + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate" + ], + "properties": { + "plate": { + "const": 200000 + } + } + } + } + } + ] + } + } + } + }, + "room_bypasses": { + "type": "object", + "minProperties": 0, + "maxProperties": 16, + "propertyNames": { + "pattern": "^door-", + "maxLength": 128 + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "bypass_id", + "blueprint_id", + "blueprint_revision", + "materials" + ], + "properties": { + "bypass_id": { + "type": "string", + "pattern": "^room-bypass-", + "maxLength": 128 + }, + "blueprint_id": { + "const": "door.manual.bypass.prototype" + }, + "blueprint_revision": { + "const": 1 + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate" + ], + "properties": { + "plate": { + "const": 200000 + } + } + } + } + } + }, + "room_reinforcements": { + "type": "object", + "minProperties": 0, + "maxProperties": 16, + "propertyNames": { + "pattern": "^door-", + "maxLength": 128 + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "reinforcement_id", + "blueprint_id", + "blueprint_revision", + "seal_id", + "materials" + ], + "properties": { + "blueprint_id": { + "const": "room.reinforcement.prototype" + }, + "blueprint_revision": { + "const": 1 + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate" + ], + "properties": { + "plate": { + "const": 4000000 + } + } + }, + "reinforcement_id": { + "type": "string", + "pattern": "^room-reinforcement-", + "maxLength": 128 + }, + "seal_id": { + "type": "string", + "pattern": "^door-seal-", + "maxLength": 128 + } + } + } + }, + "chamber_kits": { + "type": "object", + "maxProperties": 1, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "kit_id", + "inner_door_id", + "outer_door_id", + "inner_seal_id", + "outer_seal_id", + "materials", + "policy_id" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "kit_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^chamber-kit-" + }, + "inner_door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "outer_door_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^door-" + }, + "inner_seal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "outer_seal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate" + ], + "properties": { + "plate": { + "const": 6000000 + } + } + }, + "policy_id": { + "const": "constructed-chamber.habitat-exterior.prototype.v1" + } + } + } + } + }, + "$defs": { + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "stock": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "minLength": 1, + "maxLength": 128 + }, + "additionalProperties": { + "type": "integer", + "minimum": 0, + "maximum": 100000000 + } + }, + "id": { + "pattern": "\\S", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "description": "Distinct paid chamber kit and explicit catalog origin. Runtime validates topology, exact paid bills and all cross-owner links.", + "allOf": [ + { + "if": { + "properties": { + "catalog_revision": { + "const": 1 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + } + } + } + } + } + }, + { + "if": { + "properties": { + "catalog_revision": { + "const": 2 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith", + "deposit.iron.outcrop" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.iron.outcrop": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + } + } + } + } + } + }, + { + "if": { + "properties": { + "catalog_revision": { + "const": 3 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith", + "deposit.iron.outcrop", + "deposit.iron.reinforcement" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.iron.outcrop": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + }, + "deposit.iron.reinforcement": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + } + } + } + } + } + }, + { + "if": { + "properties": { + "catalog_revision": { + "const": 4 + } + } + }, + "then": { + "properties": { + "deposits": { + "type": "object", + "additionalProperties": false, + "required": [ + "deposit.iron", + "deposit.ice", + "deposit.regolith", + "deposit.iron.outcrop", + "deposit.iron.reinforcement", + "deposit.iron.chamber" + ], + "properties": { + "deposit.iron": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.ice": { + "type": "integer", + "minimum": 0, + "maximum": 5000000 + }, + "deposit.regolith": { + "type": "integer", + "minimum": 0, + "maximum": 10000000 + }, + "deposit.iron.outcrop": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + }, + "deposit.iron.reinforcement": { + "type": "integer", + "minimum": 0, + "maximum": 7000000 + }, + "deposit.iron.chamber": { + "type": "integer", + "minimum": 0, + "maximum": 15000000 + } + } + } + } + } + } + ] +} diff --git a/docs/schema/world-snapshot-v1.schema.json b/docs/schema/world-snapshot-v1.schema.json index a9f72f31..805815ac 100644 --- a/docs/schema/world-snapshot-v1.schema.json +++ b/docs/schema/world-snapshot-v1.schema.json @@ -24,7 +24,8 @@ { "$ref": "#/$defs/pressureSystem" }, { "$ref": "urn:frontier-systems:schema:pressure-system:v2" }, { "$ref": "urn:frontier-systems:schema:pressure-system:v3" }, - { "$ref": "urn:frontier-systems:schema:pressure-system:v4" } + { "$ref": "urn:frontier-systems:schema:pressure-system:v4" }, + { "$ref": "urn:frontier-systems:schema:pressure-system:v5" } ] }, "production": { "oneOf": [ { "$ref": "urn:frontier-systems:schema:production-system:v1" }, @@ -40,7 +41,8 @@ { "$ref": "urn:frontier-systems:schema:production-system:v11" }, { "$ref": "urn:frontier-systems:schema:production-system:v12" }, { "$ref": "urn:frontier-systems:schema:production-system:v13" }, - { "$ref": "urn:frontier-systems:schema:production-system:v14" } + { "$ref": "urn:frontier-systems:schema:production-system:v14" }, + { "$ref": "urn:frontier-systems:schema:production-system:v15" } ] }, "health": { "oneOf": [ { "$ref": "urn:frontier-systems:schema:health-system:v1" }, diff --git a/src/adapters/network/door_contact_observation.gd b/src/adapters/network/door_contact_observation.gd index ab5db57e..6c5df726 100644 --- a/src/adapters/network/door_contact_observation.gd +++ b/src/adapters/network/door_contact_observation.gd @@ -5,7 +5,7 @@ class_name DoorContactObservation static func valid_items(value: Variant) -> bool: if typeof(value) != TYPE_DICTIONARY or not ControlContractValidation.has_exact_fields(value, PackedStringArray(["schema_version", "control_epoch", "can_operate", "doors"])): return false if typeof(value.can_operate) != TYPE_BOOL: return false - if not ControlContractValidation.is_integer(value.schema_version, 1, 5) or not ControlContractValidation.is_integer(value.control_epoch, -1, ProductionSimulationSystem.MAX_REVISION) or typeof(value.doors) != TYPE_ARRAY or value.doors.size() > PaidDoorGeometry.MAX_DOORS: return false + if not ControlContractValidation.is_integer(value.schema_version, 1, 6) or not ControlContractValidation.is_integer(value.control_epoch, -1, ProductionSimulationSystem.MAX_REVISION) or typeof(value.doors) != TYPE_ARRAY or value.doors.size() > PaidDoorGeometry.MAX_DOORS: return false var previous: String = "" for d in value.doors: var fields := PackedStringArray(["door_id", "position", "quarter_turn", "opened"]) @@ -13,6 +13,7 @@ static func valid_items(value: Variant) -> bool: if int(value.schema_version) >= 3: fields.append("pressure_room") if int(value.schema_version) >= 4: fields.append_array(["bypass_installed", "bypass_open"]) if int(value.schema_version) >= 5: fields.append_array(["reinforcement_installed", "reinforcement_intact"]) + if int(value.schema_version) >= 6: fields.append_array(["chamber_kit_installed", "chamber"]) if typeof(d) != TYPE_DICTIONARY or not ControlContractValidation.has_exact_fields(d, fields) or not ControlContractValidation.is_id(d.door_id) or not String(d.door_id).begins_with("door-") or typeof(d.opened) != TYPE_BOOL: return false if int(value.schema_version) >= 5 and (typeof(d.reinforcement_installed) != TYPE_BOOL or typeof(d.reinforcement_intact) != TYPE_BOOL or (d.reinforcement_intact and (not d.reinforcement_installed or not d.seal_installed or d.seal_condition != 1.0))): return false if int(value.schema_version) >= 2: @@ -27,6 +28,7 @@ static func valid_items(value: Variant) -> bool: for key in ["volume_m3", "pressure_kpa", "ambient_pressure_kpa"]: if typeof(d.pressure_room[key]) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(d.pressure_room[key])) or float(d.pressure_room[key]) < 0.0: return false if d.pressure_room.volume_m3 <= 0.0 or d.pressure_room.volume_m3 > 999.0: return false + if int(value.schema_version) >= 6 and (typeof(d.chamber_kit_installed) != TYPE_BOOL or not valid_chamber(d.chamber, d.door_id)): return false if String(d.door_id) <= previous or not SpatialWallConstruction.valid_pose({"position": d.position, "quarter_turn": d.quarter_turn}): return false previous = d.door_id return true @@ -61,3 +63,14 @@ static func decode(value: Variant, world: StringName, region: StringName) -> Ser static func invalid() -> ServiceResult: return ServiceResult.failure(&"invalid_door_contact", "Expected complete bounded door frame and leaf geometry in the current scope.") + + +static func valid_chamber(value: Variant, door_id: String) -> bool: + if typeof(value) != TYPE_DICTIONARY: return false + if value.is_empty(): return true + if not ControlContractValidation.has_exact_fields(value, PackedStringArray(["inner_door_id", "outer_door_id", "side", "habitat_pressure_kpa", "chamber_pressure_kpa", "exterior_pressure_kpa", "inner_bypass_open", "outer_bypass_open"])): return false + if not ControlContractValidation.is_id(value.inner_door_id) or not ControlContractValidation.is_id(value.outer_door_id) or value.inner_door_id == value.outer_door_id or door_id not in [value.inner_door_id, value.outer_door_id]: return false + if value.side != ("inner" if door_id == value.inner_door_id else "outer") or typeof(value.inner_bypass_open) != TYPE_BOOL or typeof(value.outer_bypass_open) != TYPE_BOOL or (value.inner_bypass_open and value.outer_bypass_open): return false + for field in ["habitat_pressure_kpa", "chamber_pressure_kpa", "exterior_pressure_kpa"]: + if typeof(value[field]) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(value[field])) or float(value[field]) < 0.0: return false + return true diff --git a/src/adapters/network/remote_production_projection.gd b/src/adapters/network/remote_production_projection.gd index 2e0b7f2e..090b28f5 100644 --- a/src/adapters/network/remote_production_projection.gd +++ b/src/adapters/network/remote_production_projection.gd @@ -7,7 +7,7 @@ const MATERIALS: Array[String] = ["ore.iron", "ice", "regolith", "iron", "tailin func accept_record(value: Dictionary) -> ServiceResult: - if not ControlContractValidation.is_integer(value.get("schema_version"), 7, 16): return _invalid() + if not ControlContractValidation.is_integer(value.get("schema_version"), 7, 17): return _invalid() var version: int = int(value["schema_version"]) var fields := PackedStringArray(["schema_version", "catalog_revision", "catalog_fingerprint", "timebase_hz", "last_tick", "state_revision", "deposits", "store", "vessel", "wall_mass_milligrams", "energy_joules", "powered", "job", "cargo", "components", "recipes", "dispensable", "can_operate", "vessel_pressure_kpa", "extractable_sources"]) fields.append("machinery") @@ -17,8 +17,8 @@ func accept_record(value: Dictionary) -> ServiceResult: if version >= 9: fields.append("timed_canister") if version >= 10: fields.append("horizontal_panels") if version >= 11: fields.append("paid_doors") - if version >= 11 and (not DoorContactObservation.valid_items(value.get("paid_doors")) or int(value.paid_doors.schema_version) != (5 if version >= 16 else (4 if version >= 15 else (3 if version >= 13 else (2 if version >= 12 else 1))))): return _invalid() - if not ControlContractValidation.has_exact_fields(value, fields) or not ControlContractValidation.is_integer(value.get("schema_version"), 7, 16) or not ControlContractValidation.is_integer(value.get("timebase_hz"), 60, 90) or not [60, 72, 90].has(int(value.get("timebase_hz"))) or typeof(value.get("can_operate")) != TYPE_BOOL: + if version >= 11 and (not DoorContactObservation.valid_items(value.get("paid_doors")) or int(value.paid_doors.schema_version) != (6 if version >= 17 else (5 if version >= 16 else (4 if version >= 15 else (3 if version >= 13 else (2 if version >= 12 else 1)))))): return _invalid() + if not ControlContractValidation.has_exact_fields(value, fields) or not ControlContractValidation.is_integer(value.get("schema_version"), 7, 17) or not ControlContractValidation.is_integer(value.get("timebase_hz"), 60, 90) or not [60, 72, 90].has(int(value.get("timebase_hz"))) or typeof(value.get("can_operate")) != TYPE_BOOL: return _invalid() if not SpatialWallConstruction.valid(value["walls"]): return _invalid() if version >= 10 and not HorizontalPanelObservation.valid(value["horizontal_panels"], value.get("state_revision")): return _invalid() @@ -26,7 +26,7 @@ func accept_record(value: Dictionary) -> ServiceResult: for field in ["last_tick", "state_revision", "catalog_revision", "wall_mass_milligrams", "energy_joules"]: if not ControlContractValidation.is_integer(value[field], 0, 9007199254740000): return _invalid() - if version >= 15 and not ControlContractValidation.is_integer(value["catalog_revision"], 1, 3 if version == 16 else 2): return _invalid() + if version >= 15 and not ControlContractValidation.is_integer(value["catalog_revision"], 1, 4 if version >= 17 else (3 if version == 16 else 2)): return _invalid() var catalog: int = int(value["catalog_revision"]) if version >= 15 else (2 if version == 14 else 1) if not ControlContractValidation.is_integer(value["catalog_revision"], catalog, catalog) or not _fingerprint_valid(value["catalog_fingerprint"]): return _invalid() diff --git a/src/app/first_playable_bootstrap.gd b/src/app/first_playable_bootstrap.gd index 94d49164..c089a1d6 100644 --- a/src/app/first_playable_bootstrap.gd +++ b/src/app/first_playable_bootstrap.gd @@ -717,9 +717,10 @@ func _drop_first_definition(definition_id: String) -> void: ## Shared fresh-world commissioning; callers own attachment and transport startup. -static func create_playable_world(reinforcement_ore: bool = true) -> WorldRuntime: +static func create_playable_world(reinforcement_ore: bool = true, chamber_ore: bool = true) -> WorldRuntime: var world := WorldRuntime.new() world.resource_carriers_enabled = true world.new_world_ore_outcrop_enabled = true world.new_world_reinforcement_ore_enabled = reinforcement_ore + world.new_world_chamber_ore_enabled = reinforcement_ore and chamber_ore return world diff --git a/src/core/access/access_control_system.gd b/src/core/access/access_control_system.gd index d4b2b3be..560131e4 100644 --- a/src/core/access/access_control_system.gd +++ b/src/core/access/access_control_system.gd @@ -17,6 +17,13 @@ const PROTOTYPE_AIRLOCK_ID: StringName = &"habitat_airlock" var devices: Dictionary = {} var current_tick: int = 0 var _door_restore_guard: Callable +var _paid_open_guard: Callable + + +func bind_paid_open_guard(guard: Callable) -> ServiceResult: + if _paid_open_guard.is_valid() or not guard.is_valid(): return ServiceResult.failure(&"door_guard_bound", "Bind one paid opening authority.") + _paid_open_guard = guard + return ServiceResult.success() func bind_door_restore_guard(guard: Callable) -> ServiceResult: @@ -240,6 +247,10 @@ func validate_door_actuation( manual_operation: bool = false, context: AuthenticatedCommandContext = null ) -> ServiceResult: + if opening and _paid_open_guard.is_valid() and String(device_id).begins_with("door-"): + var admitted: ServiceResult = _paid_open_guard.call(String(device_id), context) + if not admitted.is_success: return admitted + pressure_difference_kpa = float(admitted.value) var device := devices.get(device_id) as AccessControlledDeviceState if device == null: return ServiceResult.failure(&"unknown_access_device", "The access-controlled device does not exist.") @@ -278,6 +289,9 @@ func validate_door_actuation( func update_actuator_position(device_id: StringName, target_id: StringName, is_open: bool) -> void: + # Paid geometry is committed through PreparedDoorChange after all owner guards. + # This legacy airlock synchronization hook cannot mutate constructed doors. + if String(device_id).begins_with("door-"): return var device := devices.get(device_id) as AccessControlledDeviceState if device == null: return diff --git a/src/core/access/prepared_door_change.gd b/src/core/access/prepared_door_change.gd index 1770de82..d9678b3f 100644 --- a/src/core/access/prepared_door_change.gd +++ b/src/core/access/prepared_door_change.gd @@ -12,6 +12,31 @@ var _consumed: bool = false var pressure_owner: PressureRoomOwner var pressure_before: Dictionary = {} var pressure_after: Dictionary = {} +var _paired: Dictionary = {} + + +## Stage all additional existing devices before commit. No owner writes here. +func stage_device(id: StringName, after: Dictionary) -> ServiceResult: + if _consumed or id == _id or _paired.has(id): return denied() + var live: AccessControlledDeviceState = _owner.devices.get(id) + var parsed: ServiceResult = AccessControlledDeviceState.from_record(after) + if live == null or not parsed.is_success or parsed.value.device_id != id: return denied() + _paired[id] = {"previous": live, "before": live.to_record().duplicate(true), "after": after.duplicate(true)} + return ServiceResult.success() + + +func project_access(record: Dictionary) -> Dictionary: + var result: Dictionary = record.duplicate(true) + var found: bool = false + for index in range(result.devices.size() - 1, -1, -1): + var id := StringName(result.devices[index].device_id) + if id == _id: + found = true + if _remove: result.devices.remove_at(index) + else: result.devices[index] = _after.duplicate(true) + elif _paired.has(id): result.devices[index] = _paired[id].after.duplicate(true) + if not found and not _remove: result.devices.append(_after.duplicate(true)) + return result func _init(owner: AccessControlSystem, id: StringName, previous: AccessControlledDeviceState, after: Dictionary, remove: bool) -> void: @@ -23,7 +48,7 @@ func _init(owner: AccessControlSystem, id: StringName, previous: AccessControlle _remove = remove -## Every failure precedes the one affected-device write. No callbacks/restore. +## Every failure precedes all affected-device writes. No callbacks/restore. func commit(owner: AccessControlSystem) -> ServiceResult: if _consumed or owner != _owner: return denied() _consumed = true @@ -37,11 +62,20 @@ func commit(owner: AccessControlSystem) -> ServiceResult: if not _remove: parsed = AccessControlledDeviceState.from_record(_after) if not parsed.is_success or parsed.value.device_id != _id: return denied() + var paired_decoded: Dictionary = {} + for id in _paired: + var stage: Dictionary = _paired[id] + var current: AccessControlledDeviceState = owner.devices.get(id) + if current != stage.previous or current == null or current.to_record() != stage.before: return denied() + var decoded: ServiceResult = AccessControlledDeviceState.from_record(stage.after) + if not decoded.is_success or decoded.value.device_id != id: return denied() + paired_decoded[id] = decoded.value + if _remove and live == null: return denied() if _remove: - if live == null: return denied() owner.devices.erase(_id) else: owner.devices[_id] = parsed.value + for id in paired_decoded: owner.devices[id] = paired_decoded[id] # All decoding/CAS/capacity work precedes the first write. No callbacks/yield. if pressure_owner != null and not pressure_after.is_empty(): pressure_owner.install_prepared_room_state(pressure_after) return ServiceResult.success() diff --git a/src/core/machinery/machinery_construction.gd b/src/core/machinery/machinery_construction.gd index 72476729..69096e18 100644 --- a/src/core/machinery/machinery_construction.gd +++ b/src/core/machinery/machinery_construction.gd @@ -167,7 +167,7 @@ static func plan(production: ProductionSimulationSystem, state: Dictionary, inve workshop["placements"][machine]["enabled"] = payload["enabled"] elif operation == "machine_run": var recipe: String = String(payload["recipe"]) - var spec: Dictionary = production.machine_recipe(recipe) + var spec: Dictionary = production.machine_recipe(recipe, state) if spec.is_empty() or workshop["jobs"].has(machine) or not MachineryWorkshop.operational(workshop, owner, machine, spec["capability"]): return failure("Select a compatible recipe on an operational idle machine.") if not spec["deposit"].is_empty() and InventorySpatialMath.distance_millimetres(workshop["placements"][machine]["position"], ProductionSimulationSystem.SOURCE_POSITIONS[spec["deposit"]]) > 1500: return failure("The extractor must be deployed near its resource source.") workshop["jobs"][machine] = {"recipe": recipe, "progress": 0, "energy_spent": 0, "paused": false} diff --git a/src/core/pressure/pressure_room_owner.gd b/src/core/pressure/pressure_room_owner.gd index f5c7ea46..b8884607 100644 --- a/src/core/pressure/pressure_room_owner.gd +++ b/src/core/pressure/pressure_room_owner.gd @@ -22,3 +22,7 @@ extends SimulationSystem @abstract func room_observation(door_id: String) -> Dictionary @abstract func room_valve_open(door_id: String) -> bool + +@abstract func validate_room_open(door_id: String, bypass: bool = false) -> ServiceResult + +@abstract func chamber_observation(door_id: String) -> Dictionary diff --git a/src/core/pressure/pressure_system.gd b/src/core/pressure/pressure_system.gd index 3ffdbc32..99efef13 100644 --- a/src/core/pressure/pressure_system.gd +++ b/src/core/pressure/pressure_system.gd @@ -16,6 +16,7 @@ var sensors: Dictionary = {} var total_transferred_moles: float = 0.0 var constructed_rooms: Dictionary = {} var room_valves: Dictionary = {} +var paid_chamber: Dictionary = {} var _room_production: ProductionSimulationSystem var _room_access: AccessControlSystem var _room_world: StringName @@ -155,7 +156,7 @@ func capture_snapshot() -> ServiceResult: sensor_records.append(sensor.to_record()) var result: Dictionary = { - "schema_version": _room_snapshot_version(constructed_rooms, room_valves), + "schema_version": 5 if not paid_chamber.is_empty() else _room_snapshot_version(constructed_rooms, room_valves), "volumes": volume_records, "airlocks": airlock_records, "sensors": sensor_records, @@ -163,6 +164,7 @@ func capture_snapshot() -> ServiceResult: "constructed_rooms": constructed_rooms.duplicate(true), } if int(result.schema_version) >= 3: result["room_valves"] = room_valves.duplicate(true) + if not paid_chamber.is_empty(): result.paid_chamber = paid_chamber.duplicate(true) return ServiceResult.success(result) @@ -192,6 +194,7 @@ func install_prepared_room_state(state: Dictionary) -> void: total_transferred_moles = state["total_transferred_moles"] constructed_rooms = state["constructed_rooms"] room_valves = state["room_valves"] + paid_chamber = state.get("paid_chamber", {}) func _step_transfer(first_id: StringName, second_id: StringName, conductance: float, delta_seconds: float) -> void: @@ -236,18 +239,28 @@ func _sample_sensors(tick_number: int) -> void: func _decode_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> ServiceResult: - if not ControlContractValidation.is_integer(record.get("schema_version"), 1, 4): + if not ControlContractValidation.is_integer(record.get("schema_version"), 1, 5): return ServiceResult.failure(&"unsupported_pressure_system_version", "The pressure-system record version is not supported.") + var paid_record: Variant = record.get("paid_chamber", {}) + if typeof(paid_record) != TYPE_DICTIONARY: return PaidChamber.denied() + if int(record.schema_version) == 5: + if not PaidChamber.valid_record(paid_record): return PaidChamber.denied() + elif not paid_record.is_empty(): return PaidChamber.denied() var rooms: Variant = record.get("constructed_rooms", {}) var fields := PackedStringArray(["schema_version", "volumes", "airlocks", "sensors", "total_transferred_moles"]) if int(record.schema_version) >= 2: fields.append("constructed_rooms") if int(record.schema_version) >= 3: fields.append("room_valves") + if int(record.schema_version) == 5: fields.append("paid_chamber") var valves: Variant = record.get("room_valves", {}) if typeof(rooms) != TYPE_DICTIONARY or rooms.size() > 1: return ConstructedRoomPressure.denied() - if typeof(valves) != TYPE_DICTIONARY or valves.size() > 1: return ConstructedRoomPressure.denied() + if typeof(valves) != TYPE_DICTIONARY or valves.size() > (2 if int(record.schema_version) == 5 else 1): return ConstructedRoomPressure.denied() if int(record.schema_version) == 3 and valves.is_empty(): return ConstructedRoomPressure.denied() if int(record.schema_version) == 4 and (rooms.size() != 1 or not rooms.values().all(func(r: Variant) -> bool: return typeof(r) == TYPE_DICTIONARY and r.get("policy_id") == PaidRoomReinforcement.POLICY)): return ConstructedRoomPressure.denied() + if not paid_record.is_empty() and (not rooms.is_empty() or not ControlContractValidation.has_exact_fields(valves, PackedStringArray([paid_record.inner_door_id, paid_record.outer_door_id]))): return PaidChamber.denied() for id in valves: + if not paid_record.is_empty(): + if typeof(valves[id]) != TYPE_BOOL: return PaidChamber.denied() + continue if typeof(valves[id]) != TYPE_BOOL or not rooms.values().any(func(r: Variant) -> bool: return typeof(r) == TYPE_DICTIONARY and r.get("door_id") == id): return ConstructedRoomPressure.denied() if not ControlContractValidation.has_exact_fields(record, fields) or typeof(rooms) != TYPE_DICTIONARY or rooms.size() > 1: return ConstructedRoomPressure.denied() var volume_values: Variant = record.get("volumes") @@ -346,6 +359,17 @@ func _decode_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> S var ambient: PressureVolumeState = decoded_volumes.get(PROTOTYPE_HABITAT_ID) var sensor: PressureSensorState = decoded_sensors[StringName(room.sensor_id)] if ambient == null or sensor.volume_id != StringName(id) or absf(room_volume.volume_m3 - float(ConstructedRoomPressure.assess(room.geometry).value)) > 1e-9 or room_volume.temperature_kelvin != ambient.temperature_kelvin or absf(ambient.volume_m3 + room_volume.volume_m3 - ConstructedRoomPressure.HABITAT_VOLUME) > 1e-9 or ambient.volume_m3 < 1.0: return ConstructedRoomPressure.denied() + if not paid_record.is_empty(): + var cv: PressureVolumeState = decoded_volumes.get(StringName(paid_record.volume_id)) + var habitat: PressureVolumeState = decoded_volumes.get(PROTOTYPE_HABITAT_ID) + var exterior: PressureVolumeState = decoded_volumes.get(PROTOTYPE_EXTERIOR_ID) + var sensor: PressureSensorState = decoded_sensors.get(StringName(paid_record.sensor_id)) + if cv == null or habitat == null or exterior == null or sensor == null or sensor.volume_id != cv.volume_id or cv.volume_m3 != PaidChamber.VOLUME or absf(habitat.volume_m3 + cv.volume_m3 - 1000.0) > 1e-9 or cv.temperature_kelvin != habitat.temperature_kelvin or cv.temperature_kelvin != exterior.temperature_kelvin: return PaidChamber.denied() + if _room_production != null and (paid_record.world_id != String(_room_world) or paid_record.region_id != String(_room_region)): return PaidChamber.denied() + for id in decoded_volumes: + if String(id).begins_with("paid-chamber-") and (paid_record.is_empty() or String(id) != paid_record.volume_id): return PaidChamber.denied() + for id in decoded_sensors: + if String(id).begins_with("paid-chamber-") and (paid_record.is_empty() or String(id) != paid_record.sensor_id): return PaidChamber.denied() for id in decoded_volumes: if String(id).begins_with("constructed-room-") and not rooms.has(String(id)): return ConstructedRoomPressure.denied() for id in decoded_sensors: @@ -363,6 +387,7 @@ func _decode_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> S "total_transferred_moles": float(transferred_value), "constructed_rooms": rooms.duplicate(true), "room_valves": valves.duplicate(true), + "paid_chamber": paid_record.duplicate(true), }) @@ -379,12 +404,16 @@ func bind_room_owners(production: Node, access: Node, world: StringName, region: func room_area(door_id: String) -> float: + if _chamber_contains(door_id): return ConstructedRoomPressure.AREA for room in constructed_rooms.values(): if room.door_id == door_id: return ConstructedRoomPressure.AREA return 0.0 func room_difference(door_id: String) -> float: + if _chamber_contains(door_id): + var endpoint: StringName = PROTOTYPE_HABITAT_ID if door_id == paid_chamber.inner_door_id else PROTOTYPE_EXTERIOR_ID + return absf((volumes[StringName(paid_chamber.volume_id)] as PressureVolumeState).pressure_kpa() - (volumes[endpoint] as PressureVolumeState).pressure_kpa()) for room in constructed_rooms.values(): if room.door_id == door_id: return absf((volumes[StringName(room.volume_id)] as PressureVolumeState).pressure_kpa() - (volumes[PROTOTYPE_HABITAT_ID] as PressureVolumeState).pressure_kpa()) @@ -394,11 +423,17 @@ func room_difference(door_id: String) -> float: func validate_room_links(production: Dictionary, access: Dictionary, pressure_record: Dictionary = {}, tick: int = 0, inventory: Dictionary = {}) -> ServiceResult: var rooms: Dictionary = constructed_rooms var valves: Dictionary = room_valves + var chamber: Dictionary = paid_chamber if not pressure_record.is_empty(): var decoded: ServiceResult = _decode_snapshot_record(pressure_record, tick) if not decoded.is_success: return decoded rooms = decoded.value.constructed_rooms valves = decoded.value.room_valves + chamber = decoded.value.paid_chamber + if not chamber.is_empty(): + if not rooms.is_empty(): return PaidChamber.denied() + return PaidChamber.links(chamber, valves, production, access, inventory) + if not PaidChamber.valid_kits(production.get("chamber_kits", {}), production): return PaidChamber.denied() var expected: Dictionary = {} for room in rooms.values(): if production.get("room_bypasses", {}).has(room.door_id): expected[room.door_id] = false @@ -408,7 +443,8 @@ func validate_room_links(production: Dictionary, access: Dictionary, pressure_re func record_from_prepared(state: Dictionary) -> Dictionary: var result: Dictionary = {"schema_version": 2, "volumes": [], "airlocks": [], "sensors": [], "total_transferred_moles": state.total_transferred_moles, "constructed_rooms": state.constructed_rooms.duplicate(true)} - result.schema_version = _room_snapshot_version(state.constructed_rooms, state.room_valves) + result.schema_version = 5 if not state.get("paid_chamber", {}).is_empty() else _room_snapshot_version(state.constructed_rooms, state.room_valves) + if int(result.schema_version) == 5: result.paid_chamber = state.paid_chamber.duplicate(true) if int(result.schema_version) >= 3: result["room_valves"] = state.room_valves.duplicate(true) for field in ["volumes", "airlocks", "sensors"]: for id in _sorted_ids(state[field]): result[field].append(state[field][id].to_record()) @@ -416,6 +452,7 @@ func record_from_prepared(state: Dictionary) -> Dictionary: func prepare_room_change(context: AuthenticatedCommandContext, request: String, operation: String, door_id: String, production: Dictionary, tick: int) -> ServiceResult: + if operation in ["door_chamber_commission", "door_chamber_decommission"] or (_chamber_contains(door_id) and operation in ["door_bypass_open", "door_bypass_close"]): return _prepare_chamber(context, request, operation, door_id, production, tick) if context == null or not context.is_valid_for(_room_world, _room_region) or _room_region != &"local-habitat": return ConstructedRoomPressure.denied() var captured: ServiceResult = capture_snapshot() if not captured.is_success: return captured @@ -425,7 +462,7 @@ func prepare_room_change(context: AuthenticatedCommandContext, request: String, if volume.volume_id == String(PROTOTYPE_HABITAT_ID): ambient = volume if ambient.is_empty() or ambient.temperature_kelvin != PROTOTYPE_TEMPERATURE_K: return ConstructedRoomPressure.denied() if operation == "door_room_commission": - if not constructed_rooms.is_empty() or absf(float(ambient.volume_m3) - ConstructedRoomPressure.HABITAT_VOLUME) > 1e-9: return ConstructedRoomPressure.denied() + if not paid_chamber.is_empty() or not constructed_rooms.is_empty() or absf(float(ambient.volume_m3) - ConstructedRoomPressure.HABITAT_VOLUME) > 1e-9: return ConstructedRoomPressure.denied() var body: String = production.doors[door_id].body_id var geometry: Dictionary = ConstructedRoomPressure.geometry(production, String(context.principal_id), body) var assessed: ServiceResult = ConstructedRoomPressure.assess(geometry) @@ -489,6 +526,11 @@ func prepare_room_change(context: AuthenticatedCommandContext, request: String, func room_observation(door_id: String) -> Dictionary: + if _chamber_contains(door_id): + var cv: PressureVolumeState = volumes[StringName(paid_chamber.volume_id)] + var endpoint: StringName = PROTOTYPE_HABITAT_ID if door_id == paid_chamber.inner_door_id else PROTOTYPE_EXTERIOR_ID + var device: AccessControlledDeviceState = _room_access.devices[StringName(door_id)] + return {"volume_m3": cv.volume_m3, "pressure_kpa": cv.pressure_kpa(), "ambient_pressure_kpa": (volumes[endpoint] as PressureVolumeState).pressure_kpa(), "leaking": not PaidChamber.retained(device.actuator(PaidDoorConstruction.actuator_id(door_id)), room_difference(door_id))} for room in constructed_rooms.values(): if room.door_id != door_id: continue var volume: PressureVolumeState = volumes[StringName(room.volume_id)] @@ -503,7 +545,93 @@ func room_valve_open(door_id: String) -> bool: return room_valves.get(door_id, false) +func _chamber_contains(door_id: String) -> bool: + return not paid_chamber.is_empty() and door_id in [paid_chamber.inner_door_id, paid_chamber.outer_door_id] + + +func validate_room_open(door_id: String, bypass: bool = false) -> ServiceResult: + if not _chamber_contains(door_id): return ServiceResult.success() + if _room_access == null or _room_production == null: return PaidChamber.denied() + var captured: ServiceResult = _room_access.capture_snapshot() + if not captured.is_success or not validate_room_links(_room_production._state, captured.value).is_success: return PaidChamber.denied() + var opposite: String = paid_chamber.outer_door_id if door_id == paid_chamber.inner_door_id else paid_chamber.inner_door_id + if not PaidChamber.closed_intact(PaidChamber.actuator(captured.value, opposite)) or room_valve_open(opposite): return PaidChamber.denied() + if bypass: + if not PaidChamber.closed_intact(PaidChamber.actuator(captured.value, door_id)): return PaidChamber.denied() + elif room_valve_open(door_id): return PaidChamber.denied() + return ServiceResult.success() + + +func _step_paid_chamber(delta: float) -> void: + if _room_access == null or _room_production == null: return + var access: ServiceResult = _room_access.capture_snapshot() + if not access.is_success or not validate_room_links(_room_production._state, access.value).is_success: return + # Fixed inner then outer order; max(leak,valve), never two summed paths. + for id in [paid_chamber.inner_door_id, paid_chamber.outer_door_id]: + var target: ActuatorState = PaidChamber.actuator(access.value, id) + if room_valve_open(id) or not PaidChamber.retained(target, room_difference(id)): + _step_transfer(StringName(paid_chamber.volume_id), PROTOTYPE_HABITAT_ID if id == paid_chamber.inner_door_id else PROTOTYPE_EXTERIOR_ID, ConstructedRoomPressure.CONDUCTANCE, delta) + + +func _prepare_chamber(context: AuthenticatedCommandContext, request: String, operation: String, door_id: String, production: Dictionary, tick: int) -> ServiceResult: + if context == null or not context.is_valid_for(_room_world, _room_region) or _room_region != &"local-habitat": return PaidChamber.denied() + var captured: ServiceResult = capture_snapshot() + if not captured.is_success: return captured + var record: Dictionary = captured.value.duplicate(true) + var ambient: Dictionary = {} + for volume in record.volumes: + if volume.volume_id == String(PROTOTYPE_HABITAT_ID): ambient = volume + if ambient.is_empty() or ambient.temperature_kelvin != PROTOTYPE_TEMPERATURE_K: return PaidChamber.denied() + if operation == "door_chamber_commission": + if not paid_chamber.is_empty() or not constructed_rooms.is_empty() or ambient.volume_m3 != 1000.0: return PaidChamber.denied() + var kit: Dictionary = PaidChamber.kit_for(production, door_id) + if kit.is_empty() or kit.inner_door_id != door_id: return PaidChamber.denied() + var door: Dictionary = production.doors[door_id] + var geometry: Dictionary = ConstructedRoomPressure.geometry(production, String(context.principal_id), door.body_id) + if not PaidChamber.assess(geometry).is_success: return PaidChamber.denied() + var id: String = "paid-chamber-%s" % JSON.stringify([String(context.principal_id), request]).sha256_text().substr(0, 32) + if volumes.has(StringName(id)) or sensors.has(StringName("%s-sensor" % id)): return PaidChamber.denied() + var moles: float = float(ambient.gas_moles) * PaidChamber.VOLUME / float(ambient.volume_m3) + ambient.gas_moles -= moles + ambient.volume_m3 -= PaidChamber.VOLUME + record.volumes.append(PressureVolumeState.new(StringName(id), PaidChamber.VOLUME, ambient.temperature_kelvin, moles).to_record()) + record.sensors.append(PressureSensorState.new(StringName("%s-sensor" % id), StringName(id)).to_record()) + record.paid_chamber = {"schema_version": 1, "policy_id": PaidChamber.POLICY, "volume_id": id, "sensor_id": "%s-sensor" % id, "principal_id": String(context.principal_id), "body_id": door.body_id, "world_id": String(context.world_id), "region_id": String(context.region_id), "inner_door_id": door_id, "outer_door_id": kit.outer_door_id, "inner_endpoint": PaidChamber.INNER, "outer_endpoint": PaidChamber.OUTER, "geometry": geometry} + record.room_valves = {door_id: false, kit.outer_door_id: false} + record.schema_version = 5 + else: + if not _chamber_contains(door_id) or paid_chamber.principal_id != String(context.principal_id): return PaidChamber.denied() + if operation == "door_chamber_decommission": + if door_id != paid_chamber.inner_door_id: return PaidChamber.denied() + for id in [paid_chamber.inner_door_id, paid_chamber.outer_door_id]: + if room_valve_open(id) or room_difference(id) * 1710.0 > AccessControlSystem.PROTOTYPE_MANUAL_FORCE_NEWTONS: return PaidChamber.denied() + for volume in record.volumes: + if volume.volume_id == paid_chamber.volume_id: + ambient.gas_moles += volume.gas_moles + ambient.volume_m3 += volume.volume_m3 + record.volumes = record.volumes.filter(func(v: Dictionary) -> bool: return v.volume_id != paid_chamber.volume_id) + record.sensors = record.sensors.filter(func(v: Dictionary) -> bool: return v.sensor_id != paid_chamber.sensor_id) + record.erase("paid_chamber") + record.erase("room_valves") + record.schema_version = 2 + elif operation in ["door_bypass_open", "door_bypass_close"]: + if operation == "door_bypass_open" and not validate_room_open(door_id, true).is_success: return PaidChamber.denied() + record.room_valves[door_id] = operation == "door_bypass_open" + else: return PaidChamber.denied() + var decoded: ServiceResult = _decode_snapshot_record(record, tick) + if not decoded.is_success: return decoded + var before_moles: float = 0.0 + var after_moles: float = 0.0 + for volume in captured.value.volumes: before_moles += float(volume.gas_moles) + for volume in record.volumes: after_moles += float(volume.gas_moles) + if absf(before_moles - after_moles) > maxf(1e-9, before_moles * 1e-12): return PaidChamber.denied() + return ServiceResult.success({"before": captured.value, "after": decoded.value}) + + func _step_constructed_room(delta: float) -> void: + if not paid_chamber.is_empty(): + _step_paid_chamber(delta) + return if constructed_rooms.is_empty(): return # Broken links stop flow, never erase gas or guess another ambient endpoint. if _room_production == null or _room_access == null: return @@ -592,3 +720,8 @@ static func _room_snapshot_version(rooms: Dictionary, valves: Dictionary) -> int for room in rooms.values(): if room.policy_id == PaidRoomReinforcement.POLICY: return 4 return 2 if valves.is_empty() else 3 + + +func chamber_observation(door_id: String) -> Dictionary: + if not _chamber_contains(door_id): return {} + return {"inner_door_id": paid_chamber.inner_door_id, "outer_door_id": paid_chamber.outer_door_id, "side": "inner" if door_id == paid_chamber.inner_door_id else "outer", "habitat_pressure_kpa": (volumes[PROTOTYPE_HABITAT_ID] as PressureVolumeState).pressure_kpa(), "chamber_pressure_kpa": (volumes[StringName(paid_chamber.volume_id)] as PressureVolumeState).pressure_kpa(), "exterior_pressure_kpa": (volumes[PROTOTYPE_EXTERIOR_ID] as PressureVolumeState).pressure_kpa(), "inner_bypass_open": room_valve_open(paid_chamber.inner_door_id), "outer_bypass_open": room_valve_open(paid_chamber.outer_door_id)} diff --git a/src/core/production/paid_chamber.gd b/src/core/production/paid_chamber.gd new file mode 100644 index 00000000..fe21abfa --- /dev/null +++ b/src/core/production/paid_chamber.gd @@ -0,0 +1,167 @@ +extends RefCounted +class_name PaidChamber + +## Narrow first-party logical pressure connection, not spatial atmosphere. +const MASS: int = 6000000 +const POLICY: String = "constructed-chamber.habitat-exterior.prototype.v1" +const OPERATIONS: Array[String] = ["door_chamber_kit_install", "door_chamber_kit_recover", "door_chamber_commission", "door_chamber_decommission"] +const INNER: String = "habitat_main" +const OUTER: String = "exterior_reservoir" +const VOLUME: float = 3.6 +const RETENTION_KPA: float = 110.0 + + +static func assess(geometry: Dictionary) -> ServiceResult: + if not ControlContractValidation.has_exact_fields(geometry, PackedStringArray(["walls", "panels", "doors"])): return denied() + if typeof(geometry.walls) != TYPE_DICTIONARY or typeof(geometry.panels) != TYPE_DICTIONARY or typeof(geometry.doors) != TYPE_DICTIONARY: return denied() + if not SpatialWallConstruction.valid(geometry.walls) or not HorizontalPanelConstruction.valid(geometry.panels) or not PaidDoorGeometry.valid(geometry.doors, geometry.panels) or geometry.doors.size() != 2: return denied() + var walls: ServiceResult = ConstructedWallBoundaries.describe(geometry.walls, 0) + if not walls.is_success: return denied() + var surfaces: Array[Dictionary] = [] + for id in geometry.panels: surfaces.append(HorizontalPanelConstruction.descriptor(id, geometry.panels[id])) + var openings: Array[Dictionary] = [] + var inner: String = "" + var outer: String = "" + for id in geometry.doors: + var door: Dictionary = geometry.doors[id] + if door.quarter_turn != 1: return denied() + if door.position == [5050, 0, -4000]: inner = id + elif door.position == [6950, 0, -4000]: outer = id + else: return denied() + openings.append({"door_id": id, "position": door.position.duplicate(), "quarter_turn": door.quarter_turn}) + var result: ServiceResult = ConstructedDoorOpeningTopology.assess({"schema_version": 1, "production_revision": 0, "walls": walls.value, "surfaces": surfaces, "openings": openings}) + if not result.is_success or result.value.status != "enclosure_with_unsealed_openings" or result.value.interior_bounds_millimetres != [5100, 6900, -4500, -3500] or inner.is_empty() or outer.is_empty(): return denied() + return ServiceResult.success({"inner_door_id": inner, "outer_door_id": outer}) + + +static func candidate(production: Dictionary, principal: String, body: String) -> ServiceResult: + return assess(ConstructedRoomPressure.geometry(production, principal, body)) + + +static func valid_kits(value: Variant, production: Dictionary) -> bool: + if typeof(value) != TYPE_DICTIONARY or value.size() > 1: return false + for id in value: + var kit: Variant = value[id] + if typeof(kit) != TYPE_DICTIONARY or not ControlContractValidation.has_exact_fields(kit, PackedStringArray(["schema_version", "kit_id", "inner_door_id", "outer_door_id", "inner_seal_id", "outer_seal_id", "materials", "policy_id"])): return false + if not ControlContractValidation.is_integer(kit.schema_version, 1, 1) or kit.policy_id != POLICY or kit.inner_door_id != id or kit.outer_door_id == id or typeof(kit.materials) != TYPE_DICTIONARY or kit.materials != {"plate": MASS}: return false + for field in ["kit_id", "inner_door_id", "outer_door_id", "inner_seal_id", "outer_seal_id"]: + if not ControlContractValidation.is_id(kit[field]): return false + if not ControlContractValidation.is_integer(kit.materials.plate, MASS, MASS): return false + if not ControlContractValidation.is_id(kit.kit_id) or not String(kit.kit_id).begins_with("chamber-kit-"): return false + var doors: Dictionary = production.get("doors", {}) + if not doors.has(id) or not doors.has(kit.outer_door_id): return false + var inner: Dictionary = doors[id] + var outer: Dictionary = doors[kit.outer_door_id] + if inner.principal_id != outer.principal_id or inner.body_id != outer.body_id: return false + for boundary in [id, kit.outer_door_id]: + if not production.get("room_bypasses", {}).has(boundary) or production.get("room_reinforcements", {}).has(boundary): return false + if inner.seal.is_empty() or outer.seal.is_empty() or inner.seal.seal_id != kit.inner_seal_id or outer.seal.seal_id != kit.outer_seal_id: return false + var pair: ServiceResult = candidate(production, inner.principal_id, inner.body_id) + if not pair.is_success or pair.value.inner_door_id != id or pair.value.outer_door_id != kit.outer_door_id: return false + return true + + +static func kit_for(production: Dictionary, door: String) -> Dictionary: + for kit in production.get("chamber_kits", {}).values(): + if door in [kit.inner_door_id, kit.outer_door_id]: return kit + return {} + + +static func valid_record(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY or not ControlContractValidation.has_exact_fields(value, PackedStringArray(["schema_version", "policy_id", "volume_id", "sensor_id", "principal_id", "body_id", "world_id", "region_id", "inner_door_id", "outer_door_id", "inner_endpoint", "outer_endpoint", "geometry"])): return false + if not ControlContractValidation.is_integer(value.schema_version, 1, 1) or value.policy_id != POLICY or value.region_id != "local-habitat" or value.inner_endpoint != INNER or value.outer_endpoint != OUTER: return false + for field in ["volume_id", "sensor_id", "principal_id", "body_id", "world_id", "inner_door_id", "outer_door_id"]: + if not ControlContractValidation.is_id(value[field]): return false + if not String(value.volume_id).begins_with("paid-chamber-") or value.sensor_id != "%s-sensor" % value.volume_id or typeof(value.geometry) != TYPE_DICTIONARY: return false + var pair: ServiceResult = assess(value.geometry) + if not pair.is_success or pair.value.inner_door_id != value.inner_door_id or pair.value.outer_door_id != value.outer_door_id: return false + for door in value.geometry.doors.values(): + if door.principal_id != value.principal_id or door.body_id != value.body_id or door.seal.is_empty(): return false + return true + + +static func actuator(access: Dictionary, id: String) -> ActuatorState: + for record in access.get("devices", []): + if record.get("device_id") != id: continue + var parsed: ServiceResult = AccessControlledDeviceState.from_record(record) + return parsed.value.actuator(PaidDoorConstruction.actuator_id(id)) if parsed.is_success else null + return null + + +static func closed_intact(target: ActuatorState) -> bool: + return target != null and target.position == 0.0 and target.integrity == 1.0 and target.seal_condition == 1.0 and target.mode == &"manual" and target.latch_state == &"released" + + +static func retained(target: ActuatorState, difference: float) -> bool: + return closed_intact(target) and difference <= RETENTION_KPA + ConstructedRoomPressure.TOLERANCE_KPA + + +static func links(chamber: Dictionary, valves: Dictionary, production: Dictionary, access: Dictionary, inventory: Dictionary) -> ServiceResult: + if not valid_kits(production.get("chamber_kits", {}), production): return denied() + if chamber.is_empty(): return PaidDoorConstruction.links(production, access, inventory) + if not valid_record(chamber) or ConstructedRoomPressure.geometry(production, chamber.principal_id, chamber.body_id) != chamber.geometry: return denied() + var kit: Dictionary = kit_for(production, chamber.inner_door_id) + if kit.is_empty() or kit.outer_door_id != chamber.outer_door_id: return denied() + var areas: Dictionary = {} + var opened: int = 0 + var flowing: int = 0 + for id in [chamber.inner_door_id, chamber.outer_door_id]: + if not production.get("room_bypasses", {}).has(id) or not valves.has(id) or typeof(valves[id]) != TYPE_BOOL or production.get("room_reinforcements", {}).has(id): return denied() + var target: ActuatorState = actuator(access, id) + if target == null: return denied() + opened += 1 if target.position != 0.0 else 0 + flowing += 1 if valves[id] else 0 + areas[id] = ConstructedRoomPressure.AREA + if opened > 1 or flowing > 1 or (opened > 0 and flowing > 0): return denied() + return PaidDoorConstruction.links(production, access, inventory, areas) + + +static func denied() -> ServiceResult: + return ServiceResult.failure(&"paid_chamber_denied", "A complete owned paid chamber pair, isolated boundaries and current authority are required.") + + +static func plan(state: Dictionary, inventory: InventorySimulationSystem, access: AccessControlSystem, context: AuthenticatedCommandContext, request: String, operation: String, payload: Dictionary, pressure: PressureRoomOwner) -> ServiceResult: + if not ControlContractValidation.has_exact_fields(payload, PackedStringArray(["expected_revision", "control_epoch", "door_id"])) or pressure == null: return denied() + var authorized: ServiceResult = inventory.authorize(context, true) + var tool: ServiceResult = inventory.active_tool(context, "tool.crowbar") + if not authorized.is_success or not tool.is_success: return denied() + var geometry: Dictionary = ConstructedRoomPressure.geometry(state, String(context.principal_id), authorized.value) + var pair: ServiceResult = assess(geometry) + if not pair.is_success or payload.door_id != pair.value.inner_door_id: return denied() + var ids: Array = [pair.value.inner_door_id, pair.value.outer_door_id] + var devices: Dictionary = {} + for id in ids: + var door: Dictionary = state.doors[id] + var current: AccessControlledDeviceState = access.devices.get(StringName(id)) + if current == null or not PaidDoorConstruction.valid_device(id, door, current.to_record(), pressure.room_area(id)): return denied() + var device: AccessControlledDeviceState = AccessControlledDeviceState.from_record(current.to_record()).value + if not closed_intact(device.actuator(PaidDoorConstruction.actuator_id(id))) or not device.bypass_jobs.is_empty() or not device.tamper_events.is_empty() or device.actuator(PaidDoorConstruction.actuator_id(id)).override_actor_id != &"": return denied() + if not PaidDoorConstruction.reachable(inventory, context, door.position).is_success: return denied() + if not PaidDoorConstruction.unoccupied(door, inventory.capture_snapshot().value, state): return denied() + if state.get("room_reinforcements", {}).has(id): return denied() + devices[id] = device + var kits: Dictionary = state.get("chamber_kits", {}).duplicate(true) + var inner: String = ids[0] + var outer: String = ids[1] + if operation == "door_chamber_kit_install": + if not kits.is_empty() or pressure.room_area(inner) != 0.0 or pressure.room_area(outer) != 0.0 or int(state.store.plate) < MASS: return denied() + for id in ids: + if not state.get("room_bypasses", {}).has(id): return denied() + kits[inner] = {"schema_version": 1, "kit_id": "chamber-kit-%s" % JSON.stringify([String(context.principal_id), request]).sha256_text().substr(0, 32), "inner_door_id": inner, "outer_door_id": outer, "inner_seal_id": state.doors[inner].seal.seal_id, "outer_seal_id": state.doors[outer].seal.seal_id, "materials": {"plate": MASS}, "policy_id": POLICY} + state.store.plate -= MASS + elif operation == "door_chamber_kit_recover": + if not kits.has(inner) or pressure.room_area(inner) != 0.0 or ProductionSimulationSystem._mass(state.store) + ProductionSimulationSystem._reserved_mass(state) + MASS > ProductionSimulationSystem.MAX_MASS: return denied() + state.store.plate += MASS + kits.erase(inner) + else: + if not kits.has(inner): return denied() + for id in ids: + if not state.get("room_bypasses", {}).has(id) or pressure.room_valve_open(id): return denied() + if operation == "door_chamber_decommission" and pressure.room_difference(id) * 1000.0 * ConstructedRoomPressure.AREA > AccessControlSystem.PROTOTYPE_MANUAL_FORCE_NEWTONS: return denied() + devices[id].actuator(PaidDoorConstruction.actuator_id(id)).effective_pressure_area_m2 = ConstructedRoomPressure.AREA if operation == "door_chamber_commission" else 0.0 + state.chamber_kits = kits + state.schema_version = 15 + if not state.has("room_reinforcements"): state.room_reinforcements = {} + var prepared := PreparedDoorChange.new(access, StringName(inner), access.devices[StringName(inner)], devices[inner].to_record(), false) + var staged: ServiceResult = prepared.stage_device(StringName(outer), devices[outer].to_record()) + return ServiceResult.success(prepared) if staged.is_success else staged diff --git a/src/core/production/paid_chamber.gd.uid b/src/core/production/paid_chamber.gd.uid new file mode 100644 index 00000000..50e252c6 --- /dev/null +++ b/src/core/production/paid_chamber.gd.uid @@ -0,0 +1 @@ +uid://cqfjg7jibusq3 diff --git a/src/core/production/paid_door_construction.gd b/src/core/production/paid_door_construction.gd index d5f9a972..94f2c151 100644 --- a/src/core/production/paid_door_construction.gd +++ b/src/core/production/paid_door_construction.gd @@ -94,6 +94,18 @@ static func unoccupied(pose: Dictionary, inventory: Dictionary, state: Dictionar return true +## Prototype manual handle is900mm above its paid floor anchor. Own canonical +## body_center normalizes legacy y0 and real capsule-center y900 consistently. +static func reachable(inventory: InventorySimulationSystem, context: AuthenticatedCommandContext, position: Array) -> ServiceResult: + var authorized: ServiceResult = inventory.production_capability(context, true, []) + if not authorized.is_success: return authorized + var pose: ServiceResult = inventory.own_position(context) + if not pose.is_success: return pose + var handle: Vector3 = SpatialWallConstruction.point(position) + Vector3(0, 900, 0) + if SpatialWallConstruction.body_center(pose.value).distance_to(handle) > InventorySimulationSystem.WORLD_REACH_MILLIMETRES: return failure("The server-owned manual handle is beyond1500mm reach.") + return authorized + + static func plan(state: Dictionary, inventory: InventorySimulationSystem, access: AccessControlSystem, context: AuthenticatedCommandContext, request: String, operation: String, payload: Dictionary, pressure: PressureRoomOwner = null) -> ServiceResult: var create: bool = operation == "door_build" var fields: Array = ["expected_revision", "control_epoch", "position", "quarter_turn"] if create else ["expected_revision", "control_epoch", "door_id"] @@ -120,6 +132,7 @@ static func plan(state: Dictionary, inventory: InventorySimulationSystem, access if not current.is_success or not valid_device(id, door, current.value, pressure.room_area(id) if pressure != null else 0.0): return failure("The commissioned door hardware is invalid.") device = AccessControlledDeviceState.from_record(current.value).value var actuator: ActuatorState = device.actuator(actuator_id(id)) + if not PaidChamber.kit_for(state, id).is_empty() and operation in ["door_remove", "door_seal_install", "door_seal_remove", "door_room_commission", "door_reinforcement_install", "door_bypass_recover"]: return failure("Recover the distinct paid chamber kit before changing its linked boundary.") if pressure != null and pressure.room_area(id) > 0.0 and operation in ["door_remove", "door_seal_install", "door_seal_remove"]: return failure("Decommission the pressure compartment before changing its paid boundary.") if operation == "door_remove": if state.get("room_reinforcements", {}).has(id): return failure("Recover the paid reinforcement before removing its door.") @@ -136,7 +149,7 @@ static func plan(state: Dictionary, inventory: InventorySimulationSystem, access var reinforcement: ServiceResult = PaidRoomReinforcement.plan(state, id, actuator, device, inventory, context, request, operation, pressure) if not reinforcement.is_success: return reinforcement elif operation in PaidRoomBypass.OPERATIONS: - var bypass: ServiceResult = PaidRoomBypass.plan(state, id, actuator, device, inventory, context, request, operation, pressure) + var bypass: ServiceResult = PaidRoomBypass.plan(state, id, actuator, device, inventory, context, request, operation, pressure, access) if not bypass.is_success: return bypass elif operation in ["door_room_commission", "door_room_decommission"]: var tool: ServiceResult = inventory.active_tool(context, "tool.crowbar") @@ -156,7 +169,7 @@ static func plan(state: Dictionary, inventory: InventorySimulationSystem, access var can_move: ServiceResult = access.validate_door_actuation(StringName(id), actuator_id(id), operation == "door_open", pressure.room_difference(id) if pressure != null else 0.0, true, verified) if not can_move.is_success: return can_move actuator.position = 1.0 if operation == "door_open" else 0.0 - var reach: ServiceResult = inventory.production_capability(context, true, door["position"]) + var reach: ServiceResult = reachable(inventory, context, door["position"]) if not reach.is_success: return reach if not clear_installations(doors, state) or not unoccupied(door, inventory.capture_snapshot().value, state): return failure("The installation swing is occupied or has an unsupported canonical shape.") if operation == "door_remove": doors.erase(id) diff --git a/src/core/production/paid_room_bypass.gd b/src/core/production/paid_room_bypass.gd index 478fe0a2..5b316157 100644 --- a/src/core/production/paid_room_bypass.gd +++ b/src/core/production/paid_room_bypass.gd @@ -28,13 +28,23 @@ static func mass(state: Dictionary) -> int: return state.get("room_bypasses", {}).size() * MASS -static func plan(state: Dictionary, door_id: String, actuator: ActuatorState, device: AccessControlledDeviceState, inventory: InventorySimulationSystem, context: AuthenticatedCommandContext, request: String, operation: String, pressure: PressureRoomOwner) -> ServiceResult: +static func plan(state: Dictionary, door_id: String, actuator: ActuatorState, device: AccessControlledDeviceState, inventory: InventorySimulationSystem, context: AuthenticatedCommandContext, request: String, operation: String, pressure: PressureRoomOwner, access: AccessControlSystem) -> ServiceResult: var tool: ServiceResult = inventory.active_tool(context, "tool.crowbar") if not tool.is_success: return tool if pressure == null or actuator.mode != &"manual" or actuator.integrity <= 0.0 or actuator.latch_state != &"released" or not device.bypass_jobs.is_empty() or not device.tamper_events.is_empty() or actuator.override_actor_id != &"": return PaidDoorConstruction.failure("Usable manual hardware and cleared work are required.") var parts: Dictionary = state.get("room_bypasses", {}).duplicate(true) + var candidate_pair: bool = false + if pressure.room_area(door_id) == 0.0 and operation == "door_bypass_install": + var authorized: ServiceResult = inventory.authorize(context, true) + if authorized.is_success: + var pair: ServiceResult = PaidChamber.candidate(state, String(context.principal_id), authorized.value) + candidate_pair = pair.is_success and door_id in [pair.value.inner_door_id, pair.value.outer_door_id] and PaidChamber.closed_intact(actuator) + if candidate_pair: + for id in [pair.value.inner_door_id, pair.value.outer_door_id]: + var other: AccessControlledDeviceState = access.devices.get(StringName(id)) + if other == null or pressure.room_area(id) != 0.0 or not PaidChamber.closed_intact(other.actuator(PaidDoorConstruction.actuator_id(id))): candidate_pair = false if operation == "door_bypass_install": - if parts.has(door_id) or pressure.room_area(door_id) == 0.0 or actuator.position != 0.0 or int(state.store.plate) < MASS: return PaidDoorConstruction.failure("A closed commissioned door and 0.2 kg unreserved plate are required.") + if parts.has(door_id) or (pressure.room_area(door_id) == 0.0 and not candidate_pair) or actuator.position != 0.0 or int(state.store.plate) < MASS: return PaidDoorConstruction.failure("A closed commissioned door or a complete chamber pair with both seals fitted, plus 0.2 kg unreserved plate, is required.") parts[door_id] = {"bypass_id": "room-bypass-%s" % JSON.stringify([String(context.principal_id), request]).sha256_text().substr(0, 32), "blueprint_id": BLUEPRINT, "blueprint_revision": 1, "materials": {"plate": MASS}} state.store.plate -= MASS elif operation == "door_bypass_recover": @@ -45,7 +55,7 @@ static func plan(state: Dictionary, door_id: String, actuator: ActuatorState, de parts.erase(door_id) else: if not parts.has(door_id) or pressure.room_area(door_id) == 0.0: return PaidDoorConstruction.failure("Install a paid bypass on the commissioned door first.") - if int(state.schema_version) == 14: + if int(state.schema_version) >= 14: state.room_bypasses = parts elif parts.is_empty(): state.erase("room_bypasses") diff --git a/src/core/production/paid_room_reinforcement.gd b/src/core/production/paid_room_reinforcement.gd index 4692cfa2..8781fb93 100644 --- a/src/core/production/paid_room_reinforcement.gd +++ b/src/core/production/paid_room_reinforcement.gd @@ -50,5 +50,5 @@ static func plan(state: Dictionary, door_id: String, actuator: ActuatorState, de parts.erase(door_id) state.room_reinforcements = parts if not state.has("room_bypasses"): state.room_bypasses = {} - state.schema_version = 14 + state.schema_version = maxi(int(state.schema_version), 14) return ServiceResult.success() diff --git a/src/core/production/production_simulation_system.gd b/src/core/production/production_simulation_system.gd index bfb7acfa..0f9ccb7f 100644 --- a/src/core/production/production_simulation_system.gd +++ b/src/core/production/production_simulation_system.gd @@ -18,9 +18,11 @@ var _room_pressure: PressureRoomOwner var _checkpoint_door_links: Dictionary = {} const STATIONS: Array[String] = ["drill", "refinery", "chemical", "printer", "recycler", "dispenser", "construction"] ## Stable fixture sites, relative to the inventory display origin in millimetres. -const SOURCE_POSITIONS: Dictionary = {"deposit.iron": [-3000, 0, -1000], "deposit.ice": [-6000, 0, -1000], "deposit.regolith": [-9000, 0, -1000], "deposit.iron.outcrop": [-3000, 0, -1000], "deposit.iron.reinforcement": [-3000, 0, -1000]} +const SOURCE_POSITIONS: Dictionary = {"deposit.iron": [-3000, 0, -1000], "deposit.ice": [-6000, 0, -1000], "deposit.regolith": [-9000, 0, -1000], "deposit.iron.outcrop": [-3000, 0, -1000], "deposit.iron.reinforcement": [-3000, 0, -1000], "deposit.iron.chamber": [-3000, 0, -1000]} var _catalog: Dictionary = {} var _catalog_fingerprint: String = "" +var _chamber_catalog: Dictionary = {} +var _chamber_fingerprint: String = "" var _reinforced_catalog: Dictionary = {} var _reinforced_fingerprint: String = "" var _legacy_catalog: Dictionary = {} @@ -39,7 +41,7 @@ func _init() -> void: tick_priority = 300 -func configure(inventory: InventorySimulationSystem, tools: ToolSimulationSystem = null, new_world_outcrop: bool = false, new_world_reinforcement: bool = false) -> ServiceResult: +func configure(inventory: InventorySimulationSystem, tools: ToolSimulationSystem = null, new_world_outcrop: bool = false, new_world_reinforcement: bool = false, new_world_chamber: bool = false) -> ServiceResult: if not _state.is_empty() or inventory == null or MachineryWorkshop.KINDS.is_empty(): return _failure("Configure one inventory capability provider.") var raw: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/production/catalog.v2.json")) @@ -56,6 +58,9 @@ func configure(inventory: InventorySimulationSystem, tools: ToolSimulationSystem _reinforced_catalog = JSON.parse_string(FileAccess.get_file_as_string("res://data/production/catalog.v3.json")) if not _validate_catalog(_reinforced_catalog).is_success: return _failure("Reinforced production catalog is invalid.") _reinforced_fingerprint = JSON.stringify(_reinforced_catalog, "", true, true).sha256_text() + _chamber_catalog = JSON.parse_string(FileAccess.get_file_as_string("res://data/production/catalog.v4.json")) + if not _validate_catalog(_chamber_catalog).is_success: return _failure("Chamber catalog is invalid.") + _chamber_fingerprint = JSON.stringify(_chamber_catalog, "", true, true).sha256_text() _catalog = raw.duplicate(true) _inventory = inventory _tools = tools @@ -64,24 +69,25 @@ func configure(inventory: InventorySimulationSystem, tools: ToolSimulationSystem return blueprints var timed: ServiceResult = _timed_blueprints.configure(JSON.parse_string(FileAccess.get_file_as_string("res://data/production/timed-canister-blueprint.v1.json")), inventory, _catalog["materials"]) if not timed.is_success: return timed - _state = initial_record(0, new_world_outcrop, new_world_reinforcement) + _state = initial_record(0, new_world_outcrop, new_world_reinforcement, new_world_chamber) var motion: ServiceResult = inventory.bind_motion_validator(func(from: Array, to: Array) -> bool: return SpatialWallConstruction.motion_clear(_state["walls"], from, to) and door_motion_clear(from, to)) if not motion.is_success: return motion return inventory.bind_payload_mass_provider(payload_mass) -func initial_record(tick: int, new_world_outcrop: bool = false, new_world_reinforcement: bool = false) -> Dictionary: +func initial_record(tick: int, new_world_outcrop: bool = false, new_world_reinforcement: bool = false, new_world_chamber: bool = false) -> Dictionary: var powered: Dictionary = {} for station in STATIONS: powered[station] = true - var revision: int = 3 if new_world_reinforcement else (2 if new_world_outcrop else 1) - var result: Dictionary = {"schema_version": 14 if new_world_reinforcement else (12 if new_world_outcrop else 11), "doors": {}, "panels": {}, "construction": ConstructionReservationPlan.initial(), "construction_bindings": {}, "construction_commands": {}, "walls": {}, "machinery": MachineryWorkshop.initial(), "loose_parts": {}, "structures": {}, "catalog_revision": revision, "catalog_fingerprint": _reinforced_fingerprint if revision == 3 else (_catalog_fingerprint if revision == 2 else _legacy_fingerprint), "timebase_hz": Engine.physics_ticks_per_second, - "last_tick": tick, "state_revision": 0, "deposits": _initial_deposits(new_world_outcrop, new_world_reinforcement), "store": _empty_stock(), + var revision: int = 4 if new_world_chamber else (3 if new_world_reinforcement else (2 if new_world_outcrop else 1)) + var result: Dictionary = {"schema_version": 15 if new_world_chamber else (14 if new_world_reinforcement else (12 if new_world_outcrop else 11)), "doors": {}, "panels": {}, "construction": ConstructionReservationPlan.initial(), "construction_bindings": {}, "construction_commands": {}, "walls": {}, "machinery": MachineryWorkshop.initial(), "loose_parts": {}, "structures": {}, "catalog_revision": revision, "catalog_fingerprint": _chamber_fingerprint if revision == 4 else (_reinforced_fingerprint if revision == 3 else (_catalog_fingerprint if revision == 2 else _legacy_fingerprint)), "timebase_hz": Engine.physics_ticks_per_second, + "last_tick": tick, "state_revision": 0, "deposits": _initial_deposits(new_world_outcrop, new_world_reinforcement, new_world_chamber), "store": _empty_stock(), "vessel": _empty_stock(), "wall_mass_milligrams": 0, "energy_joules": 100000, "powered": powered, "job": {}, "receipts": [], "cargo": {}} - if revision == 3: + if revision >= 3: result.room_bypasses = {} result.room_reinforcements = {} + if revision == 4: result.chamber_kits = {} return result @@ -183,14 +189,24 @@ func validate_inventory_links(record: Dictionary, inventory_record: Dictionary) return ServiceResult.success() -func _initial_deposits(new_world_outcrop: bool = false, new_world_reinforcement: bool = false) -> Dictionary: +func _initial_deposits(new_world_outcrop: bool = false, new_world_reinforcement: bool = false, new_world_chamber: bool = false) -> Dictionary: var result: Dictionary = {} - var catalog: Dictionary = _reinforced_catalog if new_world_reinforcement else (_catalog if new_world_outcrop else _legacy_catalog) + var catalog: Dictionary = _chamber_catalog if new_world_chamber else (_reinforced_catalog if new_world_reinforcement else (_catalog if new_world_outcrop else _legacy_catalog)) for id in catalog["deposits"]: result[id] = int(catalog["deposits"][id]["mass_milligrams"]) return result +## Resolve source metadata from the candidate's pinned catalog, never latest. +func _source_catalog(candidate: Dictionary) -> Dictionary: + match int(candidate.catalog_revision): + 1: return _legacy_catalog + 2: return _catalog + 3: return _reinforced_catalog + 4: return _chamber_catalog + return {} + + func _empty_stock() -> Dictionary: var result: Dictionary = {} for id in _catalog["materials"]: @@ -349,7 +365,8 @@ func observation(context: AuthenticatedCommandContext) -> ServiceResult: # Observation version is independent of the v9 persistence record. observed.erase("room_bypasses") observed.erase("room_reinforcements") - observed["schema_version"] = 16 if int(_state.schema_version) == 14 else (15 if _state.has("room_bypasses") else (14 if int(_state["catalog_revision"]) == 2 else 13)) + observed.erase("chamber_kits") + observed["schema_version"] = 17 if int(_state.schema_version) == 15 else (16 if int(_state.schema_version) == 14 else (15 if _state.has("room_bypasses") else (14 if int(_state["catalog_revision"]) == 2 else 13))) observed["paid_doors"] = door_observation(context) observed["horizontal_panels"] = HorizontalPanelObservation.project(_state, context, construction_closure(context), _inventory.production_capability(context, true, []).is_success) observed["recipes"] = _catalog["recipes"].keys() @@ -608,14 +625,14 @@ func _prepare_tool_extraction(candidate: Dictionary, context: AuthenticatedComma candidate["job"]["extraction"]["status"] = "running" candidate["job"]["extraction"]["pause_reason"] = "" return ServiceResult.success() - if _tools == null or not _fields(payload, ["expected_revision", "deposit_id", "carrier_id", "cycles"]) or not candidate["deposits"].has(payload.get("deposit_id")) or not ControlContractValidation.is_id(payload.get("carrier_id")) or not ControlContractValidation.is_integer(payload.get("cycles"), 1, ToolExtractionWork.MAX_CYCLES) or not candidate["job"].is_empty() or int(candidate["state_revision"]) > MAX_REVISION - 100: return _failure("Reserve 1–10 extraction cycles with exact source/carrier identities while production is idle.") + if _tools == null or not _fields(payload, ["expected_revision", "deposit_id", "carrier_id", "cycles"]) or not candidate["deposits"].has(payload.get("deposit_id")) or not ControlContractValidation.is_id(payload.get("carrier_id")) or not ControlContractValidation.is_integer(payload.get("cycles"), 1, ToolExtractionWork.MAX_CYCLES) or not candidate["job"].is_empty() or int(candidate["state_revision"]) > MAX_REVISION - 100: return _failure("Reserve 1-10 extraction cycles with exact source/carrier identities while production is idle.") var active: ServiceResult = _inventory.active_tool(context, "tool.extractor") if not active.is_success: return active var binding: Dictionary = {"request_id": request_id, "principal_id": String(context.principal_id), "tool_id": active.value, "carrier_id": payload["carrier_id"], "deposit_id": payload["deposit_id"], "remaining_cycles": int(payload["cycles"]), "completed_cycles": 0, "status": "running", "pause_reason": ""} if _tool_extraction_reason(binding, context) != "": return _failure("The bound tool, carrier, power and energy must be available.") var mass: int = int(payload["cycles"]) * ToolExtractionWork.CYCLE_MASS if int(candidate["deposits"][payload["deposit_id"]]) < mass: return _failure("The source cannot satisfy this reservation.") - var material: String = _reinforced_catalog["deposits"][payload["deposit_id"]]["material"] + var material: String = _source_catalog(candidate)["deposits"][payload["deposit_id"]]["material"] candidate["deposits"][payload["deposit_id"]] -= mass candidate["job"] = {"recipe_id": "", "deposit_id": payload["deposit_id"], "station": "drill", "inputs": {material: mass}, "outputs": {material: mass}, "remaining_ticks": ToolExtractionWork.CYCLE_TICKS, "extraction": binding} return ServiceResult.success() @@ -656,7 +673,7 @@ func _simulate_tool_extraction(tick: int) -> void: if not usage.is_success: _pause_tool_extraction("capability") return - var material: String = _reinforced_catalog["deposits"][binding["deposit_id"]]["material"] + var material: String = _source_catalog(candidate)["deposits"][binding["deposit_id"]]["material"] var completed: int = int(binding["completed_cycles"]) + 1 if int(binding["remaining_cycles"]) == 1: candidate["job"] = {} @@ -744,7 +761,7 @@ func _plan(candidate: Dictionary, operation: String, payload: Dictionary) -> Ser var energy: int = maxi(1, int(ceil(float(amount) / 4000.0))) if int(candidate["deposits"][payload["deposit_id"]]) < amount or int(candidate["energy_joules"]) < energy or not candidate["powered"]["drill"]: return _failure("Source mass, extractor power or finite energy is unavailable.") - var material: String = _reinforced_catalog["deposits"][payload["deposit_id"]]["material"] + var material: String = _source_catalog(candidate)["deposits"][payload["deposit_id"]]["material"] candidate["cargo"][id][material] += amount if not _cargo_valid(candidate["cargo"][id]): return _failure("The receiving canister is full, incompatible or overpressure.") @@ -830,7 +847,7 @@ func _plan(candidate: Dictionary, operation: String, payload: Dictionary) -> Ser deposit_id = payload["deposit_id"] if int(candidate["deposits"][deposit_id]) < 1000000: return _failure("This deposit is exhausted.") - inputs = {_reinforced_catalog["deposits"][deposit_id]["material"]: 1000000} + inputs = {_source_catalog(candidate)["deposits"][deposit_id]["material"]: 1000000} outputs = inputs.duplicate(true) station = "drill" duration = 60 @@ -892,11 +909,11 @@ func capture_snapshot() -> ServiceResult: ## Work recipes share the trusted refinery/chemistry recipes and finite deposits. -func machine_recipe(id: String) -> Dictionary: +func machine_recipe(id: String, source_state: Dictionary = {}) -> Dictionary: if id == "chemical.electrolysis.micro": # Portable 2 L carriers cannot receive the bulk one-kilogram gas batch. # A separate exact 10 g recipe preserves old recipe/save semantics. - var micro: Dictionary = machine_recipe("chemical.electrolysis") + var micro: Dictionary = machine_recipe("chemical.electrolysis", source_state) for stock in ["inputs", "outputs"]: for material in micro[stock]: micro[stock][material] = int(micro[stock][material]) / 100 micro["energy_joules"] = int(micro["energy_joules"]) / 100 @@ -908,8 +925,10 @@ func machine_recipe(id: String) -> Dictionary: return recipe if id.begins_with("extract."): var deposit: String = "deposit.%s" % id.trim_prefix("extract.") - if _reinforced_catalog["deposits"].has(deposit): - var material: String = _reinforced_catalog["deposits"][deposit]["material"] + var candidate: Dictionary = _state if source_state.is_empty() else source_state + var sources: Dictionary = _source_catalog(candidate)["deposits"] + if candidate.deposits.has(deposit) and sources.has(deposit): + var material: String = sources[deposit]["material"] return {"inputs": {}, "outputs": {material: 1000000}, "duration_ticks": 60, "energy_joules": 1000, "capability": "extractor", "deposit": deposit} return {} @@ -1041,29 +1060,32 @@ func restore_snapshot_record(record: Dictionary, tick: int) -> ServiceResult: func _decode(record: Dictionary, tick: int) -> ServiceResult: var fields: Array = ["schema_version", "catalog_revision", "catalog_fingerprint", "timebase_hz", "last_tick", "state_revision", "deposits", "store", "vessel", "wall_mass_milligrams", "energy_joules", "powered", "job", "receipts"] - if ControlContractValidation.is_integer(record.get("schema_version"), 2, 14): + if ControlContractValidation.is_integer(record.get("schema_version"), 2, 15): fields.append("cargo") - if ControlContractValidation.is_integer(record.get("schema_version"), 3, 14): + if ControlContractValidation.is_integer(record.get("schema_version"), 3, 15): fields.append("structures") - if ControlContractValidation.is_integer(record.get("schema_version"), 4, 14): fields.append("loose_parts") - if ControlContractValidation.is_integer(record.get("schema_version"), 6, 14): fields.append("machinery") - if ControlContractValidation.is_integer(record.get("schema_version"), 7, 14): fields.append("walls") - if ControlContractValidation.is_integer(record.get("schema_version"), 8, 14): fields.append_array(["construction", "construction_bindings", "construction_commands"]) - if ControlContractValidation.is_integer(record.get("schema_version"), 9, 14): fields.append("panels") - if ControlContractValidation.is_integer(record.get("schema_version"), 10, 14): fields.append("doors") - if ControlContractValidation.is_integer(record.get("schema_version"), 13, 14): fields.append("room_bypasses") - if ControlContractValidation.is_integer(record.get("schema_version"), 14, 14): fields.append("room_reinforcements") + if ControlContractValidation.is_integer(record.get("schema_version"), 4, 15): fields.append("loose_parts") + if ControlContractValidation.is_integer(record.get("schema_version"), 6, 15): fields.append("machinery") + if ControlContractValidation.is_integer(record.get("schema_version"), 7, 15): fields.append("walls") + if ControlContractValidation.is_integer(record.get("schema_version"), 8, 15): fields.append_array(["construction", "construction_bindings", "construction_commands"]) + if ControlContractValidation.is_integer(record.get("schema_version"), 9, 15): fields.append("panels") + if ControlContractValidation.is_integer(record.get("schema_version"), 10, 15): fields.append("doors") + if ControlContractValidation.is_integer(record.get("schema_version"), 13, 15): fields.append("room_bypasses") + if ControlContractValidation.is_integer(record.get("schema_version"), 14, 15): fields.append("room_reinforcements") + if ControlContractValidation.is_integer(record.get("schema_version"), 15, 15): fields.append("chamber_kits") if _catalog.is_empty() or not _fields(record, fields): return _failure("An exact configured production snapshot is required.") - if not ControlContractValidation.is_integer(record.schema_version, 1, 14) or not ControlContractValidation.is_integer(record.catalog_revision, 1, 3): return _failure("Unsupported production/catalog version.") + if not ControlContractValidation.is_integer(record.schema_version, 1, 15) or not ControlContractValidation.is_integer(record.catalog_revision, 1, 4): return _failure("Unsupported production/catalog version.") var revision: int = int(record.catalog_revision) - var reinforced: bool = int(record.get("schema_version", 0)) == 14 + var chamber_origin: bool = int(record.schema_version) == 15 + var reinforced: bool = int(record.schema_version) >= 14 + if revision == 4 and not chamber_origin: return _failure("Catalog4 requires explicit save15 origin.") var expanded: bool = revision == 2 if revision == 3 and not reinforced: return _failure("New content requires explicit v14 origin.") - var saved_catalog: Dictionary = _reinforced_catalog if revision == 3 else (_catalog if expanded else _legacy_catalog) - if record["catalog_fingerprint"] != (_reinforced_fingerprint if revision == 3 else (_catalog_fingerprint if expanded else _legacy_fingerprint)): + var saved_catalog: Dictionary = _chamber_catalog if revision == 4 else (_reinforced_catalog if revision == 3 else (_catalog if expanded else _legacy_catalog)) + if record["catalog_fingerprint"] != (_chamber_fingerprint if revision == 4 else (_reinforced_fingerprint if revision == 3 else (_catalog_fingerprint if expanded else _legacy_fingerprint))): return _failure("The saved catalog differs; an explicit production migration is required.") - if not ControlContractValidation.is_integer(record["schema_version"], 1, 14) or not ControlContractValidation.is_integer(record["catalog_revision"], 1, 3) or (not reinforced and ((int(record.schema_version) == 12 and revision != 2) or (int(record.schema_version) < 12 and revision != 1))): + if not ControlContractValidation.is_integer(record["schema_version"], 1, 15) or not ControlContractValidation.is_integer(record["catalog_revision"], 1, 4) or (not reinforced and ((int(record.schema_version) == 12 and revision != 2) or (int(record.schema_version) < 12 and revision != 1))): return _failure("Unsupported production/catalog version.") if not ControlContractValidation.is_integer(record["last_tick"], tick, tick) or not ControlContractValidation.is_integer(record["timebase_hz"], Engine.physics_ticks_per_second, Engine.physics_ticks_per_second) or not [60, 72, 90].has(Engine.physics_ticks_per_second): return _failure("Production tick and supported timebase must match the world.") @@ -1078,7 +1100,7 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: normalized["loose_parts"] = record.get("loose_parts", {}).duplicate(true) for field in ["schema_version", "catalog_revision", "timebase_hz", "last_tick", "state_revision", "wall_mass_milligrams", "energy_joules"]: normalized[field] = int(record[field]) - normalized["schema_version"] = 14 if reinforced else (13 if int(record["schema_version"]) == 13 else (12 if expanded else 11)) + normalized["schema_version"] = 15 if chamber_origin else (14 if reinforced else (13 if int(record["schema_version"]) == 13 else (12 if expanded else 11))) for field in ["construction", "construction_bindings", "construction_commands"]: var fallback: Dictionary = ConstructionReservationPlan.initial() if field == "construction" else {} if typeof(record.get(field, fallback)) != TYPE_DICTIONARY: return _failure("Construction fragments must be objects.") @@ -1112,20 +1134,21 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: for wall in normalized["walls"].values(): wall["position"] = InventorySpatialMath.normalized(wall["position"]) wall["quarter_turn"] = int(wall["quarter_turn"]) + if chamber_origin and not PaidChamber.valid_kits(record.chamber_kits, normalized): return _failure("Invalid paid chamber kit or geometry.") var machinery: Variant = record.get("machinery", MachineryWorkshop.initial()) if typeof(machinery) != TYPE_DICTIONARY: return _failure("Machinery must be a record object.") normalized["machinery"] = machinery.duplicate(true) if not MachineryWorkshop.validate(normalized["machinery"]): return _failure("Malformed machinery record.") normalized["machinery"]["catalog_fingerprint"] = MachineryWorkshop.initial()["catalog_fingerprint"] for job in normalized["machinery"]["jobs"].values(): - var spec: Dictionary = machine_recipe(job["recipe"]) + var spec: Dictionary = machine_recipe(job["recipe"], normalized) if spec.is_empty() or int(job["progress"]) >= int(spec["duration_ticks"]) or int(job["energy_spent"]) != int(job["progress"]) * int(spec["energy_joules"]) / int(spec["duration_ticks"]): return _failure("Invalid machine recipe progress or energy.") if not PaidDoorConstruction.clear_installations(normalized["doors"], normalized): return _failure("Invalid door installation clearance.") var structures: Variant = record.get("structures", {}) if typeof(structures) != TYPE_DICTIONARY or structures.size() > 32: return _failure("Structural equipment records must be bounded.") normalized["structures"] = {} - var structural_mass: int = PaidRoomReinforcement.mass(normalized) + PaidRoomBypass.mass(normalized) + normalized["doors"].size() * PaidDoorGeometry.MASS + PaidDoorSeal.mass(normalized["doors"]) + HorizontalPanelConstruction.mass(normalized["panels"]) + MachineryWorkshop.mass(normalized["machinery"]) + normalized["walls"].size() * SpatialWallConstruction.MASS + var structural_mass: int = normalized.get("chamber_kits", {}).size() * PaidChamber.MASS + PaidRoomReinforcement.mass(normalized) + PaidRoomBypass.mass(normalized) + normalized["doors"].size() * PaidDoorGeometry.MASS + PaidDoorSeal.mass(normalized["doors"]) + HorizontalPanelConstruction.mass(normalized["panels"]) + MachineryWorkshop.mass(normalized["machinery"]) + normalized["walls"].size() * SpatialWallConstruction.MASS if typeof(record.get("loose_parts", {})) != TYPE_DICTIONARY or normalized["loose_parts"].size() > 128: return _failure("Loose parts must be a bounded material map.") for id in normalized["loose_parts"]: if not ControlContractValidation.is_id(id) or not _stock_valid(normalized["loose_parts"][id]): return _failure("Invalid loose part materials.") @@ -1180,7 +1203,7 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: return _failure("A bounded reserved batch is required.") var expected: Dictionary if typeof(job["deposit_id"]) == TYPE_STRING and saved_catalog["deposits"].has(job["deposit_id"]) and job["recipe_id"] == "": - var material: String = _reinforced_catalog["deposits"][job["deposit_id"]]["material"] + var material: String = saved_catalog["deposits"][job["deposit_id"]]["material"] var mass: int = int(job["extraction"]["remaining_cycles"]) * ToolExtractionWork.CYCLE_MASS if job.has("extraction") else 1000000 expected = {"station": "drill", "inputs": {material: mass}, "outputs": {material: mass}, "duration_ticks": 60} elif job["deposit_id"] == "" and typeof(job["recipe_id"]) == TYPE_STRING and _catalog["recipes"].has(job["recipe_id"]): @@ -1196,7 +1219,7 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: if _mass(normalized["store"]) + _reserved_mass(normalized) > MAX_MASS or _mass(normalized["vessel"]) > VESSEL_CAPACITY or _vessel_pressure(normalized["vessel"]) > MAX_VESSEL_PRESSURE_KPA: return _failure("Reserved output or vessel exceeds capacity.") var total: int = _mass(normalized["deposits"]) + _mass(normalized["store"]) + _mass(normalized["vessel"]) + int(normalized["wall_mass_milligrams"]) + _reserved_mass(normalized) + carried_mass + structural_mass - if total != _mass(_initial_deposits(expanded, revision == 3)): + if total != _mass(_initial_deposits(expanded, revision == 3, revision == 4)): return _failure("Closed fixture material mass must be conserved, including reserved batches.") if typeof(record["receipts"]) != TYPE_ARRAY or record["receipts"].size() > MAX_RECEIPTS: return _failure("Receipt history exceeds its bound.") @@ -1214,7 +1237,7 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: receipt["tick_number"] = previous_tick ids[receipt["request_id"]] = true if int(normalized["state_revision"]) == 0: - var initial: Dictionary = initial_record(tick, expanded, revision == 3) + var initial: Dictionary = initial_record(tick, expanded, revision == 3, revision == 4) if normalized != initial: return _failure("Revision-zero production must retain its initial finite fixture state.") elif normalized["receipts"].is_empty(): @@ -1253,10 +1276,10 @@ func _apply_door(context: AuthenticatedCommandContext, request: String, operatio before = captured.value var current_links: ServiceResult = _room_pressure.validate_room_links(_state, _door_access.capture_snapshot().value) if not current_links.is_success: return current_links - var prepared: ServiceResult = PaidDoorConstruction.plan(candidate, _inventory, _door_access, context, request, operation, payload, _room_pressure) + var prepared: ServiceResult = PaidChamber.plan(candidate, _inventory, _door_access, context, request, operation, payload, _room_pressure) if operation in PaidChamber.OPERATIONS else PaidDoorConstruction.plan(candidate, _inventory, _door_access, context, request, operation, payload, _room_pressure) if not prepared.is_success: return prepared var pressure_record: Dictionary = before - if operation in ["door_room_commission", "door_room_decommission", "door_bypass_install", "door_bypass_open", "door_bypass_close", "door_reinforcement_install"]: + if operation in ["door_chamber_commission", "door_chamber_decommission", "door_room_commission", "door_room_decommission", "door_bypass_open", "door_bypass_close", "door_reinforcement_install"] or (operation == "door_bypass_install" and _room_pressure.room_area(payload.door_id) > 0.0): var pressure_plan: ServiceResult = _room_pressure.prepare_room_change(context, request, operation, payload.door_id, candidate, tick) if not pressure_plan.is_success: return pressure_plan prepared.value.pressure_after = pressure_plan.value.after @@ -1265,15 +1288,7 @@ func _apply_door(context: AuthenticatedCommandContext, request: String, operatio if _room_pressure != null: prepared.value.pressure_owner = _room_pressure prepared.value.pressure_before = before - var after_access: Dictionary = _door_access.capture_snapshot().value.duplicate(true) - var found: bool = false - for index in range(after_access.devices.size()): - if after_access.devices[index].device_id == String(prepared.value._id): - found = true - if prepared.value._remove: after_access.devices.remove_at(index) - else: after_access.devices[index] = prepared.value._after.duplicate(true) - break - if not found and not prepared.value._remove: after_access.devices.append(prepared.value._after.duplicate(true)) + var after_access: Dictionary = prepared.value.project_access(_door_access.capture_snapshot().value) if not _room_pressure.validate_room_links(candidate, after_access, pressure_record, tick).is_success: return _failure("Prepared pressure, construction and hardware links differ.") if not TimedCanisterConstruction.covered(candidate): return _failure("Unfinished construction retains its reserved stock.") if not PaidDoorGeometry.valid(candidate["doors"], candidate["panels"]): return _failure("Door support validation failed.") @@ -1315,7 +1330,7 @@ func bind_room_pressure(pressure: PressureRoomOwner, world: StringName, region: var bound: ServiceResult = pressure.bind_room_owners(self, _door_access, world, region) if not bound.is_success: return bound _room_pressure = pressure - return ServiceResult.success() + return _door_access.bind_paid_open_guard(_admit_paid_open) func _validate_constructed_links(production: Dictionary, access: Dictionary) -> ServiceResult: @@ -1349,12 +1364,16 @@ func door_observation(context: AuthenticatedCommandContext) -> Dictionary: for item in items: item["bypass_installed"] = _state.room_bypasses.has(item.door_id) item["bypass_open"] = _room_pressure.room_valve_open(item.door_id) if _room_pressure != null else false - if int(_state.schema_version) == 14: + if int(_state.schema_version) >= 14: for item in items: item["reinforcement_installed"] = _state.room_reinforcements.has(item.door_id) var actuator: ActuatorState = _door_access.devices[StringName(item.door_id)].actuator(PaidDoorConstruction.actuator_id(item.door_id)) item["reinforcement_intact"] = item.reinforcement_installed and PaidRoomReinforcement.intact(actuator) - return {"schema_version": 5 if int(_state.schema_version) == 14 else (4 if _state.has("room_bypasses") else 3), "control_epoch": epoch, "can_operate": authorize_door(context, {"control_epoch": epoch}).is_success, "doors": items} + if int(_state.schema_version) >= 15: + for item in items: + item["chamber_kit_installed"] = not PaidChamber.kit_for(_state, item.door_id).is_empty() + item["chamber"] = _room_pressure.chamber_observation(item.door_id) if _room_pressure != null else {} + return {"schema_version": 6 if int(_state.schema_version) >= 15 else (5 if int(_state.schema_version) == 14 else (4 if _state.has("room_bypasses") else 3)), "control_epoch": epoch, "can_operate": authorize_door(context, {"control_epoch": epoch}).is_success, "doors": items} func door_solids() -> ServiceResult: @@ -1502,4 +1521,16 @@ static func source_ids(revision: int) -> Array[String]: var ids: Array[String] = ["deposit.iron", "deposit.ice", "deposit.regolith"] if revision >= 2: ids.append("deposit.iron.outcrop") if revision >= 3: ids.append("deposit.iron.reinforcement") + if revision >= 4: ids.append("deposit.iron.chamber") return ids + + +func _admit_paid_open(door_id: String, context: AuthenticatedCommandContext) -> ServiceResult: + if context == null or not _state.doors.has(door_id): return _failure("Unknown paid opening authority.") + var door: Dictionary = _state.doors[door_id] + var auth: ServiceResult = _inventory.authorize(context, true) + if not auth.is_success or door.principal_id != String(context.principal_id) or door.body_id != auth.value: return _failure("Only the constructing current body may open this door.") + if not PaidDoorConstruction.reachable(_inventory, context, door.position).is_success or not _inventory.active_tool(context, "tool.crowbar").is_success: return _failure("Opening requires current reach and the actual held crowbar.") + var safe: ServiceResult = _room_pressure.validate_room_open(door_id) + if not safe.is_success: return safe + return ServiceResult.success(_room_pressure.room_difference(door_id)) diff --git a/src/core/runtime/world_runtime.gd b/src/core/runtime/world_runtime.gd index 29777d08..7fb66262 100644 --- a/src/core/runtime/world_runtime.gd +++ b/src/core/runtime/world_runtime.gd @@ -16,6 +16,7 @@ const DEFAULT_REGION_ID: StringName = &"local-habitat" ## Only fresh playable commissioning; snapshot recovery never grants this source. @export var new_world_ore_outcrop_enabled: bool = false @export var new_world_reinforcement_ore_enabled: bool = false +@export var new_world_chamber_ore_enabled: bool = false var _simulation_runner: SimulationRunner var _local_request_sequence: int = 0 var _local_request_nonce: String = Crypto.new().generate_random_bytes(8).hex_encode() @@ -61,7 +62,7 @@ func _ready() -> void: _simulation_runner.add_child(tool_system) var production_system := ProductionSimulationSystem.new() production_system.name = "ProductionSimulationSystem" - _production_setup_result = production_system.configure(inventory_system, tool_system, new_world_ore_outcrop_enabled, new_world_reinforcement_ore_enabled) + _production_setup_result = production_system.configure(inventory_system, tool_system, new_world_ore_outcrop_enabled, new_world_reinforcement_ore_enabled, new_world_chamber_ore_enabled) if _production_setup_result.is_success: _production_setup_result = production_system.bind_door_access(access_control_system) if _production_setup_result.is_success: _production_setup_result = production_system.bind_room_pressure(pressure_system, world_id, region_id) _simulation_runner.add_child(production_system) diff --git a/src/presentation/habitat/procedural_habitat_preview.gd b/src/presentation/habitat/procedural_habitat_preview.gd index 55577fd1..ea34b2b1 100644 --- a/src/presentation/habitat/procedural_habitat_preview.gd +++ b/src/presentation/habitat/procedural_habitat_preview.gd @@ -10,6 +10,8 @@ var _door_shapes: Dictionary = {} var _doors: Dictionary = {} var _targets: Dictionary = {"inner": 1.2, "outer": 1.2} var _pressure_label: Label3D +var _paid_worksite_legacy_wall: MeshInstance3D +var _paid_worksite_legacy_collider: CollisionShape3D func _ready() -> void: @@ -35,7 +37,11 @@ func _ready() -> void: _box("BackWall", Vector3(0.2, 2.5, 10.0), Vector3(-21.0, 1.25, 0.0), wall_color) for side in [-1.0, 1.0]: _box("HabitatWall", Vector3(20.0, 2.5, 0.2), Vector3(-11.0, 1.25, side * 5.0), wall_color) - _box("DoorFrameWall", Vector3(0.2, 2.5, 4.0), Vector3(-1.0, 1.25, side * 3.0), wall_color) + var frame: MeshInstance3D = _box("DoorFrameWall", Vector3(0.2, 2.5, 4.0), Vector3(-1.0, 1.25, side * 3.0), wall_color, false) + var frame_collider: CollisionShape3D = _collider(frame, "Barrier", Vector3(0.2, 2.5, 4.0), Vector3.ZERO) + if side < 0.0: + _paid_worksite_legacy_wall = frame + _paid_worksite_legacy_collider = frame_collider _box("ChamberWall", Vector3(2.0, 2.5, 0.12), Vector3(0.0, 1.25, side), Color("#96a8b1")) _doors["inner"] = _box("InnerDoor", Vector3(0.12, 2.4, 1.8), Vector3(-1.0, 1.2, 0.0), Color("#52a3ba"), false) _doors["outer"] = _box("OuterDoor", Vector3(0.12, 2.4, 1.8), Vector3(1.0, 1.2, 0.0), Color("#d69356"), false) @@ -69,6 +75,10 @@ func _ready() -> void: func focus_production() -> void: + # Only this obsolete presentation wall intersects the paid worksite at + # world(-0.95,-2). Logical prototype pressure and its doors are unchanged. + if _paid_worksite_legacy_wall != null: _paid_worksite_legacy_wall.visible = false + if _paid_worksite_legacy_collider != null: _paid_worksite_legacy_collider.disabled = true if _pressure_label != null: _pressure_label.visible = false diff --git a/src/presentation/production/horizontal_panel_controls.gd b/src/presentation/production/horizontal_panel_controls.gd index 86e788f9..2dfa8d95 100644 --- a/src/presentation/production/horizontal_panel_controls.gd +++ b/src/presentation/production/horizontal_panel_controls.gd @@ -99,7 +99,7 @@ func observe(production: Dictionary) -> void: _pending = "" _record.clear() _panels.clear() - if not ControlContractValidation.is_integer(production.get("schema_version"), 10, 16) or not WorkstationResourceView.valid(production) or int(production["state_revision"]) < _revision or int(production["last_tick"]) < _tick: + if not ControlContractValidation.is_integer(production.get("schema_version"), 10, 17) or not WorkstationResourceView.valid(production) or int(production["state_revision"]) < _revision or int(production["last_tick"]) < _tick: observation_changed.emit({}) _refresh() return diff --git a/src/presentation/production/horizontal_panel_preview.gd b/src/presentation/production/horizontal_panel_preview.gd index 0eae1494..730fb6fe 100644 --- a/src/presentation/production/horizontal_panel_preview.gd +++ b/src/presentation/production/horizontal_panel_preview.gd @@ -12,7 +12,7 @@ var _selected: String = "" func observe(production: Dictionary) -> void: var surfaces: Array = [] - if ControlContractValidation.is_integer(production.get("schema_version"), 10, 16) and HorizontalPanelObservation.valid(production.get("horizontal_panels"), production.get("state_revision")): + if ControlContractValidation.is_integer(production.get("schema_version"), 10, 17) and HorizontalPanelObservation.valid(production.get("horizontal_panels"), production.get("state_revision")): surfaces = production["horizontal_panels"]["surfaces"] var retained: Dictionary = {} for surface in surfaces: diff --git a/src/presentation/production/local_production_panel.gd b/src/presentation/production/local_production_panel.gd index f373006a..545e8722 100644 --- a/src/presentation/production/local_production_panel.gd +++ b/src/presentation/production/local_production_panel.gd @@ -89,8 +89,6 @@ func configure(world: WorldRuntime) -> ServiceResult: _activity = _label() _gather_details = _label() _deposit = _picker() - for id in ["deposit.iron", "deposit.ice", "deposit.regolith", "deposit.iron.outcrop", "deposit.iron.reinforcement"]: - _deposit.add_item(id) _deposit.item_selected.connect(func(_index: int) -> void: refresh()) _extract_button = _button("Extract selected mass into canister (up to 1 kg)", _extract_action) _recipe = _picker() @@ -224,6 +222,7 @@ func refresh() -> void: _guide_label.text = _guide.text() var valid_observation: bool = observed.is_success and typeof(observed.value) == TYPE_DICTIONARY and WorkstationResourceView.valid(observed.value) _record = observed.value if valid_observation else {} + _fill_cargo_picker(_deposit, _record.get("deposits", {}).keys()) _refresh_equipment() _fill_recipes(_record.get("recipes", [])) if not _world is RemoteWorldRuntime: diff --git a/src/presentation/production/paid_door_controls.gd b/src/presentation/production/paid_door_controls.gd index 5c06a422..311a56bd 100644 --- a/src/presentation/production/paid_door_controls.gd +++ b/src/presentation/production/paid_door_controls.gd @@ -38,11 +38,11 @@ func configure(world: WorldRuntime, local_principal: StringName = &"local-invent _details.add_child(_doors) _doors.item_selected.connect(func(index: int) -> void: _selected = _doors.get_item_metadata(index)) _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _status.text = "Requires a paid floor under the full swing. Stand outside the swing within 1.5 m, with a usable crowbar. Seal: 0.2 kg plate. Commission one complete habitat enclosure for finite pressure service; open before decommissioning." + _status.text = "Requires a paid floor under the full swing. Stand outside the swing within 1.5 m of the handle, with a usable crowbar. Seal: 0.2 kg plate. For a two-door chamber, fit both seals first, then both bypasses, then the 6 kg kit on the inner door, then commission. Close the current door and valve before operating the opposite side. Chamber dismantling requires both valves closed and both sides pressure-safe; it may require habitat depressurization. A single habitat compartment uses its separate commission/decommission actions." _details.add_child(_status) - for operation in ["door_build", "door_open", "door_close", "door_remove", "door_seal_install", "door_seal_remove", "door_room_commission", "door_room_decommission"] + PaidRoomBypass.OPERATIONS + PaidRoomReinforcement.OPERATIONS: + for operation in ["door_build", "door_open", "door_close", "door_remove", "door_seal_install", "door_seal_remove", "door_room_commission", "door_room_decommission"] + PaidRoomBypass.OPERATIONS + PaidRoomReinforcement.OPERATIONS + PaidChamber.OPERATIONS: var button := Button.new() - button.text = {"door_reinforcement_install": "Fit 110 kPa prototype kit: 4 kg plate", "door_reinforcement_recover": "Recover kit: decommission first", "door_bypass_install": "Fit manual bypass: 0.2 kg plate", "door_bypass_open": "Open bypass to surroundings", "door_bypass_close": "Close bypass", "door_bypass_recover": "Recover bypass: decommission first", "door_room_commission": "Commission habitat compartment", "door_room_decommission": "Decommission open compartment", "door_seal_install": "Fit/rework seal: 0.2 kg plate", "door_seal_remove": "Remove seal: recover 0.2 kg plate", "door_build": "Build manual door", "door_open": "Pry selected door open", "door_close": "Pry selected door closed", "door_remove": "Disassemble selected closed door"}[operation] + button.text = {"door_chamber_kit_install": "Fit chamber kit on inner door: 6 kg plate", "door_chamber_kit_recover": "Recover chamber kit: decommission first", "door_chamber_commission": "Commission paid inner/outer chamber", "door_chamber_decommission": "Dismantle only when both sides pressure-safe; may require habitat depressurization", "door_reinforcement_install": "Fit 110 kPa prototype kit: 4 kg plate", "door_reinforcement_recover": "Recover kit: decommission first", "door_bypass_install": "Fit manual bypass: 0.2 kg plate", "door_bypass_open": "Open bypass to surroundings", "door_bypass_close": "Close bypass", "door_bypass_recover": "Recover bypass: decommission first", "door_room_commission": "Commission habitat compartment", "door_room_decommission": "Decommission open compartment", "door_seal_install": "Fit/rework seal: 0.2 kg plate", "door_seal_remove": "Remove seal: recover 0.2 kg plate", "door_build": "Build manual door", "door_open": "Pry selected door open", "door_close": "Pry selected door closed", "door_remove": "Disassemble selected closed door"}[operation] button.disabled = true button.pressed.connect(_send.bind(operation)) _details.add_child(button) @@ -66,10 +66,10 @@ func _process(delta: float) -> void: var context := AuthenticatedCommandContext.for_local_development(_local_principal, _world.world_id, _world.region_id) var observed: ServiceResult = _world.production_observation(context) _record.clear() - if observed.is_success and ControlContractValidation.is_integer(observed.value.get("schema_version"), 12, 16) and WorkstationResourceView.valid(observed.value): _record = observed.value + if observed.is_success and ControlContractValidation.is_integer(observed.value.get("schema_version"), 12, 17) and WorkstationResourceView.valid(observed.value): _record = observed.value _doors.clear() for door in _record.get("paid_doors", {}).get("doors", []): - _doors.add_item("%s: %s; %s" % [String(door.door_id).right(8), "open" if door.opened else "closed", seal_text(door)]) + _doors.add_item("%s: %s; %s" % [String(door.door_id).right(8), "open" if door.opened else "closed", seal_text(door, _record.paid_doors.doors)]) _doors.set_item_metadata(_doors.item_count - 1, door.door_id) if _selected == door.door_id: _doors.select(_doors.item_count - 1) if _doors.item_count > 0 and _selected.is_empty(): _selected = _doors.get_item_metadata(0) @@ -92,7 +92,14 @@ func _send(operation: String) -> void: if not queued.is_success: _pending = ""; _status.text = queued.message -static func seal_text(door: Dictionary) -> String: +static func seal_text(door: Dictionary, doors: Array = []) -> String: + if not door.get("chamber", {}).is_empty(): + var c: Dictionary = door.chamber + var opposite: String = c.outer_door_id if c.side == "inner" else c.inner_door_id + var boundary: String = "unavailable" + for other in doors: + if other.door_id == opposite: boundary = "%s, seal %.0f%%, %s" % ["open" if other.opened else "closed", other.seal_condition * 100.0, "leaking" if other.pressure_room.leaking else "retaining"] + return "%s chamber door; pair %s / %s; habitat %.3f / chamber %.3f / exterior %.6f kPa; opposite %s; valves inner %s outer %s; delta load %.1f / 1000 N" % [c.side, c.inner_door_id.right(8), c.outer_door_id.right(8), c.habitat_pressure_kpa, c.chamber_pressure_kpa, c.exterior_pressure_kpa, boundary, c.inner_bypass_open, c.outer_bypass_open, absf(door.pressure_room.pressure_kpa - door.pressure_room.ambient_pressure_kpa) * 1710.0] if not door.get("pressure_room", {}).is_empty(): var room: Dictionary = door.pressure_room var difference: float = absf(float(room.pressure_kpa) - float(room.ambient_pressure_kpa)) diff --git a/src/presentation/production/timed_canister_controls.gd b/src/presentation/production/timed_canister_controls.gd index b2cc6e74..d5536368 100644 --- a/src/presentation/production/timed_canister_controls.gd +++ b/src/presentation/production/timed_canister_controls.gd @@ -69,7 +69,7 @@ func observe(production: Dictionary) -> void: _epoch = -1 _selected_id = "" _record.clear() - if not ControlContractValidation.is_integer(production.get("schema_version"), 9, 16) or not ControlContractValidation.is_integer(production.get("state_revision"), 0, ProductionSimulationSystem.MAX_REVISION) or int(production["state_revision"]) < _revision or not TimedCanisterConstruction.valid_observation(production.get("timed_canister")): + if not ControlContractValidation.is_integer(production.get("schema_version"), 9, 17) or not ControlContractValidation.is_integer(production.get("state_revision"), 0, ProductionSimulationSystem.MAX_REVISION) or int(production["state_revision"]) < _revision or not TimedCanisterConstruction.valid_observation(production.get("timed_canister")): _refresh() return var observed: Dictionary = production["timed_canister"] diff --git a/tests/godot/commissioned_room_equalization_test.gd b/tests/godot/commissioned_room_equalization_test.gd index 40369d46..62521e6e 100644 --- a/tests/godot/commissioned_room_equalization_test.gd +++ b/tests/godot/commissioned_room_equalization_test.gd @@ -84,7 +84,7 @@ func equalization_checks() -> void: check(room().gas_moles == retained and not pressure_owner().room_valve_open(door_id), "closed reload retains actual gas and position") var controls := PaidDoorControls.new() root.add_child(controls); controls.configure(world, context.principal_id); controls.set_process(false); controls._process(0.2) - check(controls._buttons.size() == 14, "manual bypass actions share collapsed door controls") + check(controls._buttons.size() == 18 and controls._buttons[9].text == "Open bypass to surroundings", "manual bypass actions retain their collapsed door control position") controls._buttons[9].pressed.emit() check(not controls._pending.is_empty(), "actual open-bypass button queues authenticated command") step(); controls._process(0.2) @@ -149,7 +149,7 @@ func bypass_reject(operation: String, extra: Dictionary = {}) -> void: func malformed_bypass_restores(snapshot: String) -> void: for mutation in ["bill", "orphan", "missing", "open_type", "extra", "version", "grant", "pressure_legacy"]: - var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(true, false); root.add_child(target) var before: Variant = JSON.parse_string(target.capture_world_snapshot_json().value) var bad: Dictionary = JSON.parse_string(snapshot) match mutation: @@ -184,7 +184,7 @@ func overrating_rejection_controls(snapshot: String) -> void: var shift: float = (target_difference - current) / (factor * (1.0 / float(room_record.volume_m3) + 1.0 / float(ambient_record.volume_m3))) room_record.gas_moles += shift; ambient_record.gas_moles -= shift bad.systems.pressure.room_valves[door_id] = opened - var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(true, false); root.add_child(target) check(target.restore_world_snapshot_json(JSON.stringify(bad)).is_success, "isolated finite overrating rejection fixture restores") var owner: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) var gas: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) diff --git a/tests/godot/horizontal_panel_controls_test.gd b/tests/godot/horizontal_panel_controls_test.gd index 1d9c99b7..20b2b540 100644 --- a/tests/godot/horizontal_panel_controls_test.gd +++ b/tests/godot/horizontal_panel_controls_test.gd @@ -133,7 +133,7 @@ func run() -> void: controls.observe(frame()["production"]) controls.observe(JSON.parse_string(JSON.stringify(frame()["production"]))) check(not controls._record.is_empty() and not preview._panels.is_empty(), "integral JSON version preserves controls and paid panel preview") - for version in [true, 10.5, 17, "13"]: + for version in [true, 10.5, 18, "13"]: var bad_version: Dictionary = frame()["production"].duplicate(true) bad_version.schema_version = version controls.observe(bad_version) diff --git a/tests/godot/paid_chamber_test.gd b/tests/godot/paid_chamber_test.gd new file mode 100644 index 00000000..434e55fd --- /dev/null +++ b/tests/godot/paid_chamber_test.gd @@ -0,0 +1,479 @@ +extends "res://tests/godot/paid_room_reinforcement_test.gd" + +var inner_id: String +var outer_id: String +var original_spawn: Array +var transit_remote: RemoteWorldRuntime + + +## Real factory and finite resources; no positive-route owner/stock/gas writes. +func run() -> void: + world = FirstPlayableBootstrap.create_playable_world() + root.add_child(world) + world.command_completed.connect(func(_command: SimulationCommand, result: ServiceResult) -> void: last = result) + check(world.start_simulation().is_success, "fresh chamber factory starts") + runner = world.get_node("SimulationRunner"); runner.set_physics_process(false) + production = world.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + inventory = world.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + access = world.get_simulation_system(AccessControlSystem.SYSTEM_ID) + context = AuthenticatedCommandContext.for_local_development(&"local-inventory-target", world.world_id, world.region_id) + original_spawn = inventory.own_position(context).value.duplicate() + var pristine: Dictionary = production.capture_snapshot().value + check(pristine.schema_version == 15 and pristine.catalog_revision == 4 and ProductionSimulationSystem._mass(pristine.deposits) == 54000000, "explicit finite 54kg origin") + move([0, 0, 0]) + inventory_action("world", {"operation": "pickup", "item_id": "resource-canister-2", "world_container_id": "world.tray-1", "destination_container_id": "body.human-1", "slot_id": ""}) + inventory_action("self", {"operation": "equip", "item_id": "extractor-2", "source_container_id": "storage.backpack-1", "destination_container_id": "", "slot_id": "hand.right"}) + inventory_action("self", {"operation": "select_hand", "item_id": "", "source_container_id": "", "destination_container_id": "", "slot_id": "hand.right"}) + move([-3000, 0, -1000]) + catalog_extraction_paths(world.capture_world_snapshot_json().value) + for batch in range(39): + move([-3000, 0, -1000]) + var source: String = "deposit.iron" if batch < 10 else ("deposit.iron.outcrop" if batch < 17 else ("deposit.iron.reinforcement" if batch < 24 else "deposit.iron.chamber")) + if batch == 24: + var panel := ActorPanel.new(); panel.actor = context + root.add_child(panel); panel.configure(world); panel.set_process(false) + var found: int = -1 + for index in range(panel._deposit.item_count): + if panel._deposit.get_item_text(index) == source: found = index + check(found >= 0, "actual source selector exposes distinct chamber seam") + if found >= 0: panel._deposit.select(found) + panel.refresh(); check(not panel._extract_button.disabled, "held extractor can acquire selected chamber ore") + panel.free() + command("tool_extract", {"deposit_id": source, "carrier_id": "resource-canister-2", "amount_milligrams": 1000000}) + move([0, 0, 0]) + command("unload_cargo", {"carrier_id": "resource-canister-2", "material": "ore.iron", "amount_milligrams": 1000000}) + command("process", {"recipe_id": "refine.iron"}); step(120) + if failures: finish(); return + for batch in range(21): command("process", {"recipe_id": "craft.plate"}); step(120) + command("fabricate_equipment", {"blueprint_id": "equipment.crowbar"}) + inventory_action("self", {"operation": "unequip", "item_id": "", "source_container_id": "", "destination_container_id": "storage.backpack-1", "slot_id": "hand.right"}) + var crowbar: String = "" + for item in inventory.own_inventory_observation(context).value.items: + if item.definition_id == "item.crowbar.fabricated": crowbar = item.item_id + equip(crowbar) + build_chamber() + if failures: finish(); return + var pair: ServiceResult = PaidChamber.candidate(production._state, String(context.principal_id), "body.human-1") + check(pair.is_success, "exact paid two-door topology") + if not pair.is_success: finish(); return + inner_id = pair.value.inner_door_id; outer_id = pair.value.outer_door_id + for id in [inner_id, outer_id]: + door_id = id; door_command("door_seal_install", {"door_id": id}) + if id == inner_id: bypass_reject("door_bypass_install") + for id in [inner_id, outer_id]: + door_id = id; chamber_ui(8) + if failures: finish(); return + door_id = inner_id + var insufficient: Dictionary = production._state.duplicate(true) + insufficient.store.plate = PaidChamber.MASS - 1 + var stock_before: Dictionary = production.capture_snapshot().value + check(not PaidChamber.plan(insufficient, inventory, access, context, "insufficient-chamber-kit", "door_chamber_kit_install", {"expected_revision": stock_before.state_revision, "control_epoch": 0, "door_id": inner_id}, pressure_owner()).is_success and production.capture_snapshot().value == stock_before, "kit cannot consume insufficient detached stock or change live owners") + chamber_ui(14); chamber_ui(16) + if failures: finish(); return + var paid: Dictionary = production.capture_snapshot().value + check(paid.energy_joules == 28250 and paid.store.plate == 600000 and paid.store.iron == 400000 and paid.store.tailings == 15600000, "39kg route accounts for20.4kg installed plate, crowbar and71750J") + for source in ["deposit.iron", "deposit.iron.outcrop", "deposit.iron.reinforcement", "deposit.iron.chamber"]: check(paid.deposits[source] == 0, "source exhausted: " + source) + check(paid.deposits["deposit.ice"] == 5000000 and paid.deposits["deposit.regolith"] == 10000000, "untouched non-ore origin") + var initial_moles: float = all_moles() + var snapshot: String = world.capture_world_snapshot_json().value + var observation: Dictionary = production.observation(context).value + var projection := RemoteProductionProjection.new() + check(projection.accept_record(observation).is_success, "real complete v17 projection admits chamber controls") + projection.free() + observation_consumers(observation) + chamber_negatives(snapshot) + detached_chamber_controls(snapshot) + legacy_chamber_origins() + check_floor_contract() + if failures: finish(); return + # Genuine local body starts at the original observed spawn and walks the + # actual first-playable preview. Command movement mirrors reached positions. + move([3600, 0, -5500]); move(original_spawn) + await physical_transit() + if failures: finish(); return + reload_playable(world.capture_world_snapshot_json().value, "whole chamber reload after both physical crossings") + check(gas_conserved(initial_moles), "all finite volumes conserve total moles across transit and reload") + var habitat_before: float = pressure_owner().volumes[PressureSystem.PROTOTYPE_HABITAT_ID].pressure_kpa() + for cycle in range(2): + door_id = inner_id; chamber_ui(9); step(600); chamber_ui(10) + check(pressure_owner().room_difference(inner_id) < 0.59, "finite inner equalization permits manual force") + chamber_ui(1); chamber_ui(2) + door_id = outer_id; chamber_ui(9) + var isolated: float = pressure_owner().volumes[PressureSystem.PROTOTYPE_HABITAT_ID].gas_moles + step(600); chamber_ui(10) + check(pressure_owner().volumes[PressureSystem.PROTOTYPE_HABITAT_ID].gas_moles == isolated, "outer vent isolates intact habitat boundary") + check(pressure_owner().room_difference(outer_id) < 0.59, "finite outer equalization permits manual force") + chamber_ui(1); chamber_ui(2) + check(gas_conserved(initial_moles), "both-direction cycle conserves all gas") + check(pressure_owner().volumes[PressureSystem.PROTOTYPE_HABITAT_ID].pressure_kpa() < habitat_before, "repeated cycles deplete finite habitat without refill") + door_id = inner_id; bypass_reject("door_chamber_decommission") + chamber_ui(9) + reload_playable(world.capture_world_snapshot_json().value, "open inner bypass resume") + check(pressure_owner().room_valve_open(inner_id), "open paired bypass survives reload") + chamber_ui(10) + var fixture := FileAccess.open("res://var/paid-chamber-fixture.json", FileAccess.WRITE) + fixture.store_string(JSON.stringify({"world": JSON.parse_string(snapshot), "observation": observation, "pristine": pristine})); fixture.close() + finish() + + +func build_chamber() -> void: + for count in range(4): command("materialize_plate", {}) + var plates: Array = production.observation(context).value.components.duplicate() + check(plates.size() == 4, "four actually acquired physical wall plates") + if plates.size() != 4: return + move([6000, 0, -5000]) + for index in range(2): command("wall_build", {"item_id": plates[index], "position": [5500 + index * 1000, 0, -4550], "quarter_turn": 0}) + move([8000, 0, -5500]); move([8000, 0, -2500]); move([6000, 0, -2500]) + for index in range(2): command("wall_build", {"item_id": plates[index + 2], "position": [5500 + index * 1000, 0, -3450], "quarter_turn": 0}) + move([8000, 0, -2500]); move([8000, 0, -4000]) + for bounds in [[6100, 7000, -4500, -3500], [7000, 7900, -4500, -3500]]: command("panel_build", {"kind": "floor", "bounds_millimetres": bounds, "height_millimetres": 0}) + for bounds in [[6100, 6900, -4500, -3500]]: command("panel_build", {"kind": "ceiling", "bounds_millimetres": bounds, "height_millimetres": 2000}) + move([8000, 0, -5500]); move([6000, 0, -5000]) + command("panel_build", {"kind": "floor", "bounds_millimetres": [5100, 6100, -4500, -3500], "height_millimetres": 0}) + command("panel_build", {"kind": "ceiling", "bounds_millimetres": [5100, 6100, -4500, -3500], "height_millimetres": 2000}) + move([3600, 0, -5500]); move([3600, 0, -4000]) + command("panel_build", {"kind": "floor", "bounds_millimetres": [4100, 5100, -4500, -3500], "height_millimetres": 0}) + door_command("door_build", {"position": [5050, 0, -4000], "quarter_turn": 1}) + move([3600, 0, -5500]); move([8000, 0, -5500]); move([8000, 0, -4000]) + door_command("door_build", {"position": [6950, 0, -4000], "quarter_turn": 1}) + move([8000, 0, -5500]); move([6000, 0, -5000]) + + +func chamber_ui(index: int, accepted: bool = true) -> void: + var controls := PaidDoorControls.new() + root.add_child(controls); controls.configure(world, context.principal_id); controls.set_process(false) + controls._process(0.2) + for option in range(controls._doors.item_count): + if controls._doors.get_item_metadata(option) == door_id: controls._doors.item_selected.emit(option) + controls._process(0.2) + check(not controls._buttons[index].disabled, "actual selected door action enabled: %d" % index) + controls._buttons[index].pressed.emit() + check(not controls._pending.is_empty(), "actual chamber UI queues authenticated intent") + step(); check(last.is_success == accepted, "chamber UI%d %s: %s" % [index, door_id, last.message]) + controls.free() + + +func check_floor_contract() -> void: + var full: Array[Dictionary] = [] + var short: Array[Dictionary] = [] + for id in production._state.panels: + var panel: Dictionary = production._state.panels[id] + full.append(HorizontalPanelConstruction.descriptor(id, panel)) + if panel.bounds_millimetres[0] < 7000: short.append(HorizontalPanelConstruction.descriptor(id, panel)) + for x in [4500, 5050, 5550, 6950, 7500]: + var query: Dictionary = {"schema_version": 1, "expected_production_revision": 0, "footprint_millimetres": [x - 300, x + 300, -4300, -3700], "foot_height_millimetres": 0, "vertical_tolerance_millimetres": 10} + check(HorizontalFloorSupportQuery.query(query, full, 0).value.supported, "full600mm footprint supported across both apertures:%d" % x) + if x == 6950: check(not HorizontalFloorSupportQuery.query(query, short, 0).value.supported, "old short approach fails at outer aperture") + + +func physical_transit() -> void: + host = RecordingHost.new(); root.add_child(host); host.set_process(false); host._world = world + host._keys = ["chamber-actor-000000000000000000", "chamber-target-00000000000000000"] + world.command_completed.connect(host._on_completed); connect_door_peer(1, 1) + transit_remote = RemoteWorldRuntime.new(); root.add_child(transit_remote); transit_remote.set_process(false) + transit_remote._receive({"type": "authenticated", "slot": 1, "world_id": String(world.world_id), "region_id": String(world.region_id)}) + var preview := ProceduralHabitatPreview.new(); root.add_child(preview) + check(not preview._paid_worksite_legacy_collider.disabled, "normal preview retains its legacy wall collider") + preview.focus_production() + preview.production.observe(production.observation(context).value) + check(preview._paid_worksite_legacy_collider.disabled and not preview._paid_worksite_legacy_wall.visible, "production focus removes only exact obsolete worksite obstruction") + var doors := PaidDoorContactScene.new(); root.add_child(doors); doors.bind(transit_remote); doors.set_physics_process(false) + var panels := PaidPanelContactScene.new(); root.add_child(panels); panels.bind(transit_remote); panels.set_physics_process(false) + var cleanup := func() -> void: + panels.free(); doors.free(); transit_remote.free(); preview.free(); host.free() + transit_remote._receive(publish_door(1)) + var actor: LocalHabitatCharacter = preview.character + actor.position = ProceduralWorldInventoryPreview.DISPLAY_ORIGIN + Vector3(float(original_spawn[0]), maxf(950.0, float(original_spawn[1])), float(original_spawn[2])) / 1000.0 + actor.rotation.y = 0.0; actor.read_local_keyboard = false; actor.set_navigation_enabled(true); actor.set_physics_process(false) + await walk_to(actor, Vector2(-2.4, -3.5)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-1.5, -3.5)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-1.5, -2.0)) + if failures: cleanup.call(); return + check(actor.is_on_floor(), "actual spawn-to-inner-approach route remains grounded") + var closed_start: Vector3 = actor.position + actor.set_motion_intent(Vector2.RIGHT, false) + for frame in range(12): await physics_frame; actor._physics_process(1.0 / 60.0) + check(actor.position.x < -1.25, "paid closed inner leaf blocks actual body after preview cleanup") + actor.set_motion_intent(Vector2.ZERO, false) + # Submit observed reached positions, never reposition the body after spawn. + await walk_to(actor, Vector2(-1.5, -3.0)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-0.95, -3.0)) + if failures: cleanup.call(); return + door_id = inner_id; chamber_ui(1) + transit_remote._receive(publish_door(1)); await physics_frame + # The opened q1 leaf occupies x[-1.85,-0.95], z[-2.45,-2.4]. + # Walk around its free end, retaining clearance for the whole body. + await walk_to(actor, Vector2(-2.4, -3.0)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-2.4, -2.0)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-0.45, -2.0)) + if failures: cleanup.call(); return + chamber_ui(2) + check(actor.position.distance_to(closed_start) > 0.5 and actor.is_on_floor(), "actual600mm body crosses inner aperture onto chamber floor") + door_id = outer_id; chamber_ui(9); step(600); chamber_ui(10); chamber_ui(1) + transit_remote._receive(publish_door(1)); await physics_frame + await walk_to(actor, Vector2(1.5, -2.0)) + if failures: cleanup.call(); return + chamber_ui(2) + check(actor.is_on_floor(), "actual body fully crosses outer aperture onto extended paid floor") + # Return in the opposite direction with the same finite hardware/gas. + chamber_ui(1); transit_remote._receive(publish_door(1)); await physics_frame + await walk_to(actor, Vector2(-0.45, -2.0)) + if failures: cleanup.call(); return + chamber_ui(2) + door_id = inner_id; chamber_ui(9); step(600); chamber_ui(10); chamber_ui(1) + transit_remote._receive(publish_door(1)); await physics_frame + await walk_to(actor, Vector2(-2.4, -2.0)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-2.4, -3.0)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(-0.95, -3.0)) + if failures: cleanup.call(); return + chamber_ui(2) + check(actor.is_on_floor(), "actual body returns through both supported apertures") + # Move outside both sweeps for later isolated pressure cycles. + await walk_to(actor, Vector2(-1.5, -3.5)) + if failures: cleanup.call(); return + await walk_to(actor, Vector2(0.0, -3.0)) + if failures: cleanup.call(); return + cleanup.call() + + +func walk_to(actor: LocalHabitatCharacter, destination: Vector2) -> void: + for frame in range(150): + var offset: Vector2 = destination - Vector2(actor.position.x, actor.position.z) + if offset.length() < 0.04: + actor.set_motion_intent(Vector2.ZERO, false) + bridge_body(actor) + return + actor.set_motion_intent(offset.normalized() * minf(1.0, offset.length() * 10.0), false) + await physics_frame + actor._physics_process(1.0 / 60.0) + actor.set_motion_intent(Vector2.ZERO, false) + var contacts: Array[String] = [] + for index in range(actor.get_slide_collision_count()): + var contact: KinematicCollision3D = actor.get_slide_collision(index) + var collider: Object = contact.get_collider() + contacts.append("%s at%s normal%s" % [collider.get_path() if collider is Node else str(collider), contact.get_position(), contact.get_normal()]) + check(false, "actual body reached continuous walking destination: %s, actual:%s, contacts:%s" % [destination, actor.position, contacts]) + + +func bridge_body(actor: LocalHabitatCharacter) -> void: + var point: Vector3 = (actor.position - ProceduralWorldInventoryPreview.DISPLAY_ORIGIN) * 1000.0 + move([roundi(point.x), roundi(point.y), roundi(point.z)]) + + +func chamber_negatives(snapshot: String) -> void: + door_id = inner_id + for operation in ["door_chamber_kit_install", "door_chamber_kit_recover", "door_chamber_commission", "door_chamber_decommission", "door_remove", "door_seal_remove", "door_reinforcement_install", "door_bypass_recover"]: bypass_reject(operation) + bypass_reject("door_bypass_open", {"control_epoch": 1}) + bypass_reject("door_bypass_open", {"expected_revision": production._state.state_revision - 1}) + bypass_reject("door_chamber_commission", {"gas_moles": 1}) + var own: AuthenticatedCommandContext = context + bypass_reject("door_chamber_decommission", {"control_epoch": 1}) + var retained: Dictionary = production._state.receipts.back() + var retry: Dictionary = {"expected_revision": int(retained.state_revision) - 1, "control_epoch": 0, "door_id": inner_id} + var unchanged: Dictionary = production.capture_snapshot().value + check(production.apply_local(context, retained.request_id, "door_chamber_commission", retry, runner.tick_number + 1).is_success and production.capture_snapshot().value == unchanged, "exact chamber receipt replay cannot allocate twice") + retry.door_id = outer_id + check(not production.apply_local(context, retained.request_id, "door_chamber_commission", retry, runner.tick_number + 1).is_success and production.capture_snapshot().value == unchanged, "changed chamber replay cannot redirect paired intent") + context = AuthenticatedCommandContext.for_local_development(&"local-inventory-actor", world.world_id, world.region_id) + bypass_reject("door_bypass_open"); context = own + door_id = outer_id; bypass_reject("door_open") + # The legacy synchronization hook may never bypass paid admission. + access.update_actuator_position(StringName(outer_id), PaidDoorConstruction.actuator_id(outer_id), true) + check(access.devices[StringName(outer_id)].actuator(PaidDoorConstruction.actuator_id(outer_id)).position == 0.0, "legacy low-level synchronization cannot open paid leaf") + for mutation in ["bill", "endpoint", "policy", "half_kit", "half_valve", "orphan", "both_doors", "both_valves", "door_valve", "area", "owner", "float_bill", "extra"]: + var bad: Dictionary = JSON.parse_string(snapshot) + match mutation: + "bill": bad.systems.production.chamber_kits[inner_id].materials.plate += 1 + "float_bill": bad.systems.production.chamber_kits[inner_id].materials.plate = 6000000.5 + "endpoint": bad.systems.pressure.paid_chamber.outer_endpoint = "habitat_main" + "policy": bad.systems.pressure.paid_chamber.policy_id = ConstructedRoomPressure.POLICY + "half_kit": bad.systems.production.chamber_kits.clear(); bad.systems.production.store.plate += PaidChamber.MASS + "half_valve": bad.systems.pressure.room_valves.erase(outer_id) + "orphan": bad.systems.pressure.paid_chamber.volume_id = "paid-chamber-orphan" + "both_valves": bad.systems.pressure.room_valves[inner_id] = true; bad.systems.pressure.room_valves[outer_id] = true + "owner": bad.systems.pressure.paid_chamber.body_id = "body.human-2" + "extra": bad.systems.pressure.paid_chamber.retention_kpa = 1000 + _: + for device in bad.systems.access_control.devices: + if device.device_id in [inner_id, outer_id]: + for actuator_record in device.actuators: + if mutation == "area": actuator_record.effective_pressure_area_m2 = 0.0 + elif mutation == "both_doors" or device.device_id == inner_id: actuator_record.position = 1.0 + if mutation == "door_valve": bad.systems.pressure.room_valves[outer_id] = true + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + var before: String = target.capture_world_snapshot_json().value + check(not target.restore_world_snapshot_json(JSON.stringify(bad)).is_success, "malformed atomic paired restore rejects:" + mutation) + check(target.capture_world_snapshot_json().value == before, "malformed paired restore preserves every owner/tick/epoch:" + mutation) + target.free() + door_id = inner_id + + +## Deliberately altered detached fixtures never fund the positive route. +func detached_chamber_controls(snapshot: String) -> void: + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + check(target.restore_world_snapshot_json(snapshot).is_success, "isolated paired controls restore") + var stock: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var items: InventorySimulationSystem = target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var hardware: AccessControlSystem = target.get_simulation_system(AccessControlSystem.SYSTEM_ID) + var gas: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) + var actor := AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + # Reach uses trusted bound body pose, with identical legacy/real center semantics. + for height in [0, 900]: + items._bodies["body.human-1"].position_millimetres = [5450, height, -4000] + check(PaidDoorConstruction.reachable(items, actor, [6950, 0, -4000]).is_success, "handle at exact1500mm accepted for y%d" % height) + items._bodies["body.human-1"].position_millimetres = [5449, height, -4000] + check(not PaidDoorConstruction.reachable(items, actor, [6950, 0, -4000]).is_success, "handle beyond1500mm rejected for y%d" % height) + for principal in [&"unbound", &"local-inventory-actor"]: + var foreign := AuthenticatedCommandContext.for_local_development(principal, target.world_id, target.region_id) + check(not stock._admit_paid_open(inner_id, foreign).is_success, "foreign/unbound cannot borrow paid handle") + var wrong_scope := AuthenticatedCommandContext.for_local_development(context.principal_id, &"wrong-world", target.region_id) + check(not PaidDoorConstruction.reachable(items, wrong_scope, [6950, 0, -4000]).is_success, "handle rejects wrong world session") + items._bodies["body.human-1"].position_millimetres = [6000, 900, -5000] + var inner: AccessControlledDeviceState = hardware.devices[StringName(inner_id)] + var outer: AccessControlledDeviceState = hardware.devices[StringName(outer_id)] + var prepared := PreparedDoorChange.new(hardware, StringName(inner_id), inner, inner.to_record(), false) + check(prepared.stage_device(StringName(outer_id), outer.to_record()).is_success, "stage second device without writes") + outer.actuator(PaidDoorConstruction.actuator_id(outer_id)).seal_condition = 0.5 + var before: Dictionary = hardware.capture_snapshot().value + check(not prepared.commit(hardware).is_success and hardware.capture_snapshot().value == before, "stale second CAS leaves both devices untouched") + check(not prepared.commit(hardware).is_success, "failed paired commit remains consumed") + check(not gas.validate_room_open(inner_id).is_success, "damaged opposite blocks opening") + inner.actuator(PaidDoorConstruction.actuator_id(inner_id)).position = 1.0 + check(hardware.validate_door_actuation(StringName(inner_id), PaidDoorConstruction.actuator_id(inner_id), false, 0.0, true, actor).is_success, "damaged opposite never prevents own force-safe closing") + target.free() + for entry in [[0.6, "intact"], [-0.6, "intact"], [110.1, "intact"], [0.6, "integrity"], [-0.6, "seal_condition"]]: + var bad: Dictionary = JSON.parse_string(snapshot) + var room: Dictionary = {}; var habitat: Dictionary = {} + for volume in bad.systems.pressure.volumes: + if volume.volume_id == bad.systems.pressure.paid_chamber.volume_id: room = volume + if volume.volume_id == "habitat_main": habitat = volume + var factor: float = PressureVolumeState.GAS_CONSTANT_J_PER_MOL_K * float(room.temperature_kelvin) / 1000.0 + var current: float = factor * (float(room.gas_moles) / float(room.volume_m3) - float(habitat.gas_moles) / float(habitat.volume_m3)) + var shift: float = (float(entry[0]) - current) / (factor * (1.0 / float(room.volume_m3) + 1.0 / float(habitat.volume_m3))) + room.gas_moles += shift; habitat.gas_moles -= shift + for device in bad.systems.access_control.devices: + if device.device_id == inner_id and entry[1] != "intact": device.actuators[0][entry[1]] = 0.5 + target = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + check(target.restore_world_snapshot_json(JSON.stringify(bad)).is_success, "isolated finite chamber load restores") + stock = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + gas = target.get_simulation_system(PressureSystem.SYSTEM_ID) + actor = AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + before = stock.capture_snapshot().value + var rejected: ServiceResult = stock.apply_local(actor, "chamber-force-negative", "door_open", {"expected_revision": before.state_revision, "control_epoch": 0, "door_id": inner_id}, int(bad.tick_number) + 1) + check(not rejected.is_success and rejected.error_code == &"actuator_force_exceeded" and stock.capture_snapshot().value == before, "both pressure signs preserve1000N manual limit") + var leaking: bool = gas.room_observation(inner_id).leaking + check(leaking == (absf(float(entry[0])) > 110.0 or entry[1] != "intact"), "chamber rating and damage independently govern retention") + var moles_before: float = gas.volumes[StringName(gas.paid_chamber.volume_id)].gas_moles + gas._step_paid_chamber(1.0 / 60.0) + check((gas.volumes[StringName(gas.paid_chamber.volume_id)].gas_moles != moles_before) == leaking, "closed valves do not suppress boundary leak") + if leaking: + var closed_result: Dictionary = gas.capture_snapshot().value + # Whole-world restore is startup-only; compare identical fixtures in + # separate runtimes rather than attempting a second startup restore. + target.free() + target = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + var reset: ServiceResult = target.restore_world_snapshot_json(JSON.stringify(bad)) + check(reset.is_success, "fresh runtime restores same finite leak control") + if not reset.is_success: target.free(); return + gas = target.get_simulation_system(PressureSystem.SYSTEM_ID) + gas.room_valves[inner_id] = true + gas._step_paid_chamber(1.0 / 60.0) + check(gas.capture_snapshot().value.volumes == closed_result.volumes, "open valve plus leak uses one conductance") + target.free() + # A recovery-positive fixture deliberately equalizes all three finite endpoints. + # It demonstrates the documented expensive precondition, never a vacuum refill. + var low: Dictionary = JSON.parse_string(snapshot) + var endpoints: Array = [] + var total_moles: float = 0.0; var total_volume: float = 0.0 + for volume in low.systems.pressure.volumes: + if volume.volume_id in ["habitat_main", "exterior_reservoir", low.systems.pressure.paid_chamber.volume_id]: + endpoints.append(volume); total_moles += float(volume.gas_moles); total_volume += float(volume.volume_m3) + for volume in endpoints: volume.gas_moles = total_moles * float(volume.volume_m3) / total_volume + target = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + check(target.restore_world_snapshot_json(JSON.stringify(low)).is_success, "finite force-safe decommission fixture restores") + stock = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + gas = target.get_simulation_system(PressureSystem.SYSTEM_ID) + hardware = target.get_simulation_system(AccessControlSystem.SYSTEM_ID) + actor = AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + var seals: Dictionary = hardware.capture_snapshot().value + var gas_before: float = 0.0 + for volume in gas.volumes.values(): gas_before += volume.gas_moles + for operation in ["door_chamber_decommission", "door_chamber_kit_recover", "door_chamber_kit_install", "door_chamber_commission"]: + if operation == "door_chamber_kit_recover": + for reserved in [false, true]: + var full: Dictionary = stock._state.duplicate(true) + if reserved: full.job = {"outputs": {"plate": ProductionSimulationSystem.MAX_MASS}} + else: full.store.tailings = ProductionSimulationSystem.MAX_MASS + check(not PaidChamber.plan(full, target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID), hardware, actor, "full-chamber-recovery", operation, {"expected_revision": full.state_revision, "control_epoch": 0, "door_id": inner_id}, gas).is_success, "recovery respects stock and reserved-output capacity") + var damaged: ActuatorState = hardware.devices[StringName(outer_id)].actuator(PaidDoorConstruction.actuator_id(outer_id)) + damaged.integrity = 0.5 + var detached: Dictionary = stock._state.duplicate(true) + check(not PaidChamber.plan(detached, target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID), hardware, actor, "damaged-chamber-recovery", operation, {"expected_revision": detached.state_revision, "control_epoch": 0, "door_id": inner_id}, gas).is_success and damaged.integrity == 0.5, "recovery never heals a damaged original actuator") + damaged.integrity = 1.0 + var result: ServiceResult = stock.apply_local(actor, "isolated-" + operation, operation, {"expected_revision": stock._state.state_revision, "control_epoch": 0, "door_id": inner_id}, int(low.tick_number) + 1) + check(result.is_success, "force-safe recovery/refit: " + operation + ": " + result.message) + check(stock._state.store.plate == 600000 and hardware.capture_snapshot().value == seals, "recover/refit conserves plate and original Access condition") + var gas_after: float = 0.0 + for volume in gas.volumes.values(): gas_after += volume.gas_moles + check(absf(gas_after - gas_before) < 1e-8, "decommission/refit never refills gas") + target.free() + + +func catalog_extraction_paths(snapshot: String) -> void: + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + check(target.restore_world_snapshot_json(snapshot).is_success, "isolated extraction catalog control restores actual equipped fixture") + var owner: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var actor := AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + var before: Dictionary = owner.capture_snapshot().value + var candidate: Dictionary = before.duplicate(true) + check(owner._plan(candidate, "mine", {"expected_revision": before.state_revision, "deposit_id": "deposit.iron.chamber"}).is_success and candidate.job.outputs == {"ore.iron": 1000000}, "catalog4 scheduled mining resolves pinned source material") + var reserved: ServiceResult = owner.apply_local(actor, "isolated-chamber-work", "start_tool_extract", {"expected_revision": before.state_revision, "deposit_id": "deposit.iron.chamber", "carrier_id": "resource-canister-2", "cycles": 1}, int(before.last_tick) + 1) + check(reserved.is_success, "catalog4 timed extraction reserves actual seam: " + reserved.message) + if reserved.is_success: + check(target.start_simulation().is_success, "isolated extraction world starts") + var clock: SimulationRunner = target.get_node("SimulationRunner"); clock.set_physics_process(false) + for tick in range(ToolExtractionWork.CYCLE_TICKS + 1): clock._physics_process(1.0 / 60.0) + check(owner._state.job.is_empty() and owner._state.deposits["deposit.iron.chamber"] == 14000000 and owner._state.cargo["resource-canister-2"]["ore.iron"] == 1000000, "timed completion resolves catalog4 with exact conserved output") + target.free() + + +func observation_consumers(observation: Dictionary) -> void: + var timed := TimedCanisterControls.new(); root.add_child(timed); timed.configure(world) + var controls := HorizontalPanelControls.new(); root.add_child(controls); controls.configure(world) + var preview := HorizontalPanelPreview.new(); root.add_child(preview) + timed.observe(observation); controls.observe(observation); preview.observe(observation) + check(not timed._record.is_empty() and not controls._record.is_empty() and preview._panels.size() == production._state.panels.size(), "fresh-world17 populates actual timed/panel controls and panel preview") + var future: Dictionary = observation.duplicate(true); future.schema_version = 18 + timed.observe(future); controls.observe(future); preview.observe(future) + check(timed._record.is_empty() and controls._record.is_empty() and preview._panels.is_empty() and not WorkstationResourceView.valid(future), "unsupported18 clears controls/preview and rejects resource projection") + timed.free(); controls.free(); preview.free() + check(not production.machine_recipe("extract.iron.chamber").is_empty(), "catalog4 machinery resolves distinct seam recipe") + var exhausted: Dictionary = production._state.duplicate(true) + var no_stock: ServiceResult = production._plan(exhausted, "extract", {"expected_revision": exhausted.state_revision, "deposit_id": "deposit.iron.chamber", "carrier_id": "resource-canister-2", "amount_milligrams": 1}) + check(not no_stock.is_success and exhausted.deposits["deposit.iron.chamber"] == 0, "exhausted chamber source cannot mint extra payload") + + +func legacy_chamber_origins() -> void: + for mode in [false, true]: + var legacy: WorldRuntime = FirstPlayableBootstrap.create_playable_world(mode, false); root.add_child(legacy) + var owner: ProductionSimulationSystem = legacy.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var before: Dictionary = owner.capture_snapshot().value + check(before.catalog_revision == (3 if mode else 2) and not before.deposits.has("deposit.iron.chamber"), "explicit old factory receives no chamber source") + var actor := AuthenticatedCommandContext.for_local_development(&"local-inventory-target", legacy.world_id, legacy.region_id) + for operation in ["extract", "tool_extract", "start_tool_extract", "mine"]: + check(not owner.apply_local(actor, "absent-chamber-" + operation, operation, {"expected_revision": 0, "deposit_id": "deposit.iron.chamber"}, 1).is_success and owner.capture_snapshot().value == before, "legacy catalog rejects absent chamber source without lookup/write:" + operation) + check(owner.machine_recipe("extract.iron.chamber").is_empty(), "legacy machinery never advertises future source") + var panel := ActorPanel.new(); panel.actor = actor; root.add_child(panel); panel.configure(legacy); panel.set_process(false) + var seam_found: bool = false + for option in range(panel._deposit.item_count): seam_found = seam_found or panel._deposit.get_item_text(option) == "deposit.iron.chamber" + check(not seam_found, "legacy actual source selector excludes absent chamber seam") + panel.free() + check(legacy.restore_world_snapshot_json(legacy.capture_world_snapshot_json().value).is_success and owner.capture_snapshot().value == before, "legacy reload never grants chamber ore") + legacy.free() diff --git a/tests/godot/paid_chamber_test.gd.uid b/tests/godot/paid_chamber_test.gd.uid new file mode 100644 index 00000000..b5d6788d --- /dev/null +++ b/tests/godot/paid_chamber_test.gd.uid @@ -0,0 +1 @@ +uid://cacq7tf1rnbj5 diff --git a/tests/godot/paid_room_reinforcement_test.gd b/tests/godot/paid_room_reinforcement_test.gd index 3887a306..f32049c5 100644 --- a/tests/godot/paid_room_reinforcement_test.gd +++ b/tests/godot/paid_room_reinforcement_test.gd @@ -7,7 +7,7 @@ class ActorPanel extends LocalProductionPanel: ## Real playable factory, finite acquisition and actual UI command queue. func run() -> void: - world = FirstPlayableBootstrap.create_playable_world() + world = FirstPlayableBootstrap.create_playable_world(true, false) root.add_child(world) world.command_completed.connect(func(_command: SimulationCommand, result: ServiceResult) -> void: last = result) check(world.start_simulation().is_success, "new playable reinforcement world starts") @@ -148,7 +148,7 @@ func reinforcement_negative_controls(snapshot: String) -> void: if device.device_id == door_id: device.actuators[0].effective_pressure_area_m2 = 0.0 "grant": bad.systems.production.store.plate += PaidRoomReinforcement.MASS "extra": bad.systems.production.room_reinforcements[door_id].health = 1.0 - var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(true, false); root.add_child(target) var before: Variant = JSON.parse_string(target.capture_world_snapshot_json().value) check(not target.restore_world_snapshot_json(JSON.stringify(bad)).is_success, "cross-owner malformed restore rejects: " + mutation) check(same_json_value(before, JSON.parse_string(target.capture_world_snapshot_json().value)), "malformed restore preserves all owners: " + mutation) @@ -166,7 +166,7 @@ func reinforcement_negative_controls(snapshot: String) -> void: room_record.gas_moles += shift; ambient_record.gas_moles -= shift for device in bad.systems.access_control.devices: if device.device_id == door_id and entry[1] != "intact": device.actuators[0][entry[1]] = 0.5 - var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(); root.add_child(target) + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world(true, false); root.add_child(target) check(target.restore_world_snapshot_json(JSON.stringify(bad)).is_success, "finite isolated retention control restores") var gas: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) var owner: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) diff --git a/tests/godot/timed_canister_controls_test.gd b/tests/godot/timed_canister_controls_test.gd index 48518e6a..7ee6fe14 100644 --- a/tests/godot/timed_canister_controls_test.gd +++ b/tests/godot/timed_canister_controls_test.gd @@ -238,7 +238,7 @@ func projection_checks(current: Dictionary, pre_completion: Dictionary) -> void: remote_controls.configure(remote) remote_controls.observe(remote.production_observation(context).value) check(not remote_controls._record.is_empty(), "remote UI reads real completed host projection") - for version in [true, 10.5, 17, "13"]: + for version in [true, 10.5, 18, "13"]: var bad_version: Dictionary = remote.production_observation(context).value.duplicate(true) bad_version.schema_version = version remote_controls.observe(bad_version) diff --git a/tests/schema/paid_chamber_schema_test.py b/tests/schema/paid_chamber_schema_test.py new file mode 100644 index 00000000..4f6ab316 --- /dev/null +++ b/tests/schema/paid_chamber_schema_test.py @@ -0,0 +1,72 @@ +"""Versioned chamber schemas; CI requires genuine emitted runtime fixtures.""" +from copy import deepcopy +import json +from pathlib import Path +import sys + +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +ROOT = Path(__file__).resolve().parents[2] +paths = list((ROOT / "docs/schema").glob("*.schema.json")) +schemas = [json.loads(path.read_text(encoding="utf-8-sig")) for path in paths] +registry = Registry().with_resources( + [(s["$id"], Resource.from_contents(s)) for s in schemas if "$id" in s] + + [(p.name, Resource.from_contents(s)) for p, s in zip(paths, schemas)] +) + + +def validator(name): + schema = next(s for s in schemas if s.get("$id") == "urn:frontier-systems:schema:" + name) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema, registry=registry) + + +for name in ["production-system:v15", "production-observation:v17", "paid-door-observation:v6", "pressure-system:v5", "world-snapshot:v1"]: + validator(name) +intent = validator("production-intent:v1") +for operation in ["door_chamber_kit_install", "door_chamber_kit_recover", "door_chamber_commission", "door_chamber_decommission"]: + value = {"operation": operation, "payload": {"expected_revision": 0, "control_epoch": 0, "door_id": "door-test"}} + intent.validate(value) + for field in ["health", "seal_condition", "integrity", "retention_kpa", "materials", "principal_id", "endpoint", "gas_moles"]: + bad = deepcopy(value) + bad["payload"][field] = 1 + assert not intent.is_valid(bad), (operation, field) + +if "--schema-only" in sys.argv: + print("Chamber schema definitions and strict intents passed; emitted runtime fixture not checked") + sys.exit(0) + +fixture = json.loads((ROOT / "var/paid-chamber-fixture.json").read_text(encoding="utf-8")) +world = fixture["world"] +production = world["systems"]["production"] +pressure = world["systems"]["pressure"] +observation = fixture["observation"] +validator("world-snapshot:v1").validate(world) +validator("production-system:v15").validate(production) +validator("production-system:v15").validate(fixture["pristine"]) +validator("pressure-system:v5").validate(pressure) +validator("production-observation:v17").validate(observation) +assert production["catalog_revision"] == 4 +assert sum(fixture["pristine"]["deposits"].values()) == 54000000 +assert production["energy_joules"] == 28250 +assert production["store"]["plate"] == 600000 +assert production["store"]["iron"] == 400000 +assert production["store"]["tailings"] == 15600000 +for field, value in [("materials", {"plate": 6000001}), ("policy_id", "client-rating"), ("health", 1)]: + bad = deepcopy(production) + next(iter(bad["chamber_kits"].values()))[field] = value + assert not validator("production-system:v15").is_valid(bad), field +for field, value in [("outer_endpoint", "habitat_main"), ("policy_id", "client-rating"), ("retention_kpa", 999)]: + bad = deepcopy(pressure) + bad["paid_chamber"][field] = value + assert not validator("pressure-system:v5").is_valid(bad), field +for field in ["chamber_kit_installed", "reinforcement_intact"]: + bad = deepcopy(observation) + bad["paid_doors"]["doors"][0][field] = 1 + assert not validator("production-observation:v17").is_valid(bad), field +for legacy_version in [11, 12, 13, 14]: + bad = deepcopy(production) + bad["schema_version"] = legacy_version + assert not validator(f"production-system:v{legacy_version}").is_valid(bad) +print("Chamber genuine emitted finite budget, persistence, observation and negative schemas passed")