diff --git a/.github/workflows/production-recipe-observations.yml b/.github/workflows/production-recipe-observations.yml index a7ba37ed..95e7dda4 100644 --- a/.github/workflows/production-recipe-observations.yml +++ b/.github/workflows/production-recipe-observations.yml @@ -29,6 +29,11 @@ on: - 'src/presentation/habitat/procedural_habitat_preview.gd' - 'tests/godot/paid_chamber*' - 'tests/schema/paid_chamber*' + - 'tests/godot/chamber_recovery*' + - 'tests/schema/chamber_recovery*' + - 'docs/schema/chamber-recovery*' + - 'data/production/chamber-recovery*' + - 'data/inventory/chamber-recovery*' - 'data/production/manual-room-reinforcement*' - 'tests/godot/paid_room_reinforcement*' - 'tests/schema/room_reinforcement*' @@ -92,7 +97,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','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' + 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','chamber_recovery_work_test.gd','chamber_recovery_owner_fence_test.gd','chamber_recovery_transaction_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: | @@ -112,6 +117,8 @@ jobs: 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/chamber_recovery_schema_test.py + if ($LASTEXITCODE -ne 0) { throw 'Chamber recovery backend 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 @@ -146,6 +153,7 @@ jobs: var/room-equalization-fixture.json var/room-reinforcement-fixture.json var/paid-chamber-fixture.json + var/chamber-recovery-fixture.json var/horizontal-panel-controls-fixture.json var/horizontal-floor-support-fixture.json var/paid-floor-locomotion-fixture.json diff --git a/data/inventory/chamber-recovery-pump.v1.json b/data/inventory/chamber-recovery-pump.v1.json new file mode 100644 index 00000000..0150b158 --- /dev/null +++ b/data/inventory/chamber-recovery-pump.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "definition_id": "item.chamber-recovery-pump.fabricated", + "revision": 1, + "tags": ["item.handheld", "equipment.chamber-pump"], + "mass_milligrams": 0, + "volume_millilitres": 6000, + "storage": null +} diff --git a/data/production/chamber-recovery-pump.v1.json b/data/production/chamber-recovery-pump.v1.json new file mode 100644 index 00000000..0269b630 --- /dev/null +++ b/data/production/chamber-recovery-pump.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "blueprint_id": "blueprint.chamber-recovery-pump.v1", + "definition_id": "item.chamber-recovery-pump.fabricated", + "definition_revision": 1, + "materials_milligrams": {"plate": 400000, "iron": 200000}, + "assembly_joules": 1000, + "external_volume_millilitres": 6000 +} diff --git a/docs/development/deferred-two-litre-chamber-storage-design.md b/docs/development/deferred-two-litre-chamber-storage-design.md new file mode 100644 index 00000000..360cd793 --- /dev/null +++ b/docs/development/deferred-two-litre-chamber-storage-design.md @@ -0,0 +1,217 @@ +# Deferred two-litre chamber storage analysis + +Status: Coordinator declined implementation on 2026-10-23. Historical rationale +only: tiny capture and retained filled hardware do not justify this adapter. +Prototype shell reuse remains UNAPPROVED. None of the recommendations below +constitute current scope or authorization. See the replacement +[powered recovery proposal](finite-chamber-gas-storage-milestone.md). +Base: `db74161ab66b38365b9fd76b48de18480a98c036` (PR149). +Owner: chat `01a16ad6-6e04-7503-8e5c-f9377e9fb5cd`, branch +`finite-chamber-gas-storage`, managed worktree `finite-chamber-gas-storage`. +Coordinator: `01a1283c-45d6-7e61-bedd-905e8797f03d` owns design acceptance, +runtime leases and integration. No implementation or Godot execution authorized yet. + +## Source findings and dependency decision + +The scratch-construction roadmap's remaining atmosphere adapter row explicitly +requires constructable pressure vessels and owner adapters. PR149 supplies a paid +3.6 m3 chamber, finite habitat/exterior endpoints, interlocked manual doors and +bypasses. It does not supply a recoverable gas vessel. Storage is a valid next +foundation, but passive storage cannot provide lossless chamber evacuation. + +Reviewed at the pinned base: + +| Source | Consequence | +| --- | --- | +| `docs/development/scratch-construction-milestone.md`, remaining adapters | Build through trusted blueprints/owners; never clone fixture contents | +| `src/core/pressure/pressure_system.gd`, `_prepare_chamber`, `_step_paid_chamber`, snapshot decoder | Pressure already owns chamber gas, ordered flow and paired links; retain this owner | +| `src/core/pressure/pressure_flow_solver.gd` | Equal-temperature flow follows the gradient and stops at equilibrium; no compression or uphill return | +| `src/core/pressure/pressure_volume_state.gd` | Runtime bulk mol, m3, K and absolute kPa; no species or molecular mass | +| `data/inventory/canister.v2.json` | Real fabricated carrier has 2000 ml bulk space and 2500 ml external volume; no gas rating or valve | +| `data/production/timed-canister-blueprint.v1.json`, `timed_canister_construction.gd` | 1 kg iron shell, 1000 J fabrication, 120-tick reservation lifecycle; preserve existing blueprint/fingerprint | +| `data/inventory/shared.production.v1.json`, `src/app/first_playable_bootstrap.gd` | Existing physical prototype carriers can be reused after unloading; do not create another starter | +| `src/core/production/production_simulation_system.gd`, `_apply_door`, `validate_inventory_links`, checkpoint guards | Production owns material receipts, Inventory physical identity; prepared cross-owner validation already exists | +| `docs/design/breathing-mixtures-and-pressure-control.md` | Species-aware micromoles are detached groundwork, not a runtime replacement or permission to invent gas composition | +| `docs/development/paid-chamber-sequential-interlocks-milestone.md` | Fresh route uses 39 kg ore, 71750 J and leaves 0.6 kg plate / 0.4 kg iron | + +Recommended smallest slice: a **paid manual gas-port insert on one existing, +empty, owned 2-litre canister**, placed and latched beside the chamber. Reuse +Inventory/Production identity and Pressure bulk flow. This is a small physical +storage/transfer/recovery foundation, not a practical chamber recuperator. +Prototype and fabricated shells remain distinguishable with their original +provenance; only explicit fitting creates a gas-rated assembly. No old carrier +becomes gas-rated on load. Approval must explicitly accept using a pre-existing +prototype shell for the fresh route; a fully player-fabricated extra shell does +not fit the current simultaneous chamber budget. + +At 101.325 kPa and 293.15 K the chamber has about 149.656 mol. A vacuum +0.002 m3 receiver equilibrates at 101.26874 kPa, receiving about 0.08310 mol: +only 0.05552% of the chamber gas. After venting the chamber it can return gas +downhill, but cannot restore the original pressure. Do not advertise gas savings +large enough to unlock door opening, evacuation, or lossless repeated cycles. +Once at equilibrium it cannot accept further gas without an actual lower-pressure +destination. Existing exterior gas remains in the finite exterior owner. + +Alternatives for Coordinator decision: + +1. Accept the small adapter as groundwork with the limited outcome above. +2. If useful bulk recovery is the intended next outcome, reject this scope and + first design a larger paid receiver and its geometry/material bill. A vacuum + receiver equal to chamber volume captures only 50% on first equilibrium; + 90% needs nine chamber volumes (32.4 m3), not a canister capacity rewrite. +3. Near-vacuum recovery into compact storage requires powered compression, + energy/work accounting and rated components. That is a later separate design, + outside this task. Species/tank/suit integration is likewise not prerequisite + for the existing bulk-mole chamber and must not be smuggled into this slice. + +## Proposed physical assembly and finite budget + +One insert costs exactly 200000 mg processed plate, manual fitting/removal at +zero electrical cost with the existing usable held crowbar. This is explicit +prototype tuning, not pressure-vessel engineering certification. No new ore, +energy, gas, tool, shell or repair grant. Existing sources/catalog bytes unchanged. +Fitting requires an owned empty prototype or fabricated carrier, no nested +payload, no machinery socket and an unreserved shell; it consumes its bulk space +exclusively. Bulk load/unload, nesting, extraction and machinery insertion reject +while fitted. Never count the same 2000 ml as both bulk and gas capacity. + +Fresh PR149 route can unload its existing carrier and spend 0.2 of its remaining +0.6 kg plate. Total energy remains 71750 J / 28250 J remaining. Final incremental +matter accounting: installed chamber 20.4 kg + insert 0.2 kg + crowbar 2 kg + +plate 0.4 kg + iron 0.4 kg + tails 15.6 kg = 39 kg acquired ore. Existing starter +shell mass stays in its existing Inventory origin ledger; it is not added to the +ore budget. Fabricated shells retain their existing 1 kg iron structure receipt. +An additional fabricated shell needs 1 kg iron and 1000 J, but only 0.4 kg iron +is left and all 39 kg ore is exhausted. Do not silently add ore or reduce old bills. + +Pressure volume is 0.002 m3, initially zero mol and 293.15 K, rated to 110 kPa +absolute under this fixed prototype policy. Vacuum initialization adds empty +capacity, not gas; explicitly account installed vessel volume separately from the +unchanged 1000 m3 habitat/chamber partition. Removal of an empty vessel retires +exactly that empty capacity. Gas transfers conserve all-volume moles. + +Propose a bounded fixed socket on the chamber's inner side with an actual 3D +canister, supported position, collision and reachable handle. Final socket +coordinates must be demonstrated against actual door sweeps and the 600 mm body +route before implementation acceptance, rather than assuming a UI anchor proves +placement. Existing panel/door support must prevent removal while mounted. +No arbitrary pipes, cables, free endpoints, general topology or spatial atmosphere. + +## Commands, ownership and pressure behavior + +Production owns the paid insert receipt, shell/insert association, socket placement +and reservations. Inventory owns the shell and body/control session; Pressure +owns volume, gas, rated port state and flow. Access remains owner of both chamber +actuators and force checks. UI derives actions and never writes pressure. + +Proposed explicit operations: fit/remove insert, place/recover assembly, connect/ +disconnect chamber port, set port open/closed, and deliberately discharge through +the mounted service outlet to the finite exterior. Only one connection/direction +mode exists at a time; outlet and chamber connection are mutually exclusive. +Closing remains available when opening prerequisites fail, subject to valid owner +identity. No automatic sequencing, target pressure assignment or refill command. + +Every operation uses authenticated principal/session/body/world/region, explicit +shell/chamber IDs, expected Production/Inventory/Pressure revisions and restore +epoch, live reach and appropriate hand capability. Fitting/operating requires +held usable crowbar and the existing construction capability; carrying requires +the existing empty/available hand rules. Recheck at enqueue and apply; receipts +bind exact intent. Changed-intent replay, foreign IDs, stale epochs and disconnected +observations reject. Bound receipt/assembly counts and overflow checks. + +Opening the chamber connection requires both doors, both bypasses and service +outlet closed with intact chamber boundaries. While open, reject either door, +bypass, decommission, kit recovery and geometry removal. No weakening of 1000 N, +1.71 m2 force admission or original 0.5 kPa legacy room policy. Closed storage +does not change the chamber's existing leakage. Safe closure cannot depend on +opposite hardware being intact. No manual forcing of an unsafe door. + +At existing fixed Pressure cadence process prototype flows, chamber flows, then +the single storage connection before sensors; persist deterministic ordering. +Use existing 1 mol/(s*kPa) conductance, equal-temperature equilibrium cap and +0.001 kPa epsilon. Reject nonfinite/negative states, mismatched temperatures, +unknown endpoints, self-links and incompatible policy/rating. No uphill flow, +species conversion, compressor work or hidden electrical debit. + +New insert has intact/failed retained state, no invented shell-health simulation. +Above its absolute rating, mark failed and leak through the mounted service outlet +to the finite exterior using the same bounded passive solver, even with its valve +closed. Preserve insert/shell matter and remaining gas; closing is not repair. +Disconnecting is isolated by closed port; filled or failed assemblies stay latched +until explicitly discharged to zero solver residue. Define residue conservatively: +no epsilon deletion; any positive remainder blocks destructive salvage. A final +explicit discharge transaction may move the exact remainder to the finite exterior +without pressure reset, and must reject if that would require uphill transfer. +If exterior pressure prevents emptying, retain the filled hardware and report why. + +Loaded portable gas mass cannot be derived from bulk moles without a composition +contract. Therefore prohibit carrying, nesting, salvage or arbitrary relocation +with any positive gas in this slice. This deliberate restriction avoids zero-mass +gas cargo. An empty isolated assembly can be recovered with its original shell ID, +unchanged insert condition and material receipts. Removing an empty insert refunds +its original plate once, subject to capacity/reservations; refitting never heals +failure. A failed retained insert can only be salvaged, not restored as intact. + +## Transactions, save and legacy behavior + +Extend prepared-owner operations; no second gas owner. Build detached Production, +Inventory, Pressure and relevant Access candidates, check common owner/body, +identity, shell provenance, empty bulk child, exact paid bill, unique volume/sensor, +socket support, chamber association, rating/temperature, capacity and every active +construction claim. Recheck compared revisions before a nonfallible commit. +Allocation/preparation/CAS failure leaves all owners and receipts unchanged. + +Whole-world restore must validate the normalized complete candidates before +assignment, including the existing chamber pair and new mounted shell. Reject +orphan/duplicate gas volumes, inserted bulk payload, missing paid receipt, mismatched +condition/valves, false endpoint/rating/bill and unsupported versions. Failure +leaves all owners, tick, epochs, receipts and revisions equal to before restore. +Do not temporarily install one owner to validate another. Preserve exactly the +retained gas and open/closed/failed states through reload. + +Expected future version boundaries: Production save16 / observation18 and Pressure +save6, insert/assembly record1, strict emitted schemas and decoder negatives. +Inventory save/definition and UI wire versions must follow the actual chosen +adapter representation; pin them in the accepted implementation amendment before +code changes. Preserve old item/blueprint definitions and fingerprints. Legacy +missing storage records mean absent assemblies, no gas, insert, rating, shell, +source, healing or refund. Never reinterpret prototype mixture fixtures as supply. +No environment variables, new dependencies or remote services proposed. + +## Real controls, acceptance and bounded verification + +Expose actual canister selection, empty/occupied bulk status, fit cost, physical +socket placement and recover/remove actions through hosted commands. Show shell ID, +actual mol / absolute kPa / 2 L capacity / condition / 110 kPa limit, chamber and +exterior pressures, closed/open/connection state, pending receipt and exact blocker. +State passive-equilibrium limits and retained gas clearly. Disable stale/rebound/ +disconnected actions until fresh owner observation. Use normal physical inventory +selection and derived preview/collision, not a test-only pressure setter. + +Acceptance after design approval: + +- Fresh unseeded finite PR149 acquisition/build plus real existing carrier emptying, + 0.2 kg insert payment, real socket placement and unobstructed body/door traversal. +- Real UI fit/connect/open/close, chamber-to-empty-store transfer, finite chamber + vent, store-to-lower-pressure-chamber return, retained equilibrium and repeated + diminishing cycles. Compare every volume's summed moles before/after each step. +- Loaded recovery rejection; explicit finite-outlet discharge or truthful retained + blocker; empty physical recovery/replacement and exact-once plate refund. +- Reservation, capacity, power/tool/reach/hand, foreign owner/session/body, stale + revisions/epochs, replay/changed intent, failure injection before each commit, + absolute overrating and closed-valve leak, no healing, unsupported temperature, + both flow signs, port/door/bypass interlocks and safe-close negative controls. +- Full-world save/reload open/closed/failed/retained states; malformed multi-owner + restore equality; old worlds, exhausted sources and old canisters receive nothing. +- Existing chamber, room, reinforcement, force, timed-canister, bulk capacity, + Inventory, observations and emitted-schema/version negatives remain covered. + +This commit is design text only: arithmetic/source/diff review applies. Runtime +acceptance remains unverified. After accepted design and implementation, freeze +and push a head, request Coordinator's explicit exclusive exact-head NO-PORT lease +with named stages/time budget. Use AST-extracted versioned Invoke-GodotCheck, +empty preflight inventory, stop on first diagnostic, no edit/retry under lease, +retain logs and report postflight inventory/release. Final Windows/Linux exact-head +CI and schema negatives are required; distinguish PR head from tested merge SHA. +Capture only if material physical/UI uncertainty remains. No successors, media, +publication, deployment, outreach, spending or merge before Coordinator acceptance. diff --git a/docs/development/finite-chamber-gas-storage-milestone.md b/docs/development/finite-chamber-gas-storage-milestone.md new file mode 100644 index 00000000..6d6ee72a --- /dev/null +++ b/docs/development/finite-chamber-gas-storage-milestone.md @@ -0,0 +1,527 @@ +# Bounded powered chamber gas recovery: revised design + +Status: revised outcome/budget/power binding accepted by Coordinator at +`05e79b6a0c53b6f6b97fdec815c3da04def50141`; implementation amendment +`c198e59b8baddbf65a0ce6c7e42e93d480789810` accepted for Phase 1 backend +transaction prerequisites and isolated failure controls only. Minimum persisted +assembly/job/accounting records and real detached Inventory candidates were +explicitly confirmed in scope. Device commands, scheduling, UI and physical +presentation await frozen Phase 1 source review. No local Godot execution +authorized. Revised on 2026-10-23 after the +Coordinator declined the [two-litre receiver](deferred-two-litre-chamber-storage-design.md). +Prototype shell reuse remains UNAPPROVED and is unnecessary here. +Base: `db74161ab66b38365b9fd76b48de18480a98c036` (PR149). +Owner chat: `01a16ad6-6e04-7503-8e5c-f9377e9fb5cd`; Coordinator: +`01a1283c-45d6-7e61-bedd-905e8797f03d`. Retain draft PR150 and descriptive +branch/worktree `finite-chamber-gas-storage`; no successor or branch rename. + +## Observable outcome and source decision + +Fit one paid fixed pump/interface to the existing chamber, close both doors and +bypasses, then deliberately recover a bounded amount of chamber gas directly +into the finite existing habitat using real controls and finite electricity. +The fresh finite acquisition route should recover 20% of the initial chamber +gas before stopping, retaining every remaining mole in the chamber. The player +then uses the existing manual bypass/force-safe door sequence. This reduces gas +lost on one outward cycle; it does not promise vacuum evacuation, lossless +cycles, breathing, portable gas, powered door movement or free subsequent energy. + +The roadmap's atmosphere-owner adapter dependency remains appropriate, but a +receiver is not needed for this outcome. Pressure remains sole owner of all gas. +No new pressure volumes, shell conversion, species inference, inventory gas mass, +general pump/network framework, pipes, suits, ships, thermal simulation or release. + +Reviewed at the base: + +| Source | Finding | +| --- | --- | +| `src/core/production/production_simulation_system.gd`, `initial_record`, `_decode`, `apply_local` | Production owns the existing integer 100000 J bank, powered station flags, material stock, receipts and revisions. Save validates energy in [0,100000]. Existing spending is finite; do not introduce recharge | +| Same file, `simulate_tick`, `_simulate_machinery` | Machinery buffers live separately inside Production. Solar generation is explicit; it does not refill the workbench bank | +| `src/core/machinery/machinery_workshop.gd`, `draw` | 20000 J per machine buffer, 1000 W generation and shared cable budgets. `draw` mutates workshop candidates and has no pump endpoint/Pressure transaction; do not borrow this path or count solar energy twice | +| `src/core/access/prepared_door_change.gd`, `commit`; Production `_apply_door` | Existing Access/Pressure prevalidation/CAS then non-yielding writes followed by Production assignment provides a transaction pattern, not an existing pump-energy API | +| `src/core/pressure/pressure_system.gd`, `install_prepared_room_state`, `_step_paid_chamber` | Pressure owns finite bulk-mole volumes, ordered passive flows, sensors, chamber links and decoded candidate installation | +| `src/core/simulation/simulation_runner.gd`, `_system_order`; `SimulationSystem`; Production `_init` | Pressure defaults to priority0; Production is priority300, after pressure/sensors. Preserve that order rather than relying on child insertion order | +| `src/core/pressure/pressure_flow_solver.gd` | Passive solver cannot transfer uphill; pump needs a separate bounded work-paid planner, never a negative conductance or free use of `_step_transfer` | +| `src/core/runtime/world_runtime.gd`, `restore_world_snapshot` | Detached mass/link validation surrounds whole-runner restore; extend common candidate validation before any owner writes | +| `docs/development/paid-chamber-sequential-interlocks-milestone.md` | Actual fresh route spends 71750 J from 100000 J and leaves 0.6 kg plate / 0.4 kg iron after 39 kg ore | + +**Prerequisite:** there is no existing atomic gas-transfer/energy-debit API. +Accept a narrow prepared Production/Pressure transaction before pump behavior, +or stop at this prerequisite. Do not debit energy after Pressure mutation, call +live `MachineryWorkshop.draw`, compensate via restore, or claim existing APIs +already make the operation atomic. The accepted Phase 1 amendment below defines +this prerequisite; later device expansion still requires Coordinator review. + +## Work model, bounds and finite acquisition + +Use the current equal-temperature ideal-gas model: bulk mol, m3, K, absolute kPa, +joules, watts and simulation seconds. No heat/species changes. Reversible +isothermal compression requires `dn * R * T * ln(Pout/Pin)` for fixed endpoint +pressures. The [USPAS Cryogenic Engineering lecture, slides 16-17](https://uspas.fnal.gov/materials/21onlineSBU/Cryo/5%20-%20Thermodynamics%20for%20Cryogenics.pdf) +derives this minimum-work relation and explains isothermal efficiency. Our +finite-volume endpoint bound and tuning below are independent project proposals, +not performance data for a real pump. + +For a proposed batch q from chamber C into habitat H, derive candidate endpoint +pressures from `nC-q` and `nH+q`. Reject q <= 0, q >= nC, invalid/nonfinite +derived states, unequal temperatures or unsupported endpoints. Charge: + +``` +ratio_end = max(1, PH_after / PC_after) +work_bound_J = q * R * T * ln(ratio_end) +electrical_J = ceil(work_bound_J / 0.35) + 1 +``` + +The ratio rises throughout this fixed-volume transfer, so the endpoint ratio +upper-bounds reversible work across the entire batch, rather than undercharging +using its initial pressure. Proposed 35% isothermal efficiency is conservative +prototype tuning, not certification. The extra 1 J per successful batch covers +bounded drive overhead even at equal/downhill pressures. Downhill pumping earns +no energy credit; stopped/no-flow states consume no power. Isothermal heat +rejection to an unmodeled environment is an explicit approximation, not stored +thermal energy, electricity regeneration or gas heating. + +One batch of at most 0.1 mol per 60 simulation ticks at 60 Hz (0.1 mol/s); +maximum 250 J per batch / 250 W average over its one-second interval. No burst +catch-up after pause/restore. Reject a batch whose conservative price exceeds +250 J, whose destination exceeds 110 kPa absolute, whose pressure ratio exceeds +16, or whose source would fall below 1 kPa absolute. Do not truncate the price, +silently shrink a batch to match electricity or borrow future energy. The final +batch may be smaller solely to hit the user-selected fixed 20% recovery budget. +No epsilon-emptying, clamping of moles, target-pressure write or residual deletion. + +Proposed assembly bill: 400000 mg processed plate housing/interface plus +200000 mg refined iron drive; manual assembly with usable held crowbar costs +1000 J from the existing Production bank. Prototype material/device tuning, +without adding materials, ore, catalog versions, stock, motor items or starter +equipment. Actual pump provenance records both materials and energy spent; +recover its intact physical assembly rather than cloning a factory fixture. +Manual recover/refit costs zero additional energy but preserves condition and +does not refund assembly energy. Destructive salvage returns original materials +once, never electricity; rebuilding then costs 1000 J again. + +Finite matter after construction: chamber 20.4 kg + crowbar 2 kg + pump 0.6 kg ++ plate 0.2 kg + iron 0.2 kg + tails 15.6 kg = 39 kg acquired ore. Existing +unacquired ice/regolith and starter ledgers remain unchanged. Pump uses no shell. +Assembly leaves 27250 J for all subsequent work, not a dedicated free reserve. + +Independent source-model calculation at initial 101.325 kPa / 293.15 K, +3.6 m3 chamber and 996.4 m3 habitat, with the unrelated prototype valves closed: + +| Metric | Proposed 20% recovery | +| --- | ---: | +| Starting chamber gas | 149.65630889 mol | +| Gas returned to habitat | 29.93126178 mol | +| Batches / ideal duration | 300 / 300 seconds | +| Endpoint-bound electricity including ceil and 1 J per batch | 22993 J | +| Largest batch charge | 157 J, below 250 J limit | +| Final chamber / habitat absolute pressure | 81.060 / 101.39821758 kPa | +| Total route + pump assembly + recovery | 95743 J | +| Remaining bank | 4257 J | + +The integrated reversible minimum for the same finite endpoints is about +7863.4934 J. At 35% efficiency this already requires about 22467 J; the 22993 J +charged batch sum is conservatively higher, including endpoint bounds/rounding. +PowerShell and independent JavaScript arithmetic agree on all table totals. + +This is arithmetic evidence, not runtime or physical acceptance. Other work or +reservoir changes can prevent the target; admission/step checks use actual state. +Remaining 80% is explicitly retained until the player opens the existing outer +bypass, conserving gas into exterior. The pump cannot make the outer door safe +by itself. Repeated recovery can exhaust the finite bank and must stop honestly. +90% recovery would cost about 702335 J even without respecting the 250 J batch +cap; it is not achievable from this bank. No hidden source or easier-work formula. +Solar-powered deeper recovery needs its own paid placement/wiring/atomic-power +design; it is not silently included here. + +## Placement, commands and interlocks + +One fixed assembly bound to exact paid-chamber ID, `habitat_main`, owner/body, +world/region and existing paid geometry. Use a bounded nonblocking mount at the +inner chamber interface with actual 3D pump silhouette, support/contact and +reachable handle. Pin final coordinates against the actual sweeps and 600 mm +body path in the implementation amendment; actual physical tests must verify +the chosen mount rather than treating a UI anchor as placement acceptance. +Power connection is an explicit fixed logical interface to the existing +Production workbench bank, using `powered.construction`; no spatial electrical +grid, cable, solar-feed or realistic wiring claim. Coordinator must accept this +bounded fixture power binding or require a paid grid prerequisite instead. + +Proposed host commands: build/place assembly, start recovery, stop, recover, +refit, salvage. Start sets an immutable job target of exactly 20% of observed +current chamber gas and a maximum permitted job electricity budget, never a +target pressure. It captures current owner/body/control epoch and fixed links. +Only one job/assembly; no direct user q, efficiency, rating or endpoint values. +Start and state-changing commands require authenticated session, principal, +body, revisions/restore epoch, current reach, construction capability and actual +held usable crowbar. Per-step owner/session/reach/tool checks mirror existing +bound finite jobs; losing any pauses before gas/energy writes. Resume is explicit +under fresh authorization, no saved session credential or automatic reconnect. +Receipts bind exact intent; changed-intent replays reject, same request is once. + +Both doors/bypasses closed and intact before start and each transfer. Running +job blocks both door/bypass openings, chamber decommission and supporting-part/ +kit recovery. Stopping always isolates the pump's normally closed interface; +safe closure must remain possible with damaged opposite hardware. No reverse +pump path, no automatic vent and no free bypass flow through stopped hardware. +Existing Access force checks, 1000 N / 1.71 m2, original 0.5 kPa policy, chamber +retention/leak behavior and collision/occupancy guards remain authoritative. +Supported chamber damage pauses recovery and existing leaks remain active; +the pump does not seal a failed boundary. No new damage/repair simulation. + +Physical recovery requires stopped job and isolated interface, valid paid mount, +supported inventory destination/capacity and no occupant/sweep conflict. Preserve +assembly ID, condition and exact material receipt; no gas resides in the pump, +and no volume disappears when removed. Failed device state, if introduced by +existing supported integrity semantics, must disable flow and remain retained; +refitting/reloading cannot heal it. Do not invent an unsupported wear tick. + +## Prepared transaction and save prerequisite + +Add only a narrow server-owned prepared operation: Pressure plans new C/H moles, +retaining all other volumes; Production plans energy debit, job progress and +bounded counters/receipt. Stage and validate detached records against current +Access/Inventory and active reservation claims. Compare complete required owner +revisions, current energy and source/destination state immediately before commit. +After the last possible failure, assign prepared owner records synchronously +without callback, yield, signal, live restore, decoder or fallible allocation. +No debit without gas and no gas without debit; failure/no-power/capacity/rating/ +limit/stale-CAS leaves all canonical state and receipts unchanged. + +Admission/start/stop may explicitly change job state. A scheduled stalled batch +does not alter progress, energy, gas, target, revision or receipt; derive the +stall reason in observation. Normal owner tick and independently scheduled flows +can still advance; no-write assertions compare the affected transaction state +at its exact step boundary, not the whole world across time. No per-stall log spam. +Persist last committed batch tick and refuse duplicate or backwards ticks; +Proposed scheduling preserves existing priorities: Pressure0 executes passive +flows and samples sensors, then Production300 advances its tick and existing +canister/machinery work, then attempts one due pump transaction before existing +extraction/recipe early returns. Competing earlier spends win; pump rechecks the +remaining bank. There is no second pump call from Pressure. The existing pressure +sensors sample the result on the next tick (one-tick latency); pump admission and +current UI pressures derive from actual canonical volumes, never stale sensors. +Pin this ordering in the accepted transaction amendment and test restore parity. +Do not execute from UI or service callbacks. + +Prepared-operation failure injection and all-owner equality tests are a required +prerequisite to starting powered behavior. Existing `_apply_door` is the pattern; +its Access-specific assumptions must not be disguised as an energy transaction. +If a nonfallible commit boundary cannot be demonstrated, stop and report that +prerequisite instead of compensating writes. + +The frozen amendment below pins Production16 / observation18 / paid-door7 and +Pressure6, including reciprocal audit records; pump/job and definition/bill are1. +Preserve all old definitions/catalogs/fingerprints and force semantics. Decode +complete Production/Pressure/Access/Inventory candidates first; validate paid +bill, geometry/mount, ownership, target/accumulated mol, energy spent, tick/cadence, +links, valves and supported conditions before any install. Reject orphan pumps, +false bills, wrong endpoints, duplicate jobs, inconsistent target/receipts, energy +overflow and malformed half-restores with all-owner/tick/epoch equality. + +Legacy missing pump means absent pump/job, unchanged energy/gas and no materials, +healing, source, motor or port grant. Save/reload preserves actual gas and energy +without reapplying a batch. Restored jobs are paused pending fresh authorization; +document this intentional session-safety migration rather than silently resuming. +Recovery/salvage respects all active material claims, receiving capacities and +exact-once receipts. No environment variables, dependencies or services. + +## Frozen implementation amendment for acceptance + +### Exact mount, collision and physical provenance + +All coordinates below are canonical millimetres, before the existing display +offset. Pump solid center `[5600,150,-4400]`, size `[200,300,100]`; AABB +x[5500,5700], y[0,300], z[-4450,-4350]. Its footprint must be completely covered +by the paid floor at y0 using the existing exact union support query, and remain +within the accepted chamber interior x[5100,6900], z[-4500,-3500]. No wall health +or wall mount is implied: this is a floor-supported compact prototype interface. +Handle `[5600,900,-4300]` is a noncolliding interaction point, tested against +canonical body center using existing 1500 mm reach. The entire pump solid is +derived from the owned mounted assembly record, not an independent scene owner. + +At quarter-turn1 the existing inner door sweep occupies x[4100,5100], +z[-4500,-3500]; outer occupies x[6000,7000], same z band. Pump has 400 mm +separation from inner sweep and 300 mm from outer sweep. The unchanged central +600 mm body route at z=-4000 occupies z[-4300,-3700], leaving 50 mm to pump's +frontmost face z=-4350; rear wall interior face at -4500 leaves 50 mm behind it. +These are AABB calculations, not contact evidence. Retain PR149's continuous +spawn/inner-leaf detour, both apertures and outer-return route; test actual +CharacterBody3D/PhysicsServer contact with installed pump and all swept/open/closed +door shapes, floor seams and body heights. Place/recover fails if a body, world +container or existing machine overlaps the solid. Supporting-floor removal is +guarded while installed. No collision disabling, body teleport or narrowed body. + +New immutable definition `item.chamber-recovery-pump.fabricated` revision1, +schema item-definition1, tags `item.handheld` and `equipment.chamber-pump`, +6000 ml external volume, base mass0 and storage null. Production's dedicated +`chamber_recovery.assemblies` material record supplies exactly 600000 mg through +both `payload_mass` and `payload_masses`; it is never also a generic structure, +machinery part or cargo payload. Exact record: assembly ID/item ID, definition +revision, bill/fingerprint, owner/body/world/region, 1000 J assembly-spend receipt, +original Inventory condition and mounted-or-carried state. No fabricated equipment +tag that accidentally routes through unrelated blueprint validation. + +Build prepares the new physical item in the authenticated body's inventory using +the existing factory-candidate pattern with registered definition and capacity; +then place relocates that same ID to a dedicated bounded root container +`world.chamber-pump-` at floor anchor `[5600,0,-4400]`, capacity one +item/600000 mg/6000 ml. Root is observable but general Inventory transfer/closure/ +equip/socket operations must reject mounted pump movement; only the paired pump +recover command can remove it and retire the root. Mounted record and actual +Inventory item/root must match exactly. Recover prepares original item into the +body subject to capacity/available-hand/ownership checks; no condition reset or +material refund. Salvage only an owned carried inactive item; remove exact ID and +refund original bill once, preserving lifetime audit. Record existing condition +verbatim; only condition1000 can start, resume or perform a running batch, with +other conditions retained without healing for salvage. +No new damage model, wear/repair operation or healing path. + +### Versions, lifetime limits and reciprocal accounting + +Pin Production save16, observation18, paid-door observation7 (pump interlocks), +Pressure save6, pump assembly/job/accounting records1 and pump blueprint1. +Pressure6 extends the chamber5 shape with `pump_accounting`; it preserves every +existing gas volume/valve field. Inventory system5, shared owner2 / ledger2, +item-definition1 and Production intent1 envelope remain their existing shape; +strict operation allowlists gain pump operations. Add new emitted schemas for +Production16/observation18/paid-door7/Pressure6/pump records and update every +current consumer's accepted/unsupported-version controls. Do not accept unknown +future versions or alter old immutable item/blueprint/catalog bytes. + +Legacy Production<=15/Pressure<=5 normalize to empty pump history/accounting, +without changing old gas, energy, origins, receipts or definitions. Both absent +is valid; a current pump on only one side is invalid. Existing chamber records +remain unchanged in their nested version. New item registration never spawns an +item or grants a rating. No missing-owner migration creates a pump. + +One current assembly maximum, at most32 lifetime assembly records and128 lifetime +jobs; retain completed/cancelled/salvaged summaries, reject further creation at +capacity instead of trimming provenance or inventing a counter reset. Jobs have +monotonic server ID/sequence, initial mol, target=0.2*initial mol, returned mol, +spent J, immutable maxJ=`min(25000, actual_bank_J_at_start)`, owner/control binding, +state and last successful batch tick. Client supplies no target/maxJ/efficiency. +maxJ must be positive; each batch checks spent+price<=maxJ and current bank>=price. +Stop means pause; resume retains the same target/maxJ/spend. Cancel permanently +closes that job without refund; a fresh authorized job may target20% of then-current +gas, but all lifetime counters and actual bank debit persist. Place/recover/refit/ +salvage/build never reset job history or recovered-mol/paid-J totals. No budget +refresh on restart, receipt eviction or reload. Bounds/overflow reject before write. + +Production owns authoritative bank and material/job accounting. Pressure6 stores +a reciprocal audit mirror, not another energy/gas owner: monotonic commit sequence, +assembly/job ID, last batch tick, exact last q/chargedJ, cumulative returned mol +and paid J, plus retained per-job summaries (same128 limit). Both candidates receive +the identical calculated values, never recomputed/rounded independently. Reciprocal +sequence/totals/job summaries must match exactly on every prepare/restore; check +sum(job paidJ)=lifetime paidJ, sum(job returned mol)=lifetime returned mol using +the same deterministic accumulation order, valid target/spend bounds, and bank ++ lifetime pump assembly/batch spending<=100000 J as a necessary finite-origin +constraint. Other existing station spending remains in the existing bank/receipts; +the mirror is not permission to infer or refill missing energy. + +### Prepared API, CAS and commit boundaries + +New server-only `PreparedChamberRecoveryChange` is short-lived, single-use and +never persisted/sent. Proposed `Production.prepare_chamber_recovery_step(tick)` +returns a prepared success or rejection; it asks +`Pressure.prepare_chamber_recovery_transfer(assembly_id, job_id, q, tick)` for +detached decoded pressure-after plus conservative cost. No public caller chooses +cost/efficiency or writes gas. `prepared.commit(production, pressure, inventory, +access)` verifies exact owner object identities, volatile restore fences, expected +Production state_revision/last_tick and complete before snapshot (including +bank/job/material claims), exact Pressure before record (all volumes, valve, +sensor, accounting state), Access pair before records, and Inventory owner/ledger +revisions, body/control/action epochs, current grip/tool/pose/condition and +controller restore generation. Compare exact canonical records where no revision +exists; a prior matching ID alone is insufficient. A mismatch consumes/rejects +the prepared object. Validate overflow, candidate schemas and all cross-links +before the first assignment. + +All allocations/decoding/projection/hashes and optional test-failure injection +occur before publication. Final boundary directly swaps prepared Pressure state +and Production state with no callback, signal, decoder, allocation-dependent +helper, yielding or restore. Optional Inventory swaps for build/place/recover +must be completely predecoded and fenced before this same boundary; pure gas +steps do not change Inventory or Access. Existing Inventory factory preparation +and `PreparedDoorChange` demonstrate detached preparation; neither live factory +commit followed by gas mutation nor live `MachineryWorkshop.draw` is acceptable. +Build/place/recover/salvage use the same rejection-before-write discipline. + +First implementation phase is this prerequisite plus focused failure-injection +tests, before pump UI/preview expansion. Inject before/after candidate preparation, +at each owner validation and immediately before publication; change bank, gas, +claims, door/valve, Inventory/grip/body/epoch, restore fence and single-use status +between prepare/commit. Assert equality of every affected owner and reciprocal +receipt/counter, including successful same-step second-call rejection. No rollback +or live compensation. Prove legitimate success charges the computed work once. + +### Scheduler and paused-reload cadence + +Keep Pressure priority0 (passive flows then sensors) and Production priority300. +Production advances last_tick, simulates existing timed canister/machinery work, +then calls pump scheduling exactly once before extraction/recipe early returns. +Successful previous spends have precedence; pump plans against the current bank. +Admission at external-command tick start sets first eligibility to tick+60. +Successful batch sets next eligibility to tick+60. Ineligible ticks have no pump +mutation; stalled eligible ticks derive a reason with unchanged job/gas/bank/ +receipts and can try one batch on a later tick, never catch up multiple batches. +Explicit stop or lost session/reach/tool parks the job until authorized resume; +resume sets fresh eligibility current_tick+60 while retaining target/maxJ/spend. +No saved session secret. Whole-world reload installs a paused job with retained +last successful tick; normalize that pause in both candidates, no debit/transfer, +and require fresh resume/60-tick delay. Reload/stop cannot accelerate the rate. +Running pump interlocks are enforced at external commands and step admission. +Actual volumes drive admission/UI; sensors deliberately catch up next tick. + +### Boundary checks before device expansion + +Use R=8.314462618 J/(mol*K), actual fixed T and volumes; mol as existing finite +float, J as exact bounded integer. Compute after-state first, use dimensionless +PHafter/PCafter and natural logarithm, ceil only after dividing by0.35, then add1. +Check no rounded-down free charge, positive q, q250 J batch, exhausted target, + source floor, ratio16, destination110 kPa, unequal T, nonfinite values, wrong + owner/session/body/reach/tool/hand, stale revisions/epochs, replay/changed intent, + concurrent material reservations and safe-close cases with rejected-step equality. +- Forced failure at each preparation/CAS boundary proves no one-sided debit/gas + transfer. Unrelated passive flows and scheduling have deterministic ordering. +- Save/reload before/after a charged batch, stalled/restored paused jobs, corrupted + link/bill/energy/tick/target candidates and old-world no-grant preservation. +- Existing chamber/pressure stability, force/bypass, reinforcement, finite bootstrap, + machinery energy, timed construction, Inventory/UI and strict emitted schemas. + +Current verification is source review and local JSON syntax/diff checks only. +Godot tests and emitted runtime schema validation are authored, not yet run. +Freeze/push the complete Phase 1 prerequisite for Coordinator source review; +request an explicit +exclusive exact-head NO-PORT lease with named stages/budget, AST-extracted versioned +Invoke-GodotCheck, empty preflight inventory, first-diagnostic stop, no edit/retry, +retained logs and postflight release. Final Windows/Linux exact-head CI and emitted +schema negatives required; distinguish source head and tested merge revision. +Capture only if actual UI/physical uncertainty remains. No successor, media, +publication, spending, deployment or outreach. + +## Phase 1 implementation boundary and review packet + +Production exposes server-only preparation methods for one paid assembly, mount, +job start/resume and batch. Nothing registers these as player commands or calls +them from a simulation tick. The existing Pressure0/Production300 order and +passive gas flows remain unchanged. Explicit stop/recover/refit/salvage, automatic +authority-loss parking, physical pump collision, controls and observations are +later device work. Admission and budget stalls in this prerequisite reject with +unchanged canonical records. Reload alone parks a running job, increments the +Production revision with checked capacity and clears ephemeral authority; resume +retains target, cap and spend and starts a fresh 60-tick wait. + +`ChamberRecoveryWork` produces detached source/receiver states and integer work. +`ChamberRecoveryPreparation` checks actual authority, crowbar, paid chamber, +closed intact doors/bypasses, body reach, finite bank, unclaimed stock, mounted +support and the real Inventory candidate. `ChamberRecoveryRecords` validates the +bounded paid assembly history, job history, material/energy origin bounds, +physical definition/root/body links and exact reciprocal audit. The new data +files must equal their immutable pinned definition and bill at configuration. + +`PreparedChamberRecoveryChange` owns decoded detached candidates. Before writing, +it reruns admission and consumes a single-use four-owner fence: exact object +identity, complete canonical snapshots, Access tick and volatile successful- +restore generations. Identical successful restores retire old preparations; +failed restores do not advance the counters; exhaustion rejects in prevalidation +and restore. Publication only installs the already-decoded Pressure state, +optional Inventory owner/world-storage graph, Production state and ephemeral +context map. No restore, compensating action, signal or fallible validation runs +after the first write. Rejection consumes only the ephemeral prepared object; +it cannot add an ordinary intent receipt. + +Save16 and Pressure6 are emitted only after the deliberate backend assembly +transaction. New nested records are v1 and the world snapshot schema accepts the +new versions. Old absence remains absence: no item, assembly, job, gas or joules +are granted on migration. Existing immutable catalogs and legacy schema files +are unchanged. Observation17 and paid-door observation6 remain the public shapes; +the backend field is removed from that projection. Observation18/paid-door7 are +reserved for later approved device work, not emitted by this prerequisite. + +Authored checks are `chamber_recovery_work_test.gd` (work bounds, rounding and the +300-batch arithmetic model), `chamber_recovery_owner_fence_test.gd` (real-owner +identity/ABA/exhaustion/single-use controls), and +`chamber_recovery_transaction_test.gd` (inherits the authentic 39kg finite paid +route; assembly/mount/job/one-batch commit; changed-owner rejection, budget stalls, +reload delay and forged/one-sided restore controls). Direct owner substitutions +occur only in isolated negative controls, not in successful route provenance. +The transaction test emits `var/chamber-recovery-fixture.json` for the strict +schema check. The existing Windows/Linux production-contract workflow runs all +three scripts and validates this fixture. No capture or full device/physical +acceptance is claimed by these backend tests. + +The Phase 1 review correction adds pre-admission condition999/1/0 controls for +start, resume and an otherwise eligible running batch through real Inventory +restore, with full owner/receipt equality after rejection. The successful batch +case now captures and reloads the complete genuinely charged world: only job +parking and its Production revision may change, while reciprocal audit, finite +gas/J and physical identity persist. Fresh resume retains identity, target, cap +and spend, rejects at tick+59 and permits preparation at tick+60. These added +regressions remain authored and unrun while local Godot execution is on hold. + +The first leased Windows run at `a111c31` imported successfully (17.27s) but +startup failed (3.50s): the new direct Dictionary comparison rejected valid +JSON float quantities against integer literals before Inventory created its +owner. Dependent Tools then attempted to capture that absent owner. The same +comparison affected the Production bill. Both checks now compare exact pinned +shape and quantities, accepting integral JSON numbers without truncating +fractions or coercing strings/bools. Initialization order and canonical owner +creation remain unchanged. Transaction coverage adds actual default/production +Inventory-to-Tools initialization and malformed/pinned catalog controls; these +corrections await a fresh exact-head review and execution lease. + +At `1fc11c8`, leased import/startup passed (8.33s/3.85s), then the transaction +failed mount preparation (23.86s); pressure stability was not run. Source trace +identified an extra `is_open` field on the new root in the authentic route's v1 +ledger. Mount now emits the existing ledger's exact root shape: implicit open +for v1, explicit open for v2, without upgrading containers or altering closure +authority. Regression assertions distinguish actor admission and exact paid +support from candidate preparation, preserve ledger version, and check that the +original paid item's sole change is location. This correction is source-only +until a new exact-head review/lease. + +The pinned restore audit extends exact numeric comparison to assembly bills, +mounted capacity and anchor coordinates. Validated job summaries normalize +joules to integers and moles to finite exact numeric values before comparison; +no fractional joules, wrong types, shape changes or tolerance are accepted. +Transaction controls JSON-roundtrip actual mounted, admitted and charged route +records and reject changed/fractional/wrong-type fields and shapes. The charged +whole-world reload remains required; its complete-record comparison now decodes +both sides through full-precision JSON to compare JSON values independently of +each owner's integral runtime normalization. Four-owner CAS snapshots and +reciprocal audit copies retain strict equality: they compare the same live +representation or paired decoded records, never integer-literal catalogs. +These source corrections and additional controls await runtime verification. + +Local checks: `git diff --check` and +`tests/schema/chamber_recovery_schema_test.py --syntax-only` passed. The local +Python environments lack `jsonschema`; metaschema and genuine emitted-fixture +checks remain unrun locally. A frozen-head exclusive NO-PORT lease is required +before any local Godot import/test, with the existing first-diagnostic-stop and +no-edit/no-retry procedure. CI results must identify the tested revision and do +not substitute for pending Coordinator source review or later device acceptance. diff --git a/docs/schema/chamber-recovery-accounting-v1.schema.json b/docs/schema/chamber-recovery-accounting-v1.schema.json new file mode 100644 index 00000000..94fad802 --- /dev/null +++ b/docs/schema/chamber-recovery-accounting-v1.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:chamber-recovery-accounting:v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "sequence", + "last_assembly_id", + "last_job_id", + "last_batch_tick", + "last_quantity_moles", + "last_charged_joules", + "returned_moles", + "paid_joules", + "jobs" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "last_assembly_id": { + "type": "string", + "maxLength": 128 + }, + "last_job_id": { + "type": "string", + "maxLength": 128 + }, + "last_batch_tick": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "last_quantity_moles": { + "type": "number", + "minimum": 0, + "maximum": 0.1 + }, + "last_charged_joules": { + "type": "integer", + "minimum": 0, + "maximum": 250 + }, + "returned_moles": { + "type": "number", + "minimum": 0 + }, + "paid_joules": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "jobs": { + "type": "array", + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "job_id", + "assembly_id", + "returned_moles", + "paid_joules" + ], + "properties": { + "job_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "assembly_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "returned_moles": { + "type": "number", + "minimum": 0 + }, + "paid_joules": { + "type": "integer", + "minimum": 0, + "maximum": 25000 + } + } + } + } + } +} diff --git a/docs/schema/chamber-recovery-assembly-v1.schema.json b/docs/schema/chamber-recovery-assembly-v1.schema.json new file mode 100644 index 00000000..89a7dee9 --- /dev/null +++ b/docs/schema/chamber-recovery-assembly-v1.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:chamber-recovery-assembly:v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "assembly_id", + "item_id", + "definition_id", + "definition_revision", + "blueprint_id", + "blueprint_fingerprint", + "materials", + "principal_id", + "body_id", + "world_id", + "region_id", + "chamber_id", + "original_condition_permille", + "assembly_spent_joules", + "state" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "assembly_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "item_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "definition_id": { + "const": "item.chamber-recovery-pump.fabricated" + }, + "definition_revision": { + "const": 1 + }, + "blueprint_id": { + "const": "blueprint.chamber-recovery-pump.v1" + }, + "blueprint_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "materials": { + "type": "object", + "additionalProperties": false, + "required": [ + "plate", + "iron" + ], + "properties": { + "plate": { + "const": 400000 + }, + "iron": { + "const": 200000 + } + } + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "body_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "world_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "region_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "chamber_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "original_condition_permille": { + "const": 1000 + }, + "assembly_spent_joules": { + "const": 1000 + }, + "state": { + "enum": [ + "carried", + "mounted", + "salvaged" + ] + } + } +} diff --git a/docs/schema/chamber-recovery-job-v1.schema.json b/docs/schema/chamber-recovery-job-v1.schema.json new file mode 100644 index 00000000..326afdc8 --- /dev/null +++ b/docs/schema/chamber-recovery-job-v1.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:chamber-recovery-job:v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "job_id", + "assembly_id", + "principal_id", + "body_id", + "control_epoch", + "initial_moles", + "target_moles", + "returned_moles", + "spent_joules", + "max_joules", + "state", + "last_batch_tick", + "next_eligible_tick" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "job_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "assembly_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "principal_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "body_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "control_epoch": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "initial_moles": { + "type": "number", + "exclusiveMinimum": 0 + }, + "target_moles": { + "type": "number", + "exclusiveMinimum": 0 + }, + "returned_moles": { + "type": "number", + "minimum": 0 + }, + "spent_joules": { + "type": "integer", + "minimum": 0, + "maximum": 25000 + }, + "max_joules": { + "type": "integer", + "minimum": 1, + "maximum": 25000 + }, + "state": { + "enum": [ + "running", + "paused", + "completed", + "cancelled" + ] + }, + "last_batch_tick": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + }, + "next_eligible_tick": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740000 + } + } +} diff --git a/docs/schema/chamber-recovery-v1.schema.json b/docs/schema/chamber-recovery-v1.schema.json new file mode 100644 index 00000000..0ae4bab1 --- /dev/null +++ b/docs/schema/chamber-recovery-v1.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:chamber-recovery:v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "assemblies", + "jobs", + "accounting" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "assemblies": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "urn:frontier-systems:schema:chamber-recovery-assembly:v1" + } + }, + "jobs": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "urn:frontier-systems:schema:chamber-recovery-job:v1" + } + }, + "accounting": { + "$ref": "urn:frontier-systems:schema:chamber-recovery-accounting:v1" + } + } +} diff --git a/docs/schema/pressure-system-v6.schema.json b/docs/schema/pressure-system-v6.schema.json new file mode 100644 index 00000000..a22f2734 --- /dev/null +++ b/docs/schema/pressure-system-v6.schema.json @@ -0,0 +1,301 @@ +{ + "type": "object", + "required": [ + "schema_version", + "volumes", + "airlocks", + "sensors", + "total_transferred_moles", + "constructed_rooms", + "room_valves", + "paid_chamber", + "pump_accounting" + ], + "properties": { + "schema_version": { + "const": 6 + }, + "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": { + "anyOf": [ + { + "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" + } + } + }, + { + "type": "object", + "maxProperties": 0 + } + ] + }, + "pump_accounting": { + "$ref": "urn:frontier-systems:schema:chamber-recovery-accounting:v1" + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:pressure-system:v6", + "title": "Pressure reciprocal chamber recovery snapshot v6", + "$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-system-v16.schema.json b/docs/schema/production-system-v16.schema.json new file mode 100644 index 00000000..79120747 --- /dev/null +++ b/docs/schema/production-system-v16.schema.json @@ -0,0 +1,1207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:frontier-systems:schema:production-system:v16", + "title": "Production chamber recovery snapshot v16", + "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", + "chamber_recovery" + ], + "properties": { + "schema_version": { + "const": 16 + }, + "catalog_revision": { + "const": 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" + } + } + } + }, + "chamber_recovery": { + "$ref": "urn:frontier-systems:schema:chamber-recovery: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": "Paid provenance and bounded recovery jobs. Runtime validation additionally enforces material conservation, immutable job targets/budgets, deterministic audit sums and matching Pressure/Inventory 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 805815ac..afe39edd 100644 --- a/docs/schema/world-snapshot-v1.schema.json +++ b/docs/schema/world-snapshot-v1.schema.json @@ -25,7 +25,8 @@ { "$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:v5" } + { "$ref": "urn:frontier-systems:schema:pressure-system:v5" }, + { "$ref": "urn:frontier-systems:schema:pressure-system:v6" } ] }, "production": { "oneOf": [ { "$ref": "urn:frontier-systems:schema:production-system:v1" }, @@ -42,7 +43,8 @@ { "$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:v15" } + { "$ref": "urn:frontier-systems:schema:production-system:v15" }, + { "$ref": "urn:frontier-systems:schema:production-system:v16" } ] }, "health": { "oneOf": [ { "$ref": "urn:frontier-systems:schema:health-system:v1" }, diff --git a/src/core/access/access_control_system.gd b/src/core/access/access_control_system.gd index 560131e4..43b6708e 100644 --- a/src/core/access/access_control_system.gd +++ b/src/core/access/access_control_system.gd @@ -16,6 +16,7 @@ const PROTOTYPE_AIRLOCK_ID: StringName = &"habitat_airlock" var devices: Dictionary = {} var current_tick: int = 0 +var _restore_generation: int = 0 var _door_restore_guard: Callable var _paid_open_guard: Callable @@ -341,6 +342,7 @@ func capture_snapshot() -> ServiceResult: func validate_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> ServiceResult: + if _restore_generation >= 9007199254740000: return ServiceResult.failure(&"access_restore_generation_exhausted", "Access restore fence cannot wrap.") var decoded: ServiceResult = _decode_snapshot_record(record, snapshot_tick_number) return ServiceResult.success() if decoded.is_success else decoded @@ -374,6 +376,7 @@ func validate_pressure_links(pressure_record: Dictionary, access_record: Diction func restore_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> ServiceResult: + if _restore_generation >= 9007199254740000: return ServiceResult.failure(&"access_restore_generation_exhausted", "Access restore fence cannot wrap.") var decoded: ServiceResult = _decode_snapshot_record(record, snapshot_tick_number) if not decoded.is_success: return decoded @@ -382,9 +385,14 @@ func restore_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> S if not links.is_success: return links devices = decoded.value current_tick = snapshot_tick_number + _restore_generation += 1 return ServiceResult.success() +func restore_generation() -> int: + return _restore_generation + + func _decode_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> ServiceResult: if ( record.size() != 2 diff --git a/src/core/inventory/inventory_simulation_system.gd b/src/core/inventory/inventory_simulation_system.gd index c7a74ee9..9e009e2d 100644 --- a/src/core/inventory/inventory_simulation_system.gd +++ b/src/core/inventory/inventory_simulation_system.gd @@ -24,6 +24,9 @@ var _motion_validator: Callable var _primary_payload_mass: Callable var _external_payload_masses: Callable var _checkpoint_payload_masses: Variant = null +var _chamber_recovery_restore_guard: Callable + + ## Logical development targets; these are not inventory items or rigid bodies. const GRIP_TARGETS: Dictionary = { "fixture.salvage-crate": {"position": [0, 0, 0], "movable": true, "required_force": 300000}, @@ -37,6 +40,12 @@ func _init() -> void: tick_priority = 200 +func bind_chamber_recovery_restore_guard(guard: Callable) -> ServiceResult: + if _chamber_recovery_restore_guard.is_valid() or not guard.is_valid(): return _failure("Bind one chamber recovery provenance guard.") + _chamber_recovery_restore_guard = guard + return ServiceResult.success() + + func configure_defaults(include_resource_carriers: bool = false) -> ServiceResult: for definition in JSON.parse_string(FileAccess.get_file_as_string("res://data/inventory/items.default.v1.json")): var registered: ServiceResult = _catalog.register_definition(definition) @@ -46,6 +55,10 @@ func configure_defaults(include_resource_carriers: bool = false) -> ServiceResul if typeof(canister) != TYPE_DICTIONARY: return _failure("A versioned canister definition is required.") var registered_canister: ServiceResult = _catalog.register_definition(canister) if not registered_canister.is_success: return registered_canister + var pump: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/inventory/chamber-recovery-pump.v1.json")) + if not ChamberRecoveryRecords.matches_definition(pump): return _failure("The immutable chamber pump definition changed.") + var registered_pump: ServiceResult = _catalog.register_definition(pump) + if not registered_pump.is_success: return registered_pump for name in ["human", "cyborg", "station-ai"]: var parsed: ServiceResult = InventoryEquipmentProfile.from_record(JSON.parse_string(FileAccess.get_file_as_string("res://data/inventory/%s.v1.json" % name))) if not parsed.is_success: @@ -379,6 +392,19 @@ func commit_factory_graph(record: Dictionary) -> ServiceResult: return valid +## Decode a detached factory candidate for a multi-owner publication. Does not +## install it, refresh views, emit signals, or alter canonical revisions. +func decode_factory_candidate(record: Dictionary, masses: Dictionary) -> ServiceResult: + var decoded: ServiceResult = _decode(record, _last_tick, _catalog.with_payload_masses(combined_payload_masses(masses))) + if not decoded.is_success: return decoded + # Prospective loads are already computed. The detached owner must retain the + # live catalog for later transactions/refreshes, not freeze this mass override. + var owner: SharedInventoryCoordinator = decoded.value.owner + owner._catalog = _catalog + owner._ledger._catalog = _catalog + return decoded + + func apply_local(context: AuthenticatedCommandContext, request_id: String, operation: String, payload: Dictionary, tick: int) -> ServiceResult: var authorized: ServiceResult = authorize(context, true) if not authorized.is_success or tick != _last_tick + 1: @@ -386,6 +412,7 @@ func apply_local(context: AuthenticatedCommandContext, request_id: String, opera var actor_id: String = authorized.value var actor: Dictionary = _bodies[actor_id] if operation in ["open_container", "close_container"]: + if String(payload.get("container_id", "")).begins_with("world.chamber-pump-"): return _failure("Mounted chamber pump storage is controlled by its assembly owner.") if not ControlContractValidation.has_exact_fields(payload, PackedStringArray(["container_id", "expected_revision"])) or not ControlContractValidation.is_id(payload.get("container_id")) or not ControlContractValidation.is_integer(payload.get("expected_revision"), 0, MAX_EXACT_INTEGER) or not _has_unreserved_hand(actor_id): return _failure("Closure requires exact container/revision intent and an available hand.") var result: ServiceResult = _owner.set_container_open(actor_id, request_id, payload["container_id"], operation == "open_container", int(payload["expected_revision"])) @@ -396,6 +423,7 @@ func apply_local(context: AuthenticatedCommandContext, request_id: String, opera if operation in ["grip_join", "grip_release", "grip_resume"]: return _apply_grip(actor_id, request_id, operation, payload) if operation == "world": + if _contains_chamber_pump(String(payload.get("item_id", ""))): return _failure("The chamber assembly owner controls pump placement and recovery.") if not _has_unreserved_hand(actor_id): return _failure("Release a grip before manipulating world inventory.") var fields := PackedStringArray(["operation", "item_id", "world_container_id", "destination_container_id", "slot_id", "expected_revision"]) @@ -472,6 +500,7 @@ func apply_local(context: AuthenticatedCommandContext, request_id: String, opera for field in ["item_id", "slot_id", "destination_container_id"]: if typeof(payload[field]) != TYPE_STRING or (payload[field] != "" and not ControlContractValidation.is_id(payload[field])): return _failure("Optional item, slot and destination identities must be bounded strings.") + if payload["action"] != "search" and _contains_chamber_pump(payload["item_id"]): return _failure("Chamber pump provenance cannot transfer through a generic body interaction.") for entry in _jobs.values(): if entry["job"].to_record()["actor_body_id"] == actor_id: return _failure("This actor already has an active or paused inventory job.") @@ -694,7 +723,12 @@ func validate_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> # Runner validates all systems before any restore; exhaustion must fail there. if _controller_restore_generation >= MAX_EXACT_INTEGER: return ServiceResult.failure(&"restore_generation_exhausted", "Restore fence cannot wrap.") - return _decode(record, snapshot_tick_number) + var decoded: ServiceResult = _decode(record, snapshot_tick_number) + if not decoded.is_success: return decoded + if _chamber_recovery_restore_guard.is_valid(): + var guarded: ServiceResult = _chamber_recovery_restore_guard.call(record) + if not guarded.is_success: return guarded + return decoded ## Volatile fence is owned here so every direct or runner restore retires tokens. @@ -708,6 +742,9 @@ func restore_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> S var decoded: ServiceResult = _decode(record, snapshot_tick_number) if not decoded.is_success: return decoded + if _chamber_recovery_restore_guard.is_valid(): + var guarded: ServiceResult = _chamber_recovery_restore_guard.call(record) + if not guarded.is_success: return guarded _owner = decoded.value["owner"] _bodies = decoded.value["bodies"] _jobs = decoded.value["jobs"] @@ -971,6 +1008,7 @@ func _release_lost_grip_contacts(tick: int) -> void: func _job_access(entry: Dictionary, owner: SharedInventoryCoordinator = null) -> ServiceResult: var state: Dictionary = entry["job"].to_record() var access_owner: SharedInventoryCoordinator = _owner if owner == null else owner + if state["action"] != "search" and _contains_chamber_pump(state["item_id"], access_owner): return _failure("Chamber pump provenance cannot transfer through a generic body interaction.") var access: ServiceResult = access_owner.contents_access(state["target_container_id"]) if not access.is_success: return access if state["action"] != "search": @@ -980,6 +1018,25 @@ func _job_access(entry: Dictionary, owner: SharedInventoryCoordinator = null) -> return ServiceResult.success() +## Include ancestors so putting a carried pump in a backpack cannot bypass the +## dedicated assembly/body ownership rule through a generic drop or handoff. +func _contains_chamber_pump(item_id: String, owner: SharedInventoryCoordinator = null) -> bool: + if item_id == "": return false + var ledger: Dictionary = (_owner if owner == null else owner).to_record()["ledger"] + var locations: Dictionary = {} + var parents: Dictionary = {} + for item in ledger.items: locations[item.item_id] = item.container_id + for container in ledger.containers: parents[container.container_id] = container.owner_item_id + for item in ledger.items: + if item.definition_id != ChamberRecoveryRecords.DEFINITION: continue + var ancestor: String = item.item_id + for depth in range(InventoryLedger.MAX_DEPTH + 1): + if ancestor == item_id: return true + ancestor = parents.get(locations.get(ancestor, ""), "") + if ancestor == "": break + return false + + func _context(state: Dictionary) -> Dictionary: var revisions: Dictionary = {} for body in _owner.to_record()["bodies"]: diff --git a/src/core/pressure/chamber_recovery_work.gd b/src/core/pressure/chamber_recovery_work.gd new file mode 100644 index 00000000..acb66c66 --- /dev/null +++ b/src/core/pressure/chamber_recovery_work.gd @@ -0,0 +1,40 @@ +extends RefCounted +class_name ChamberRecoveryWork + +## Detached isothermal chamber-to-habitat compression proposal. Does not authorize +## a job, draw energy, change either volume, or publish a prepared transaction. +const EFFICIENCY: float = 0.35 +const MAX_BATCH_MOLES: float = 0.1 +const MAX_BATCH_JOULES: int = 250 +const MIN_SOURCE_KPA: float = 1.0 +const MAX_RECEIVER_KPA: float = 110.0 +const MAX_PRESSURE_RATIO: float = 16.0 + + +static func prepare(source: PressureVolumeState, receiver: PressureVolumeState, quantity_moles: float) -> ServiceResult: + if source == null or receiver == null or not source.is_valid() or not receiver.is_valid(): return denied() + if source == receiver or source.volume_id == receiver.volume_id: return denied() + if source.temperature_kelvin != receiver.temperature_kelvin: return denied() + if not is_finite(quantity_moles) or quantity_moles <= 0.0 or quantity_moles > MAX_BATCH_MOLES or quantity_moles >= source.gas_moles: return denied() + var source_after := PressureVolumeState.new(source.volume_id, source.volume_m3, source.temperature_kelvin, source.gas_moles - quantity_moles) + var receiver_after := PressureVolumeState.new(receiver.volume_id, receiver.volume_m3, receiver.temperature_kelvin, receiver.gas_moles + quantity_moles) + # Reject quantities smaller than representable changes; never clamp/delete gas. + if not source_after.is_valid() or not receiver_after.is_valid(): return denied() + if source_after.gas_moles >= source.gas_moles or receiver_after.gas_moles <= receiver.gas_moles: return denied() + var source_kpa: float = source_after.pressure_kpa() + var receiver_kpa: float = receiver_after.pressure_kpa() + if source_kpa < MIN_SOURCE_KPA or receiver_kpa > MAX_RECEIVER_KPA: return denied() + if source_kpa >= source.pressure_kpa() or receiver_kpa <= receiver.pressure_kpa(): return denied() + var ratio: float = receiver_kpa / source_kpa + if not is_finite(ratio) or ratio > MAX_PRESSURE_RATIO: return denied() + # The endpoint ratio bounds the increasing ratio over the entire batch. + # Downhill transfer still costs one joule and never generates energy credit. + var work: float = quantity_moles * PressureVolumeState.GAS_CONSTANT_J_PER_MOL_K * source.temperature_kelvin * log(maxf(1.0, ratio)) / EFFICIENCY + if not is_finite(work) or work < 0.0 or work > float(MAX_BATCH_JOULES - 1): return denied() + var charge: int = int(ceil(work)) + 1 + if charge < 1 or charge > MAX_BATCH_JOULES: return denied() + return ServiceResult.success({"source_after": source_after, "receiver_after": receiver_after, "quantity_moles": quantity_moles, "charged_joules": charge}) + + +static func denied() -> ServiceResult: + return ServiceResult.failure(&"chamber_recovery_work_rejected", "The detached compression proposal exceeds a physical or work limit.") diff --git a/src/core/pressure/pressure_room_owner.gd b/src/core/pressure/pressure_room_owner.gd index b8884607..2631078b 100644 --- a/src/core/pressure/pressure_room_owner.gd +++ b/src/core/pressure/pressure_room_owner.gd @@ -9,6 +9,13 @@ extends SimulationSystem @abstract func install_prepared_room_state(state: Dictionary) -> void +## Volatile ABA fence: identical saved records do not preserve prepared authority. +@abstract func room_restore_generation() -> int + +@abstract func decode_chamber_recovery_candidate(record: Dictionary, tick: int) -> ServiceResult + +@abstract func prepare_chamber_recovery_transfer(assembly_id: String, job_id: String, quantity_moles: float, tick: int) -> ServiceResult + @abstract func room_area(door_id: String) -> float @abstract func room_difference(door_id: String) -> float diff --git a/src/core/pressure/pressure_system.gd b/src/core/pressure/pressure_system.gd index 99efef13..9600e742 100644 --- a/src/core/pressure/pressure_system.gd +++ b/src/core/pressure/pressure_system.gd @@ -17,6 +17,8 @@ var total_transferred_moles: float = 0.0 var constructed_rooms: Dictionary = {} var room_valves: Dictionary = {} var paid_chamber: Dictionary = {} +var pump_accounting: Dictionary = {} +var _room_restore_generation: int = 0 var _room_production: ProductionSimulationSystem var _room_access: AccessControlSystem var _room_world: StringName @@ -156,7 +158,7 @@ func capture_snapshot() -> ServiceResult: sensor_records.append(sensor.to_record()) var result: Dictionary = { - "schema_version": 5 if not paid_chamber.is_empty() else _room_snapshot_version(constructed_rooms, room_valves), + "schema_version": 6 if not pump_accounting.is_empty() else (5 if not paid_chamber.is_empty() else _room_snapshot_version(constructed_rooms, room_valves)), "volumes": volume_records, "airlocks": airlock_records, "sensors": sensor_records, @@ -164,11 +166,13 @@ 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) + if int(result.schema_version) >= 5: result.paid_chamber = paid_chamber.duplicate(true) + if int(result.schema_version) == 6: result.pump_accounting = pump_accounting.duplicate(true) return ServiceResult.success(result) func validate_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> ServiceResult: + if _room_restore_generation >= 9007199254740000: return ServiceResult.failure(&"pressure_restore_generation_exhausted", "Pressure restore fence cannot wrap.") var decoded: ServiceResult = _decode_snapshot_record(record, snapshot_tick_number) if not decoded.is_success: return decoded @@ -176,6 +180,7 @@ func validate_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> func restore_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> ServiceResult: + if _room_restore_generation >= 9007199254740000: return ServiceResult.failure(&"pressure_restore_generation_exhausted", "Pressure restore fence cannot wrap.") var decoded: ServiceResult = _decode_snapshot_record(record, snapshot_tick_number) if not decoded.is_success: return decoded @@ -183,9 +188,14 @@ func restore_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> S var links: ServiceResult = _room_production.validate_pressure_restore(record, snapshot_tick_number) if not links.is_success: return links install_prepared_room_state(decoded.value) + _room_restore_generation += 1 return ServiceResult.success() +func room_restore_generation() -> int: + return _room_restore_generation + + ## Only decoded, prevalidated server candidates reach this non-yielding write. func install_prepared_room_state(state: Dictionary) -> void: volumes = state["volumes"] @@ -194,7 +204,34 @@ 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", {}) + paid_chamber = state["paid_chamber"] + pump_accounting = state["pump_accounting"] + + +func decode_chamber_recovery_candidate(record: Dictionary, tick: int) -> ServiceResult: + return _decode_snapshot_record(record, tick) + + +func prepare_chamber_recovery_transfer(assembly_id: String, job_id: String, quantity_moles: float, tick: int) -> ServiceResult: + if _room_production == null or _room_access == null or paid_chamber.is_empty(): return ChamberRecoveryRecords.denied() + var production: Dictionary = _room_production.capture_snapshot().value + var job: Dictionary = ChamberRecoveryRecords.active_job(production.get("chamber_recovery", {})) + if job.is_empty() or job.state != "running" or job.job_id != job_id or job.assembly_id != assembly_id or tick != int(production.last_tick) or tick < int(job.next_eligible_tick) or quantity_moles != minf(0.1, float(job.target_moles) - float(job.returned_moles)): return ChamberRecoveryRecords.denied() + var access: ServiceResult = _room_access.capture_snapshot() + if not access.is_success or not validate_room_links(production, access.value).is_success: return ChamberRecoveryRecords.denied() + for id in [paid_chamber.inner_door_id, paid_chamber.outer_door_id]: + if room_valves[id] or not PaidChamber.closed_intact(PaidChamber.actuator(access.value, id)): return ChamberRecoveryRecords.denied() + var proposal: ServiceResult = ChamberRecoveryWork.prepare(volumes.get(StringName(paid_chamber.volume_id)), volumes.get(PROTOTYPE_HABITAT_ID), quantity_moles) + if not proposal.is_success: return proposal + var captured: ServiceResult = capture_snapshot() + if not captured.is_success: return captured + var record: Dictionary = captured.value.duplicate(true) + for index in range(record.volumes.size()): + if record.volumes[index].volume_id == paid_chamber.volume_id: record.volumes[index] = proposal.value.source_after.to_record() + elif record.volumes[index].volume_id == String(PROTOTYPE_HABITAT_ID): record.volumes[index] = proposal.value.receiver_after.to_record() + if not is_finite(float(record.total_transferred_moles) + quantity_moles): return ChamberRecoveryRecords.denied() + record.total_transferred_moles += quantity_moles + return ServiceResult.success({"after": record, "charged_joules": proposal.value.charged_joules}) func _step_transfer(first_id: StringName, second_id: StringName, conductance: float, delta_seconds: float) -> void: @@ -239,21 +276,24 @@ 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, 5): + if not ControlContractValidation.is_integer(record.get("schema_version"), 1, 6): 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() + if int(record.schema_version) >= 5: + if (int(record.schema_version) == 5 or not paid_record.is_empty()) and 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") + if int(record.schema_version) >= 5: fields.append("paid_chamber") + if int(record.schema_version) == 6: + fields.append("pump_accounting") + if not ChamberRecoveryRecords.valid_accounting(record.get("pump_accounting"), snapshot_tick_number): return ChamberRecoveryRecords.denied() 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() > (2 if int(record.schema_version) == 5 else 1): return ConstructedRoomPressure.denied() + if typeof(valves) != TYPE_DICTIONARY or valves.size() > (2 if not paid_record.is_empty() 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() @@ -388,6 +428,7 @@ func _decode_snapshot_record(record: Dictionary, snapshot_tick_number: int) -> S "constructed_rooms": rooms.duplicate(true), "room_valves": valves.duplicate(true), "paid_chamber": paid_record.duplicate(true), + "pump_accounting": record.get("pump_accounting", {}).duplicate(true), }) @@ -424,12 +465,18 @@ func validate_room_links(production: Dictionary, access: Dictionary, pressure_re var rooms: Dictionary = constructed_rooms var valves: Dictionary = room_valves var chamber: Dictionary = paid_chamber + var accounting: Dictionary = pump_accounting 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 + accounting = decoded.value.pump_accounting + if accounting != production.get("chamber_recovery", {}).get("accounting", {}): return ChamberRecoveryRecords.denied() + var assembly: Dictionary = ChamberRecoveryRecords.current(production.get("chamber_recovery", {})) + if not assembly.is_empty(): + if chamber.is_empty() or assembly.chamber_id != chamber.volume_id or assembly.principal_id != chamber.principal_id or assembly.body_id != chamber.body_id or assembly.world_id != chamber.world_id or assembly.region_id != chamber.region_id: return ChamberRecoveryRecords.denied() if not chamber.is_empty(): if not rooms.is_empty(): return PaidChamber.denied() return PaidChamber.links(chamber, valves, production, access, inventory) @@ -443,8 +490,9 @@ 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 = 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) + result.schema_version = 6 if not state.get("pump_accounting", {}).is_empty() else (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) == 6: result.pump_accounting = state.pump_accounting.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()) diff --git a/src/core/production/chamber_recovery_owner_fence.gd b/src/core/production/chamber_recovery_owner_fence.gd new file mode 100644 index 00000000..2d92bdda --- /dev/null +++ b/src/core/production/chamber_recovery_owner_fence.gd @@ -0,0 +1,51 @@ +extends RefCounted +class_name ChamberRecoveryOwnerFence + +## Ephemeral all-owner admission fence for the forthcoming prepared gas/energy +## transaction. Taking this fence never publishes candidates or changes receipts. +## The caller must finish all decoding/cross-validation before taking it, and +## publish directly without callbacks/yields after a successful take. +var _production: ProductionSimulationSystem +var _pressure: PressureRoomOwner +var _inventory: InventorySimulationSystem +var _access: AccessControlSystem +var _records: Array[Dictionary] = [] +var _generations: Array[int] = [] +var _consumed: bool = false +var _access_tick: int = 0 + + +static func capture(production: ProductionSimulationSystem, pressure: PressureRoomOwner, inventory: InventorySimulationSystem, access: AccessControlSystem) -> ServiceResult: + if production == null or pressure == null or inventory == null or access == null: return denied() + var fence := ChamberRecoveryOwnerFence.new() + fence._production = production + fence._pressure = pressure + fence._inventory = inventory + fence._access = access + for owner in [production, pressure, inventory, access]: + var captured: ServiceResult = owner.capture_snapshot() + if not captured.is_success: return denied() + fence._records.append(captured.value.duplicate(true)) + fence._generations = [production.collision_restore_generation(), pressure.room_restore_generation(), inventory.controller_restore_generation(), access.restore_generation()] + fence._access_tick = access.current_tick + return ServiceResult.success(fence) + + +## A failed take consumes only this ephemeral value, never canonical receipts. +func take(production: ProductionSimulationSystem, pressure: PressureRoomOwner, inventory: InventorySimulationSystem, access: AccessControlSystem) -> ServiceResult: + if _consumed: return denied() + _consumed = true + if production != _production or pressure != _pressure or inventory != _inventory or access != _access: return denied() + if not is_instance_valid(production) or not is_instance_valid(pressure) or not is_instance_valid(inventory) or not is_instance_valid(access): return denied() + if _generations != [production.collision_restore_generation(), pressure.room_restore_generation(), inventory.controller_restore_generation(), access.restore_generation()]: return denied() + if access.current_tick != _access_tick: return denied() + var index: int = 0 + for owner in [production, pressure, inventory, access]: + var captured: ServiceResult = owner.capture_snapshot() + if not captured.is_success or captured.value != _records[index]: return denied() + index += 1 + return ServiceResult.success() + + +static func denied() -> ServiceResult: + return ServiceResult.failure(&"chamber_recovery_owner_changed", "The recovery preparation is stale, consumed or unavailable.") diff --git a/src/core/production/chamber_recovery_preparation.gd b/src/core/production/chamber_recovery_preparation.gd new file mode 100644 index 00000000..187b2551 --- /dev/null +++ b/src/core/production/chamber_recovery_preparation.gd @@ -0,0 +1,190 @@ +extends RefCounted +class_name ChamberRecoveryPreparation + +## Detached backend only. No player command, scheduler or presentation entry +## point. Real paid candidates, inventory capacities and live admission are used +## by isolated tests and the later command implementation. + +static func admit(production: ProductionSimulationSystem, context: AuthenticatedCommandContext, mode: String) -> ServiceResult: + if production == null or context == null or production._room_pressure == null or production._door_access == null or production._inventory == null: return ChamberRecoveryRecords.denied() + var state: Dictionary = production.capture_snapshot().value + if int(state.timebase_hz) != 60 or Engine.physics_ticks_per_second != 60 or not state.powered.get("construction", false) or int(state.state_revision) >= ProductionSimulationSystem.MAX_REVISION: return ChamberRecoveryRecords.denied() + var inventory: InventorySimulationSystem = production._inventory + var allowed: ServiceResult = inventory.production_capability(context, true, [0, 0, 0] if mode == "assembly" else []) + if not allowed.is_success or not inventory.active_tool(context, "tool.crowbar").is_success: return ChamberRecoveryRecords.denied() + var pressure: ServiceResult = production._room_pressure.capture_snapshot() + var access: ServiceResult = production._door_access.capture_snapshot() + if not pressure.is_success or not access.is_success: return ChamberRecoveryRecords.denied() + if not production._room_pressure.validate_room_links(state, access.value, pressure.value, int(state.last_tick), inventory.capture_snapshot().value).is_success: return ChamberRecoveryRecords.denied() + var chamber: Dictionary = pressure.value.get("paid_chamber", {}) + if chamber.is_empty() or chamber.principal_id != String(context.principal_id) or chamber.body_id != allowed.value or chamber.world_id != String(context.world_id) or chamber.region_id != String(context.region_id): return ChamberRecoveryRecords.denied() + for id in [chamber.inner_door_id, chamber.outer_door_id]: + if pressure.value.room_valves[id] or not PaidChamber.closed_intact(PaidChamber.actuator(access.value, id)): return ChamberRecoveryRecords.denied() + if mode != "assembly": + var pose: ServiceResult = inventory.own_position(context) + if not pose.is_success or SpatialWallConstruction.body_center(pose.value).distance_to(Vector3(5600, 900, -4300)) > InventorySimulationSystem.WORLD_REACH_MILLIMETRES: return ChamberRecoveryRecords.denied() + var assembly: Dictionary = ChamberRecoveryRecords.current(state.get("chamber_recovery", {})) + if assembly.is_empty() or assembly.principal_id != chamber.principal_id or assembly.body_id != chamber.body_id or assembly.chamber_id != chamber.volume_id: return ChamberRecoveryRecords.denied() + var physical: Dictionary = MachineryWorkshop.item(inventory.capture_snapshot().value.owner, assembly.item_id) + if physical.is_empty() or int(physical.condition_permille) <= 0: return ChamberRecoveryRecords.denied() + if mode in ["start", "step"] and int(physical.condition_permille) != 1000: return ChamberRecoveryRecords.denied() + if mode != "mount" and assembly.state != "mounted": return ChamberRecoveryRecords.denied() + return ServiceResult.success(allowed.value) + + +static func finish(production: ProductionSimulationSystem, context: AuthenticatedCommandContext, mode: String, state: Dictionary, pressure_record: Dictionary, inventory_record: Dictionary = {}, contexts: Dictionary = {}) -> ServiceResult: + var decoded: ServiceResult = production.validate_snapshot_record(state, int(state.last_tick)) + if not decoded.is_success: return decoded + var pressure_decoded: ServiceResult = production._room_pressure.decode_chamber_recovery_candidate(pressure_record, int(state.last_tick)) + if not pressure_decoded.is_success: return pressure_decoded + var physical: Dictionary = production._inventory.capture_snapshot().value if inventory_record.is_empty() else inventory_record + if not production.validate_inventory_links(state, physical).is_success: return ChamberRecoveryRecords.denied() + var access: Dictionary = production._door_access.capture_snapshot().value + if not production._room_pressure.validate_room_links(state, access, pressure_record, int(state.last_tick), physical).is_success: return ChamberRecoveryRecords.denied() + var inventory_decoded: ServiceResult = production._inventory.decode_factory_candidate(physical, ProductionSimulationSystem.payload_masses(state)) + if not inventory_decoded.is_success: return inventory_decoded + var fence: ServiceResult = ChamberRecoveryOwnerFence.capture(production, production._room_pressure, production._inventory, production._door_access) + if not fence.is_success: return fence + var prepared := PreparedChamberRecoveryChange.new() + prepared._fence = fence.value + prepared._production_after = decoded.value + prepared._pressure_after = pressure_decoded.value + prepared._inventory_after = {} if inventory_record.is_empty() else inventory_decoded.value + prepared._contexts_after = contexts.duplicate() + prepared._context = context + prepared._mode = mode + return ServiceResult.success(prepared) + + +static func assembly(production: ProductionSimulationSystem, context: AuthenticatedCommandContext, request_id: String) -> ServiceResult: + var admission: ServiceResult = admit(production, context, "assembly") + if not admission.is_success or not ControlContractValidation.is_id(request_id): return ChamberRecoveryRecords.denied() + var state: Dictionary = production.capture_snapshot().value.duplicate(true) + var recovery: Dictionary = state.get("chamber_recovery", ChamberRecoveryRecords.initial()).duplicate(true) + if not ChamberRecoveryRecords.current(recovery).is_empty() or recovery.assemblies.size() >= ChamberRecoveryRecords.MAX_ASSEMBLIES or int(state.energy_joules) < 1000 or int(state.store.plate) < 400000 or int(state.store.iron) - ConstructionReservationPlan.claimed(state.construction) < 200000: return ChamberRecoveryRecords.denied() + var pressure: Dictionary = production._room_pressure.capture_snapshot().value.duplicate(true) + var chamber: Dictionary = pressure.paid_chamber + var id: String = "chamber-recovery-assembly-%d" % (recovery.assemblies.size() + 1) + var item_id: String = "item-%s" % id + recovery.assemblies.append({"schema_version": 1, "assembly_id": id, "item_id": item_id, "definition_id": ChamberRecoveryRecords.DEFINITION, "definition_revision": 1, "blueprint_id": ChamberRecoveryRecords.BLUEPRINT, "blueprint_fingerprint": ChamberRecoveryRecords.fingerprint(), "materials": ChamberRecoveryRecords.BILL.duplicate(), "principal_id": String(context.principal_id), "body_id": admission.value, "world_id": String(context.world_id), "region_id": String(context.region_id), "chamber_id": chamber.volume_id, "original_condition_permille": 1000, "assembly_spent_joules": 1000, "state": "carried"}) + state.schema_version = 16 + state.chamber_recovery = recovery + state.store.plate -= 400000 + state.store.iron -= 200000 + state.energy_joules -= 1000 + state.state_revision += 1 + var graph: ServiceResult = production._inventory.prepare_equipment_change(context, request_id, item_id, true, ProductionSimulationSystem.payload_masses(state), ChamberRecoveryRecords.DEFINITION) + if not graph.is_success: return graph + var inventory: Dictionary = production._inventory.capture_snapshot().value + inventory.owner = graph.value + pressure.schema_version = 6 + pressure.pump_accounting = recovery.accounting.duplicate(true) + return finish(production, context, "assembly", state, pressure, inventory, production._chamber_recovery_contexts) + + +static func mount(production: ProductionSimulationSystem, context: AuthenticatedCommandContext, request_id: String) -> ServiceResult: + var admission: ServiceResult = admit(production, context, "mount") + if not admission.is_success or not ControlContractValidation.is_id(request_id): return ChamberRecoveryRecords.denied() + var state: Dictionary = production.capture_snapshot().value.duplicate(true) + var assembly: Dictionary = ChamberRecoveryRecords.current(state.chamber_recovery) + if assembly.state != "carried" or not ChamberRecoveryRecords.active_job(state.chamber_recovery).is_empty(): return ChamberRecoveryRecords.denied() + if not supported(state): return ChamberRecoveryRecords.denied() + var inventory: Dictionary = production._inventory.capture_snapshot().value + var owner: Dictionary = inventory.owner.duplicate(true) + var root: String = ChamberRecoveryRecords.root_id(assembly.assembly_id) + var container: Dictionary = {"container_id": root, "owner_item_id": "", "capacity": ChamberRecoveryRecords.capacity()} + # Match the existing ledger contract: v1 roots are implicitly open, while + # v2 requires explicit closure state. Mount must not upgrade other containers. + if int(owner.ledger.schema_version) == 2: container.is_open = true + owner.ledger.containers.append(container) + for item in owner.ledger.items: + if item.item_id == assembly.item_id: item.container_id = root + var locations: Array = inventory.world_storage.duplicate(true) + locations.append({"container_id": root, "position_millimetres": ChamberRecoveryRecords.ANCHOR.duplicate(), "accessible": false}) + var graph: ServiceResult = production._inventory.prepare_factory_graph(context, owner, locations, request_id, ProductionSimulationSystem.payload_masses(state)) + if not graph.is_success: return graph + assembly.state = "mounted" + state.state_revision += 1 + return finish(production, context, "mount", state, production._room_pressure.capture_snapshot().value, graph.value, production._chamber_recovery_contexts) + + +static func supported(state: Dictionary) -> bool: + # Reuse the exact union algorithm with the pump's actual support rectangle. + return PaidDoorGeometry.supported_bounds([5500, 5700, -4450, -4350], state.panels) + + +static func start(production: ProductionSimulationSystem, context: AuthenticatedCommandContext) -> ServiceResult: + var admission: ServiceResult = admit(production, context, "start") + if not admission.is_success: return admission + var state: Dictionary = production.capture_snapshot().value.duplicate(true) + var recovery: Dictionary = state.chamber_recovery + if int(state.last_tick) > ChamberRecoveryRecords.LIMIT - 60: return ChamberRecoveryRecords.denied() + var pressure: Dictionary = production._room_pressure.capture_snapshot().value.duplicate(true) + var controller: ServiceResult = production._inventory.controller_candidate() + if not controller.is_success or not controller.value.has(admission.value): return ChamberRecoveryRecords.denied() + var previous: Dictionary = ChamberRecoveryRecords.active_job(recovery) + if not previous.is_empty(): + if previous.state != "paused" or previous.principal_id != String(context.principal_id) or previous.body_id != admission.value: return ChamberRecoveryRecords.denied() + previous.state = "running" + previous.control_epoch = int(controller.value[admission.value].control_epoch) + previous.next_eligible_tick = int(state.last_tick) + 60 + state.state_revision += 1 + var resumed_contexts: Dictionary = production._chamber_recovery_contexts.duplicate() + resumed_contexts[previous.job_id] = context + return finish(production, context, "start", state, pressure, {}, resumed_contexts) + if recovery.jobs.size() >= ChamberRecoveryRecords.MAX_JOBS or int(state.energy_joules) <= 0: return ChamberRecoveryRecords.denied() + var initial: float = -1.0 + for volume in pressure.volumes: + if volume.volume_id == pressure.paid_chamber.volume_id: initial = float(volume.gas_moles) + if not is_finite(initial) or initial <= 0.0: return ChamberRecoveryRecords.denied() + var assembly: Dictionary = ChamberRecoveryRecords.current(recovery) + var id: String = "chamber-recovery-job-%d" % (recovery.jobs.size() + 1) + var job: Dictionary = {"schema_version": 1, "job_id": id, "assembly_id": assembly.assembly_id, "principal_id": String(context.principal_id), "body_id": admission.value, "control_epoch": int(controller.value[admission.value].control_epoch), "initial_moles": initial, "target_moles": initial * 0.2, "returned_moles": 0.0, "spent_joules": 0, "max_joules": mini(25000, int(state.energy_joules)), "state": "running", "last_batch_tick": 0, "next_eligible_tick": int(state.last_tick) + 60} + recovery.jobs.append(job) + recovery.accounting.jobs.append(ChamberRecoveryRecords.summary(job)) + pressure.pump_accounting = recovery.accounting.duplicate(true) + state.state_revision += 1 + var contexts: Dictionary = production._chamber_recovery_contexts.duplicate() + contexts[id] = context + return finish(production, context, "start", state, pressure, {}, contexts) + + +static func step(production: ProductionSimulationSystem, tick: int) -> ServiceResult: + var state: Dictionary = production.capture_snapshot().value.duplicate(true) + var job: Dictionary = ChamberRecoveryRecords.active_job(state.get("chamber_recovery", {})) + if job.is_empty() or job.state != "running" or tick != int(state.last_tick) or tick < int(job.next_eligible_tick) or tick > ChamberRecoveryRecords.LIMIT - 60: return ChamberRecoveryRecords.denied() + var context: AuthenticatedCommandContext = production._chamber_recovery_contexts.get(job.job_id) + var admission: ServiceResult = admit(production, context, "step") + if not admission.is_success: return admission + var control: ServiceResult = production._inventory.controller_candidate() + if not control.is_success or not control.value.has(job.body_id) or int(control.value[job.body_id].control_epoch) != int(job.control_epoch): return ChamberRecoveryRecords.denied() + var quantity: float = minf(0.1, float(job.target_moles) - float(job.returned_moles)) + var transfer: ServiceResult = production._room_pressure.prepare_chamber_recovery_transfer(job.assembly_id, job.job_id, quantity, tick) + if not transfer.is_success: return transfer + var charged: int = int(transfer.value.charged_joules) + if int(state.energy_joules) < charged or int(job.spent_joules) + charged > int(job.max_joules): return ChamberRecoveryRecords.denied() + job.returned_moles += quantity + job.spent_joules += charged + job.last_batch_tick = tick + job.next_eligible_tick = tick + 60 + if float(job.returned_moles) == float(job.target_moles): job.state = "completed" + var audit: Dictionary = state.chamber_recovery.accounting + if int(audit.sequence) >= ChamberRecoveryRecords.LIMIT: return ChamberRecoveryRecords.denied() + audit.sequence += 1 + audit.last_assembly_id = job.assembly_id + audit.last_job_id = job.job_id + audit.last_batch_tick = tick + audit.last_quantity_moles = quantity + audit.last_charged_joules = charged + audit.jobs.clear() + audit.returned_moles = 0.0 + audit.paid_joules = 0 + for retained in state.chamber_recovery.jobs: + audit.jobs.append(ChamberRecoveryRecords.summary(retained)) + audit.returned_moles += float(retained.returned_moles) + audit.paid_joules += int(retained.spent_joules) + state.energy_joules -= charged + state.state_revision += 1 + var pressure: Dictionary = transfer.value.after + pressure.pump_accounting = audit.duplicate(true) + return finish(production, context, "step", state, pressure, {}, production._chamber_recovery_contexts) diff --git a/src/core/production/chamber_recovery_records.gd b/src/core/production/chamber_recovery_records.gd new file mode 100644 index 00000000..b23a1dfb --- /dev/null +++ b/src/core/production/chamber_recovery_records.gd @@ -0,0 +1,226 @@ +extends RefCounted +class_name ChamberRecoveryRecords + +## Bounded paid provenance and accounting. Production owns these jobs and bank; +## Pressure carries only the identical audit value, never a second energy bank. +const DEFINITION: String = "item.chamber-recovery-pump.fabricated" +const BLUEPRINT: String = "blueprint.chamber-recovery-pump.v1" +const MASS: int = 600000 +const ASSEMBLY_JOULES: int = 1000 +const MAX_ASSEMBLIES: int = 32 +const MAX_JOBS: int = 128 +const LIMIT: int = 9007199254740000 +const ANCHOR: Array = [5600, 0, -4400] +const BILL: Dictionary = {"plate": 400000, "iron": 200000} + + +static func initial() -> Dictionary: + return {"schema_version": 1, "assemblies": [], "jobs": [], "accounting": empty_accounting()} + + +static func empty_accounting() -> Dictionary: + return {"schema_version": 1, "sequence": 0, "last_assembly_id": "", "last_job_id": "", "last_batch_tick": 0, "last_quantity_moles": 0.0, "last_charged_joules": 0, "returned_moles": 0.0, "paid_joules": 0, "jobs": []} + + +static func fingerprint() -> String: + return JSON.stringify(blueprint(), "", true).sha256_text() + + +static func blueprint() -> Dictionary: + return {"schema_version": 1, "blueprint_id": BLUEPRINT, "definition_id": DEFINITION, "definition_revision": 1, "materials_milligrams": BILL.duplicate(), "assembly_joules": ASSEMBLY_JOULES, "external_volume_millilitres": 6000} + + +static func definition() -> Dictionary: + return {"schema_version": 1, "definition_id": DEFINITION, "revision": 1, "tags": ["item.handheld", "equipment.chamber-pump"], "mass_milligrams": 0, "volume_millilitres": 6000, "storage": null} + + +static func matches_definition(value: Variant) -> bool: + return _matches_pinned(value, definition()) + + +static func matches_blueprint(value: Variant) -> bool: + return _matches_pinned(value, blueprint()) + + +## JSON numbers decode as floats; Dictionary equality distinguishes them from +## integer literals. Validate exact quantities before conversion, never truncate. +static func _matches_pinned(value: Variant, expected: Variant) -> bool: + if typeof(expected) == TYPE_INT: + return ControlContractValidation.is_integer(value, expected, expected) + if typeof(expected) == TYPE_FLOAT: + return typeof(value) in [TYPE_INT, TYPE_FLOAT] and is_finite(float(value)) and is_finite(expected) and float(value) == expected + if typeof(value) != typeof(expected): return false + if typeof(expected) == TYPE_DICTIONARY: + if value.size() != expected.size(): return false + for key in expected: + if not value.has(key) or not _matches_pinned(value[key], expected[key]): return false + return true + if typeof(expected) == TYPE_ARRAY: + if value.size() != expected.size(): return false + for index in range(expected.size()): + if not _matches_pinned(value[index], expected[index]): return false + return true + return value == expected + + +static func root_id(assembly_id: String) -> String: + return "world.chamber-pump-%s" % assembly_id + + +static func capacity() -> Dictionary: + return {"max_mass_milligrams": MASS, "max_volume_millilitres": 6000, "max_items": 1, "accepted_tags": ["equipment.chamber-pump"]} + + +static func current(value: Dictionary) -> Dictionary: + for assembly in value.get("assemblies", []): + if assembly.state != "salvaged": return assembly + return {} + + +static func active_job(value: Dictionary) -> Dictionary: + for job in value.get("jobs", []): + if job.state in ["running", "paused"]: return job + return {} + + +static func mass(state: Dictionary) -> int: + return 0 if current(state.get("chamber_recovery", {})).is_empty() else MASS + + +static func payloads(state: Dictionary) -> Dictionary: + var assembly: Dictionary = current(state.get("chamber_recovery", {})) + return {} if assembly.is_empty() else {assembly.item_id: MASS} + + +static func fields(value: Variant, names: Array) -> bool: + return typeof(value) == TYPE_DICTIONARY and ControlContractValidation.has_exact_fields(value, PackedStringArray(names)) + + +static func number(value: Variant, minimum: float = 0.0) -> bool: + return typeof(value) in [TYPE_INT, TYPE_FLOAT] and is_finite(float(value)) and float(value) >= minimum + + +static func integer(value: Variant, minimum: int = 0, maximum: int = LIMIT) -> bool: + return ControlContractValidation.is_integer(value, minimum, maximum) + + +static func summary(job: Dictionary) -> Dictionary: + return {"job_id": job.job_id, "assembly_id": job.assembly_id, "returned_moles": float(job.returned_moles), "paid_joules": int(job.spent_joules)} + + +static func valid_accounting(value: Variant, tick: int) -> bool: + if not fields(value, ["schema_version", "sequence", "last_assembly_id", "last_job_id", "last_batch_tick", "last_quantity_moles", "last_charged_joules", "returned_moles", "paid_joules", "jobs"]): return false + if not integer(value.schema_version, 1, 1) or not integer(value.sequence) or not integer(value.last_batch_tick, 0, tick) or not integer(value.last_charged_joules, 0, 250) or not integer(value.paid_joules, 0, 100000): return false + if not number(value.returned_moles) or not number(value.last_quantity_moles) or float(value.last_quantity_moles) > 0.1 or typeof(value.jobs) != TYPE_ARRAY or value.jobs.size() > MAX_JOBS: return false + var returned: float = 0.0 + var paid: int = 0 + var last_found: bool = false + for index in range(value.jobs.size()): + var job: Variant = value.jobs[index] + if not fields(job, ["job_id", "assembly_id", "returned_moles", "paid_joules"]): return false + if job.job_id != "chamber-recovery-job-%d" % (index + 1) or not ControlContractValidation.is_id(job.assembly_id) or not number(job.returned_moles) or not integer(job.paid_joules, 0, 25000): return false + if (float(job.returned_moles) == 0.0) != (int(job.paid_joules) == 0): return false + returned += float(job.returned_moles) + paid += int(job.paid_joules) + if job.job_id == value.last_job_id and job.assembly_id == value.last_assembly_id and float(job.returned_moles) >= float(value.last_quantity_moles) and int(job.paid_joules) >= int(value.last_charged_joules): last_found = true + if returned != float(value.returned_moles) or paid != int(value.paid_joules): return false + if int(value.sequence) == 0: return value.last_assembly_id == "" and value.last_job_id == "" and int(value.last_batch_tick) == 0 and float(value.last_quantity_moles) == 0.0 and int(value.last_charged_joules) == 0 and returned == 0.0 and paid == 0 + return last_found and float(value.last_quantity_moles) > 0.0 and int(value.last_charged_joules) > 0 and int(value.sequence) <= paid + + +static func valid(value: Variant, bank: int, tick: int) -> bool: + if not fields(value, ["schema_version", "assemblies", "jobs", "accounting"]) or not integer(value.schema_version, 1, 1): return false + if typeof(value.assemblies) != TYPE_ARRAY or value.assemblies.size() > MAX_ASSEMBLIES or typeof(value.jobs) != TYPE_ARRAY or value.jobs.size() > MAX_JOBS or not valid_accounting(value.accounting, tick): return false + var assemblies: Dictionary = {} + var living: int = 0 + for index in range(value.assemblies.size()): + var assembly: Variant = value.assemblies[index] + if not fields(assembly, ["schema_version", "assembly_id", "item_id", "definition_id", "definition_revision", "blueprint_id", "blueprint_fingerprint", "materials", "principal_id", "body_id", "world_id", "region_id", "chamber_id", "original_condition_permille", "assembly_spent_joules", "state"]): return false + if not integer(assembly.schema_version, 1, 1) or assembly.assembly_id != "chamber-recovery-assembly-%d" % (index + 1) or assembly.item_id != "item-%s" % assembly.assembly_id: return false + if assembly.definition_id != DEFINITION or not integer(assembly.definition_revision, 1, 1) or assembly.blueprint_id != BLUEPRINT or assembly.blueprint_fingerprint != fingerprint() or not _matches_pinned(assembly.materials, BILL): return false + if not integer(assembly.original_condition_permille, 1000, 1000) or not integer(assembly.assembly_spent_joules, ASSEMBLY_JOULES, ASSEMBLY_JOULES) or assembly.state not in ["carried", "mounted", "salvaged"]: return false + for key in ["principal_id", "body_id", "world_id", "region_id", "chamber_id"]: + if not ControlContractValidation.is_id(assembly[key]): return false + assemblies[assembly.assembly_id] = assembly + if assembly.state != "salvaged": living += 1 + if living > 1 or bank < 0 or bank + value.assemblies.size() * ASSEMBLY_JOULES + int(value.accounting.paid_joules) > 100000: return false + if value.accounting.jobs.size() != value.jobs.size(): return false + var active: int = 0 + for index in range(value.jobs.size()): + var job: Variant = value.jobs[index] + if not fields(job, ["schema_version", "job_id", "assembly_id", "principal_id", "body_id", "control_epoch", "initial_moles", "target_moles", "returned_moles", "spent_joules", "max_joules", "state", "last_batch_tick", "next_eligible_tick"]): return false + if not integer(job.schema_version, 1, 1) or job.job_id != "chamber-recovery-job-%d" % (index + 1) or not assemblies.has(job.assembly_id): return false + var assembly: Dictionary = assemblies[job.assembly_id] + if job.principal_id != assembly.principal_id or job.body_id != assembly.body_id or not integer(job.control_epoch): return false + if not number(job.initial_moles) or float(job.initial_moles) <= 0.0 or not number(job.target_moles) or float(job.target_moles) != float(job.initial_moles) * 0.2 or not number(job.returned_moles) or float(job.returned_moles) > float(job.target_moles): return false + if not integer(job.max_joules, 1, 25000) or not integer(job.spent_joules, 0, int(job.max_joules)) or not integer(job.last_batch_tick, 0, tick) or not integer(job.next_eligible_tick, 0, LIMIT): return false + if (float(job.returned_moles) == 0.0) != (int(job.spent_joules) == 0) or (int(job.spent_joules) == 0) != (int(job.last_batch_tick) == 0): return false + if job.state not in ["running", "paused", "completed", "cancelled"] or (job.state == "completed") != (float(job.returned_moles) == float(job.target_moles)): return false + if job.state in ["running", "paused"]: + active += 1 + if assembly.state != "mounted" or int(job.next_eligible_tick) < int(job.last_batch_tick) + 60: return false + # Both records were validated above. Normalize their numeric representation, + # retaining exact joules and exact finite moles, not Dictionary type identity. + if not _matches_pinned(value.accounting.jobs[index], summary(job)): return false + return active <= 1 + + +static func inventory_links(state: Dictionary, inventory: Dictionary) -> bool: + var recovery: Dictionary = state.get("chamber_recovery", initial()) + var active: Dictionary = current(recovery) + var owner: Dictionary = inventory.get("owner", {}) + var items: Array = owner.get("ledger", {}).get("items", []) + var found: int = 0 + if not active.is_empty(): + var matching_body: bool = false + for body in inventory.get("bodies", []): + if body.body_id == active.body_id and body.principal_id == active.principal_id: matching_body = true + if not matching_body: return false + if active.state == "mounted" and not ChamberRecoveryPreparation.supported(state): return false + for job in recovery.jobs: + var valid_binding: bool = false + for body in inventory.get("bodies", []): + if body.body_id == job.body_id and body.principal_id == job.principal_id and int(body.control_epoch) >= int(job.control_epoch): valid_binding = true + if not valid_binding: return false + for item in items: + if item.definition_id != DEFINITION: continue + found += 1 + if active.is_empty() or item.item_id != active.item_id or int(item.definition_revision) != 1 or int(item.condition_permille) > int(active.original_condition_permille): return false + if active.state == "mounted": + if item.container_id != root_id(active.assembly_id): return false + else: + # Carried items may be nested; the real owner decoder has already checked + # the graph. Follow containers without inventing another catalog. + var roots: Dictionary = {} + for container in owner.ledger.containers: roots[container.container_id] = container.owner_item_id + var locations: Dictionary = {} + for physical in items: locations[physical.item_id] = physical.container_id + var root: String = item.container_id + for depth in range(65): + if not roots.has(root): return false + if roots[root] == "": break + if not locations.has(roots[root]) or depth == 64: return false + root = locations[roots[root]] + var matched: bool = false + for body in owner.get("bodies", []): + if body.body_id == active.body_id and body.body_container_id == root: matched = true + if not matched: return false + if found != (0 if active.is_empty() else 1): return false + for assembly in recovery.assemblies: + if state.get("cargo", {}).has(assembly.item_id) or state.get("structures", {}).has(assembly.item_id) or state.get("loose_parts", {}).has(assembly.item_id) or state.get("machinery", {}).get("parts", {}).has(assembly.item_id): return false + var expected: String = root_id(active.assembly_id) if not active.is_empty() and active.state == "mounted" else "" + var roots_found: int = 0 + var locations_found: int = 0 + for container in owner.get("ledger", {}).get("containers", []): + if not String(container.container_id).begins_with("world.chamber-pump-"): continue + if container.container_id != expected or container.owner_item_id != "" or not _matches_pinned(container.capacity, capacity()) or container.get("is_open", true) != true: return false + roots_found += 1 + for location in inventory.get("world_storage", []): + if not String(location.container_id).begins_with("world.chamber-pump-"): continue + if location.container_id != expected or not _matches_pinned(location.position_millimetres, ANCHOR) or location.accessible != false: return false + locations_found += 1 + return roots_found == (1 if expected != "" else 0) and locations_found == roots_found + + +static func denied() -> ServiceResult: + return ServiceResult.failure(&"chamber_recovery_rejected", "Paid chamber recovery provenance, accounting or admission is invalid.") diff --git a/src/core/production/paid_door_geometry.gd b/src/core/production/paid_door_geometry.gd index b5d825ba..6d44be5d 100644 --- a/src/core/production/paid_door_geometry.gd +++ b/src/core/production/paid_door_geometry.gd @@ -38,6 +38,11 @@ static func supported(pose: Dictionary, panels: Dictionary) -> bool: var c: Array = volume["center_millimetres"] var s: Array = volume["size_millimetres"] var bounds: Array[int] = [int(c[0] - s[0] / 2), int(c[0] + s[0] / 2), int(c[2] - s[2] / 2), int(c[2] + s[2] / 2)] + return supported_bounds(bounds, panels) + + +## Exact paid-floor union coverage for an axis-aligned support rectangle. +static func supported_bounds(bounds: Array, panels: Dictionary) -> bool: var xs: Array[int] = [bounds[0], bounds[1]] var zs: Array[int] = [bounds[2], bounds[3]] var floors: Array = [] diff --git a/src/core/production/prepared_chamber_recovery_change.gd b/src/core/production/prepared_chamber_recovery_change.gd new file mode 100644 index 00000000..6444b242 --- /dev/null +++ b/src/core/production/prepared_chamber_recovery_change.gd @@ -0,0 +1,34 @@ +extends RefCounted +class_name PreparedChamberRecoveryChange + +## Server-only decoded publication. No normal intent receipt is consumed on a +## rejection. Every helper, allocation and authorization precedes the first swap. +var _fence: ChamberRecoveryOwnerFence +var _production_after: Dictionary +var _pressure_after: Dictionary +var _inventory_after: Dictionary +var _contexts_after: Dictionary +var _context: AuthenticatedCommandContext +var _mode: String +var _consumed: bool = false + + +func commit(production: ProductionSimulationSystem, pressure: PressureRoomOwner, inventory: InventorySimulationSystem, access: AccessControlSystem) -> ServiceResult: + if _consumed: return ChamberRecoveryRecords.denied() + _consumed = true + if production == null or pressure == null or inventory == null or access == null or _fence == null: return ChamberRecoveryRecords.denied() + var admission: ServiceResult = ChamberRecoveryPreparation.admit(production, _context, _mode) + if not admission.is_success: return admission + var result: ServiceResult = ServiceResult.success() + var taken: ServiceResult = _fence.take(production, pressure, inventory, access) + if not taken.is_success: return taken + # No callbacks, decoding, hashing, validation, signals, restore or allocation + # after this boundary. Access and Inventory are fenced but pure steps do not + # mutate them. Candidates and all nested object graphs were detached/decoded. + pressure.install_prepared_room_state(_pressure_after) + if not _inventory_after.is_empty(): + inventory._owner = _inventory_after.owner + inventory._world_storage = _inventory_after.world_storage + production._state = _production_after + production._chamber_recovery_contexts = _contexts_after + return result diff --git a/src/core/production/production_simulation_system.gd b/src/core/production/production_simulation_system.gd index 0f9ccb7f..d2a8f337 100644 --- a/src/core/production/production_simulation_system.gd +++ b/src/core/production/production_simulation_system.gd @@ -13,6 +13,7 @@ const MAX_VESSEL_PRESSURE_KPA: float = 1000.0 const MAX_RECEIPTS: int = 64 const MAX_REVISION: int = 9007199254740000 var _collision_restore_generation: int = 0 +var _chamber_recovery_contexts: Dictionary = {} var _door_access: AccessControlSystem var _room_pressure: PressureRoomOwner var _checkpoint_door_links: Dictionary = {} @@ -69,12 +70,20 @@ 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 + if not ChamberRecoveryRecords.matches_blueprint(JSON.parse_string(FileAccess.get_file_as_string("res://data/production/chamber-recovery-pump.v1.json"))): return _failure("The immutable chamber recovery bill changed.") _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 + var recovery_guard: ServiceResult = inventory.bind_chamber_recovery_restore_guard(validate_chamber_recovery_inventory_restore) + if not recovery_guard.is_success: return recovery_guard return inventory.bind_payload_mass_provider(payload_mass) +func validate_chamber_recovery_inventory_restore(record: Dictionary) -> ServiceResult: + var state: Dictionary = _state if _checkpoint_door_links.is_empty() else _checkpoint_door_links.normalized_production + return ServiceResult.success() if ChamberRecoveryRecords.inventory_links(state, record) else ChamberRecoveryRecords.denied() + + 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: @@ -92,11 +101,12 @@ func initial_record(tick: int, new_world_outcrop: bool = false, new_world_reinfo func payload_mass(item_id: String) -> int: - return (int(MachineryWorkshop.KINDS[_state["machinery"]["parts"][item_id]["kind"]]) if _state.get("machinery", {}).get("parts", {}).has(item_id) else 0) + _mass(_state.get("loose_parts", {}).get(item_id, {})) + _mass(_state.get("cargo", {}).get(item_id, {})) + _mass(_state.get("structures", {}).get(item_id, {}).get("materials", {})) + return int(ChamberRecoveryRecords.payloads(_state).get(item_id, 0)) + (int(MachineryWorkshop.KINDS[_state["machinery"]["parts"][item_id]["kind"]]) if _state.get("machinery", {}).get("parts", {}).has(item_id) else 0) + _mass(_state.get("loose_parts", {}).get(item_id, {})) + _mass(_state.get("cargo", {}).get(item_id, {})) + _mass(_state.get("structures", {}).get(item_id, {}).get("materials", {})) static func payload_masses(state: Dictionary) -> Dictionary: var masses: Dictionary = cargo_masses(state.get("cargo", {})) + masses.merge(ChamberRecoveryRecords.payloads(state)) for id in state.get("machinery", {}).get("parts", {}): masses[id] = int(masses.get(id, 0)) + int(MachineryWorkshop.KINDS[state["machinery"]["parts"][id]["kind"]]) for id in state.get("loose_parts", {}): masses[id] = int(masses.get(id, 0)) + _mass(state["loose_parts"][id]) for id in state.get("structures", {}): @@ -147,6 +157,7 @@ func validate_inventory_links(record: Dictionary, inventory_record: Dictionary) var validated_owner: ServiceResult = _inventory.validate_payload_masses(payload_masses(record), inventory_record["owner"]) if not validated_owner.is_success: return validated_owner + if not ChamberRecoveryRecords.inventory_links(record, inventory_record): return ChamberRecoveryRecords.denied() var carriers: Dictionary = {} var structures: Dictionary = record.get("structures", {}) var matched: Dictionary = {} @@ -220,6 +231,24 @@ func authorize(context: AuthenticatedCommandContext, voluntary: bool = true) -> return _inventory.production_capability(context, voluntary) +## Server-only detached Phase1 entry points. No external operation allowlist or +## tick scheduler calls these until the transaction prerequisite is reviewed. +func prepare_chamber_recovery_assembly(context: AuthenticatedCommandContext, request_id: String) -> ServiceResult: + return ChamberRecoveryPreparation.assembly(self, context, request_id) + + +func prepare_chamber_recovery_mount(context: AuthenticatedCommandContext, request_id: String) -> ServiceResult: + return ChamberRecoveryPreparation.mount(self, context, request_id) + + +func prepare_chamber_recovery_job(context: AuthenticatedCommandContext) -> ServiceResult: + return ChamberRecoveryPreparation.start(self, context) + + +func prepare_chamber_recovery_step(tick: int) -> ServiceResult: + return ChamberRecoveryPreparation.step(self, tick) + + func authorize_operation(context: AuthenticatedCommandContext, operation: String, payload: Dictionary, request_id: String = "") -> ServiceResult: if operation.begins_with("canister_"): var blueprint: ServiceResult = _timed_blueprints.get_definition(ConstructionReservationPlan.BLUEPRINT) @@ -366,7 +395,8 @@ func observation(context: AuthenticatedCommandContext) -> ServiceResult: observed.erase("room_bypasses") observed.erase("room_reinforcements") 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.erase("chamber_recovery") + 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() @@ -496,6 +526,8 @@ func apply_local(context: AuthenticatedCommandContext, request_id: String, opera if not capacity.is_success: return capacity if not PaidDoorGeometry.valid(candidate["doors"], candidate["panels"]) or not PaidDoorConstruction.clear_installations(candidate["doors"], candidate): return _failure("Construction would remove paid door support or overlap its reserved swing.") + var recovery_assembly: Dictionary = ChamberRecoveryRecords.current(candidate.get("chamber_recovery", {})) + if recovery_assembly.get("state", "") == "mounted" and not ChamberRecoveryPreparation.supported(candidate): return ChamberRecoveryRecords.denied() if _room_pressure != null: var room_links: ServiceResult = _room_pressure.validate_room_links(candidate, _door_access.capture_snapshot().value) if not room_links.is_success: return room_links @@ -1038,6 +1070,7 @@ func collision_restore_generation() -> int: func restore_snapshot_record(record: Dictionary, tick: int) -> ServiceResult: var decoded: ServiceResult = validate_snapshot_record(record, tick) + if decoded.is_success and _checkpoint_door_links.is_empty() and _inventory != null and not ChamberRecoveryRecords.inventory_links(decoded.value, _inventory.capture_snapshot().value): return ChamberRecoveryRecords.denied() if decoded.is_success and _door_access != null: var pair: Dictionary = _door_access.capture_snapshot().value if _checkpoint_door_links.is_empty() else _checkpoint_door_links["access"] if not _checkpoint_door_links.is_empty() and record != _checkpoint_door_links["production"]: return _failure("Restore differs from the validated paired Production candidate.") @@ -1049,6 +1082,11 @@ func restore_snapshot_record(record: Dictionary, tick: int) -> ServiceResult: _state["construction"] = restored.value if not _state["construction"]["jobs"].is_empty(): _state["state_revision"] += 1 _construction_contexts.clear() + _chamber_recovery_contexts.clear() + for recovery_job in _state.get("chamber_recovery", {}).get("jobs", []): + if recovery_job.state == "running": + recovery_job.state = "paused" + _state["state_revision"] += 1 for job in _state["machinery"]["jobs"].values(): job["paused"] = true if _state["job"].has("extraction"): _state["job"]["extraction"]["status"] = "paused" @@ -1060,24 +1098,26 @@ 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, 15): + if ControlContractValidation.is_integer(record.get("schema_version"), 2, 16): fields.append("cargo") - if ControlContractValidation.is_integer(record.get("schema_version"), 3, 15): + if ControlContractValidation.is_integer(record.get("schema_version"), 3, 16): fields.append("structures") - 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 ControlContractValidation.is_integer(record.get("schema_version"), 4, 16): fields.append("loose_parts") + if ControlContractValidation.is_integer(record.get("schema_version"), 6, 16): fields.append("machinery") + if ControlContractValidation.is_integer(record.get("schema_version"), 7, 16): fields.append("walls") + if ControlContractValidation.is_integer(record.get("schema_version"), 8, 16): fields.append_array(["construction", "construction_bindings", "construction_commands"]) + if ControlContractValidation.is_integer(record.get("schema_version"), 9, 16): fields.append("panels") + if ControlContractValidation.is_integer(record.get("schema_version"), 10, 16): fields.append("doors") + if ControlContractValidation.is_integer(record.get("schema_version"), 13, 16): fields.append("room_bypasses") + if ControlContractValidation.is_integer(record.get("schema_version"), 14, 16): fields.append("room_reinforcements") + if ControlContractValidation.is_integer(record.get("schema_version"), 15, 16): fields.append("chamber_kits") + if record.get("schema_version") == 16: fields.append("chamber_recovery") 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, 15) or not ControlContractValidation.is_integer(record.catalog_revision, 1, 4): return _failure("Unsupported production/catalog version.") + if not ControlContractValidation.is_integer(record.schema_version, 1, 16) or not ControlContractValidation.is_integer(record.catalog_revision, 1, 4): return _failure("Unsupported production/catalog version.") var revision: int = int(record.catalog_revision) - var chamber_origin: bool = int(record.schema_version) == 15 + if int(record.schema_version) == 16 and revision != 4: return ChamberRecoveryRecords.denied() + 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 @@ -1085,7 +1125,7 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: 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, 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))): + if not ControlContractValidation.is_integer(record["schema_version"], 1, 16) 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.") @@ -1100,7 +1140,9 @@ 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"] = 15 if chamber_origin else (14 if reinforced else (13 if int(record["schema_version"]) == 13 else (12 if expanded else 11))) + normalized["schema_version"] = 16 if int(record.schema_version) == 16 else (15 if chamber_origin else (14 if reinforced else (13 if int(record["schema_version"]) == 13 else (12 if expanded else 11)))) + if int(record.schema_version) == 16: + if not ChamberRecoveryRecords.valid(record.chamber_recovery, int(record.energy_joules), tick): return ChamberRecoveryRecords.denied() 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.") @@ -1110,6 +1152,9 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: if not normalized["construction"]["jobs"].is_empty() and int(normalized["timebase_hz"]) != 60: return _failure("Timed canister checkpoints require 60 Hz.") if not ConstructionReservationPlan.restore(normalized["construction"], int(normalized["store"]["iron"])).is_success: return _failure("Construction cannot safely pause for restore.") if not normalized["construction"]["jobs"].is_empty() and int(normalized["state_revision"]) >= MAX_REVISION: return _failure("Restore needs revision capacity to pause construction.") + var recovery_job: Dictionary = ChamberRecoveryRecords.active_job(normalized.get("chamber_recovery", {})) + var pause_revisions: int = (0 if normalized["construction"]["jobs"].is_empty() else 1) + (1 if recovery_job.get("state", "") == "running" else 0) + if int(normalized["state_revision"]) > MAX_REVISION - pause_revisions: return _failure("Restore needs revision capacity to pause retained work.") if normalized["construction_commands"].size() > 128: return _failure("Construction command history exceeds bounds.") for id in normalized["construction_commands"]: var receipt: Variant = normalized["construction_commands"][id] @@ -1148,7 +1193,7 @@ func _decode(record: Dictionary, tick: int) -> ServiceResult: if typeof(structures) != TYPE_DICTIONARY or structures.size() > 32: return _failure("Structural equipment records must be bounded.") normalized["structures"] = {} - 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 + var structural_mass: int = ChamberRecoveryRecords.mass(normalized) + 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.") @@ -1268,6 +1313,11 @@ func authorize_door(context: AuthenticatedCommandContext, payload: Dictionary) - func _apply_door(context: AuthenticatedCommandContext, request: String, operation: String, payload: Dictionary, tick: int, fingerprint: String) -> ServiceResult: + var recovery: Dictionary = _state.get("chamber_recovery", {}) + var active_assembly: Dictionary = ChamberRecoveryRecords.current(recovery) + var active_recovery: Dictionary = ChamberRecoveryRecords.active_job(recovery) + if not active_assembly.is_empty() and operation in ["door_chamber_decommission", "door_chamber_kit_recover"]: return ChamberRecoveryRecords.denied() + if not active_recovery.is_empty() and active_recovery.state == "running" and operation == "door_bypass_open": return ChamberRecoveryRecords.denied() var candidate: Dictionary = _state.duplicate(true) var before: Dictionary = {} if _room_pressure != null: @@ -1526,6 +1576,8 @@ static func source_ids(revision: int) -> Array[String]: func _admit_paid_open(door_id: String, context: AuthenticatedCommandContext) -> ServiceResult: + var recovery_job: Dictionary = ChamberRecoveryRecords.active_job(_state.get("chamber_recovery", {})) + if not recovery_job.is_empty() and recovery_job.state == "running": return ChamberRecoveryRecords.denied() 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) diff --git a/tests/godot/chamber_recovery_owner_fence_test.gd b/tests/godot/chamber_recovery_owner_fence_test.gd new file mode 100644 index 00000000..66615089 --- /dev/null +++ b/tests/godot/chamber_recovery_owner_fence_test.gd @@ -0,0 +1,88 @@ +extends SceneTree + +var failures: int = 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + push_error(message) + + +func snapshots(owners: Array) -> Array: + var result: Array = [] + for owner in owners: + var snapshot: ServiceResult = owner.capture_snapshot() + check(snapshot.is_success, "real owner snapshot available") + result.append(snapshot.value.duplicate(true) if snapshot.is_success else {}) + return result + + +func run() -> void: + for mutation in ["none", "bank", "gas", "valve", "access", "production_restore", "pressure_restore", "inventory_restore", "access_restore", "identity"]: + var world: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(world) + check(world.start_simulation().is_success, "real factory starts for " + mutation) + world.get_node("SimulationRunner").set_physics_process(false) + var production: ProductionSimulationSystem = world.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var pressure: PressureSystem = world.get_simulation_system(PressureSystem.SYSTEM_ID) + var inventory: InventorySimulationSystem = world.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var access: AccessControlSystem = world.get_simulation_system(AccessControlSystem.SYSTEM_ID) + var owners: Array = [production, pressure, inventory, access] + var captured: ServiceResult = ChamberRecoveryOwnerFence.capture(production, pressure, inventory, access) + check(captured.is_success, "capture real owner fence") + if not captured.is_success: + world.free() + continue + var fence: ChamberRecoveryOwnerFence = captured.value + var baseline: Array = snapshots(owners) + # Isolated failure injection mutates real owners, without runtime fault hooks. + match mutation: + "bank": + var changed: Dictionary = baseline[0].duplicate(true) + changed.energy_joules -= 1 + check(production.restore_snapshot_record(changed, int(changed.last_tick)).is_success, "real Production accepts lower finite bank") + "gas": + (pressure.volumes[PressureSystem.PROTOTYPE_HABITAT_ID] as PressureVolumeState).gas_moles -= 0.1 + "valve": + (pressure.airlocks[&"habitat_airlock"] as AirlockState).equalization_valve_open = true + "access": + var device: AccessControlledDeviceState = access.devices[&"habitat_airlock"] + device.actuator(&"habitat_airlock-inner-door").position = 1.0 + "production_restore": check(production.restore_snapshot_record(baseline[0], int(baseline[0].last_tick)).is_success, "Production restore succeeds") + "pressure_restore": check(pressure.restore_snapshot_record(baseline[1], 0).is_success, "identical Pressure restore succeeds") + "inventory_restore": check(inventory.restore_snapshot_record(baseline[2], 0).is_success, "identical Inventory restore succeeds") + "access_restore": check(access.restore_snapshot_record(baseline[3], 0).is_success, "identical Access restore succeeds") + var immediately_before: Array = snapshots(owners) + var other_pressure: PressureSystem = PressureSystem.new() if mutation == "identity" else pressure + var taken: ServiceResult = fence.take(production, other_pressure, inventory, access) + check(taken.is_success == (mutation == "none"), "only unchanged exact owners pass: " + mutation) + check(snapshots(owners) == immediately_before, "take changes no canonical records or receipts: " + mutation) + check(not fence.take(production, pressure, inventory, access).is_success, "both success and rejection consume only the ephemeral fence: " + mutation) + check(snapshots(owners) == immediately_before, "second take also changes no canonical state: " + mutation) + if other_pressure != pressure: other_pressure.free() + world.free() + var exhausted: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(exhausted) + var production: ProductionSimulationSystem = exhausted.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var pressure: PressureSystem = exhausted.get_simulation_system(PressureSystem.SYSTEM_ID) + var inventory: InventorySimulationSystem = exhausted.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var access: AccessControlSystem = exhausted.get_simulation_system(AccessControlSystem.SYSTEM_ID) + # Test-only counter seeding checks the actual successful-restore increment and + # fail-closed terminal value, without adding runtime injection interfaces. + production._collision_restore_generation = ProductionSimulationSystem.MAX_REVISION - 1 + pressure._room_restore_generation = ChamberRecoveryRecords.LIMIT - 1 + inventory._controller_restore_generation = InventorySimulationSystem.MAX_EXACT_INTEGER - 1 + access._restore_generation = ChamberRecoveryRecords.LIMIT - 1 + var owners: Array = [production, pressure, inventory, access] + for owner in owners: + var before: Dictionary = owner.capture_snapshot().value.duplicate(true) + check(owner.restore_snapshot_record(before, 0).is_success, "last representable restore advances its real fence") + check(not owner.validate_snapshot_record(before, 0).is_success, "exhausted fence rejects whole-world prevalidation") + check(not owner.restore_snapshot_record(before, 0).is_success and owner.capture_snapshot().value == before, "exhausted restore cannot wrap or mutate canonical records") + exhausted.free() + quit(failures) diff --git a/tests/godot/chamber_recovery_transaction_test.gd b/tests/godot/chamber_recovery_transaction_test.gd new file mode 100644 index 00000000..2e02d868 --- /dev/null +++ b/tests/godot/chamber_recovery_transaction_test.gd @@ -0,0 +1,400 @@ +extends "res://tests/godot/paid_chamber_test.gd" + + +func run() -> void: + pinned_catalog_controls() + if failures: finish(); return + await super.run() + + +func pinned_catalog_controls() -> void: + var definition: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/inventory/chamber-recovery-pump.v1.json")) + var blueprint: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/production/chamber-recovery-pump.v1.json")) + check(ChamberRecoveryRecords.matches_definition(definition) and ChamberRecoveryRecords.matches_definition(ChamberRecoveryRecords.definition()), "JSON and literal pump definitions both admit exact numeric values") + check(ChamberRecoveryRecords.matches_blueprint(blueprint) and ChamberRecoveryRecords.matches_blueprint(ChamberRecoveryRecords.blueprint()), "JSON and literal bills both admit exact nested numeric values") + for field in ["schema_version", "revision", "mass_milligrams", "volume_millilitres"]: + for wrong in [float(definition[field]) + 0.25, float(definition[field]) + 1.0, str(definition[field]), true, null, INF, NAN]: + var changed: Dictionary = definition.duplicate(true) + changed[field] = wrong + check(not ChamberRecoveryRecords.matches_definition(changed), "definition rejects changed or non-integral pinned quantity: " + field) + for field in ["plate", "iron"]: + for wrong in [float(blueprint.materials_milligrams[field]) + 0.25, 0, "400000", true, null]: + var changed: Dictionary = blueprint.duplicate(true) + changed.materials_milligrams[field] = wrong + check(not ChamberRecoveryRecords.matches_blueprint(changed), "bill rejects changed or non-integral nested quantity: " + field) + var extra: Dictionary = definition.duplicate(true); extra["unsupported"] = 1 + var missing: Dictionary = blueprint.duplicate(true); missing.erase("assembly_joules") + var tags: Dictionary = definition.duplicate(true); tags.tags.reverse() + check(not ChamberRecoveryRecords.matches_definition(extra) and not ChamberRecoveryRecords.matches_blueprint(missing) and not ChamberRecoveryRecords.matches_definition(tags), "pinned fields and ordered tags remain exact") + check(not ChamberRecoveryRecords.matches_definition(null) and not ChamberRecoveryRecords.matches_blueprint([]), "malformed catalog shapes reject") + for carriers in [false, true]: + var fresh_inventory := InventorySimulationSystem.new() + var configured: ServiceResult = fresh_inventory.configure_defaults(carriers) + check(configured.is_success, "actual catalog creates canonical Inventory for both commissioning fixtures") + if configured.is_success: + check(fresh_inventory.capture_snapshot().is_success, "real configured Inventory owner captures before dependent Tools configure") + var fresh_tools := ToolSimulationSystem.new() + check(fresh_tools.configure(fresh_inventory).is_success, "dependent Tools initialize from real Inventory without reordered or fabricated owners") + fresh_tools.free() + fresh_inventory.free() + + +## Positive provenance comes entirely from the existing 39kg acquisition route +## and real detached Inventory/Production candidates. Direct owner mutations +## below are isolated rejection controls, never positive factory shortcuts. +func verify_chamber_extension() -> void: + move([3600, 0, -5500]); move([0, 0, 0]) + var prepared: ServiceResult = production.prepare_chamber_recovery_assembly(context, "paid-recovery-assembly") + check(prepared.is_success, "authentic leftovers prepare paid physical pump in actual body capacity") + if not prepared.is_success: return + check(prepared.value.commit(production, pressure_owner(), inventory, access).is_success, "publish paid assembly with real Inventory") + var paid: Dictionary = production.capture_snapshot().value + check(paid.energy_joules == 27250 and paid.store.plate == 200000 and paid.store.iron == 200000, "exact 600g bill and 1000J debit without new resources") + var assembly: Dictionary = ChamberRecoveryRecords.current(paid.chamber_recovery) + check(production.payload_mass(assembly.item_id) == 600000 and ProductionSimulationSystem.payload_masses(paid)[assembly.item_id] == 600000, "both mass providers count pump provenance once") + check(MachineryWorkshop.item(inventory.capture_snapshot().value.owner, assembly.item_id).container_id == "body.human-1", "paid assembly first occupies actual body inventory") + var before_drop: Dictionary = inventory.capture_snapshot().value.duplicate(true) + var dropped: ServiceResult = inventory.apply_local(context, "generic-pump-drop", "world", {"operation": "drop", "item_id": assembly.item_id, "world_container_id": "world.tray-1", "destination_container_id": "", "slot_id": "", "expected_revision": before_drop.owner.state_revision}, int(before_drop.last_tick) + 1) + check(not dropped.is_success and inventory.capture_snapshot().value == before_drop, "generic world movement cannot detach paid pump ownership or consume a receipt") + move([3600, 0, -5500]); move([5600, 0, -5000]) + var before_mount: Dictionary = inventory.capture_snapshot().value + check(ChamberRecoveryPreparation.admit(production, context, "mount").is_success, "actual paid-route actor pose, reach, tool and chamber authority admit mounting") + check(ChamberRecoveryPreparation.supported(production.capture_snapshot().value), "unchanged paid panels cover the exact mount support rectangle") + if failures: return + prepared = production.prepare_chamber_recovery_mount(context, "paid-recovery-mount") + check(prepared.is_success, "same paid item prepares a supported fixed mount: " + prepared.message) + if not prepared.is_success: return + check(prepared.value.commit(production, pressure_owner(), inventory, access).is_success, "mount candidate commits without replacing item or condition") + var mounted_inventory: Dictionary = inventory.capture_snapshot().value.duplicate(true) + check(mounted_inventory.owner.ledger.schema_version == before_mount.owner.ledger.schema_version, "mount preserves the real ledger version and existing closure contract") + for container in mounted_inventory.owner.ledger.containers: + if container.container_id == ChamberRecoveryRecords.root_id(assembly.assembly_id): + check(container.has("is_open") == (int(before_mount.owner.ledger.schema_version) == 2) and container.get("is_open", true) == true, "mounted root uses the current ledger's exact open-root shape") + var mounted_item: Dictionary = MachineryWorkshop.item(mounted_inventory.owner, assembly.item_id) + var original_item: Dictionary = MachineryWorkshop.item(before_mount.owner, assembly.item_id).duplicate(true) + original_item.container_id = ChamberRecoveryRecords.root_id(assembly.assembly_id) + check(mounted_item == original_item, "mount changes only location of the original paid physical item") + pinned_restore_controls(production.capture_snapshot().value, mounted_inventory, "mounted") + var picked_up: ServiceResult = inventory.apply_local(context, "generic-pump-pickup", "world", {"operation": "pickup", "item_id": assembly.item_id, "world_container_id": ChamberRecoveryRecords.root_id(assembly.assembly_id), "destination_container_id": "body.human-1", "slot_id": "", "expected_revision": mounted_inventory.owner.state_revision}, int(mounted_inventory.last_tick) + 1) + check(not picked_up.is_success and inventory.capture_snapshot().value == mounted_inventory, "mounted item cannot escape through generic pickup") + var mounted_snapshot: String = world.capture_world_snapshot_json().value + for condition in [999, 1, 0]: damaged_admission_case(mounted_snapshot, "start", condition) + prepared = production.prepare_chamber_recovery_job(context) + check(prepared.is_success, "server derives immutable twenty-percent target and finite job cap") + if not prepared.is_success: return + check(prepared.value.commit(production, pressure_owner(), inventory, access).is_success, "publish reciprocal job admission") + var job: Dictionary = ChamberRecoveryRecords.active_job(production.capture_snapshot().value.chamber_recovery) + check(job.max_joules == 25000 and job.spent_joules == 0 and job.returned_moles == 0.0 and job.target_moles == job.initial_moles * 0.2, "client supplies no budget or target") + pinned_restore_controls(production.capture_snapshot().value, inventory.capture_snapshot().value, "admitted") + var baseline: String = world.capture_world_snapshot_json().value + var fixture := FileAccess.open("res://var/chamber-recovery-fixture.json", FileAccess.WRITE) + fixture.store_string(baseline); fixture.close() + for condition in [999, 1, 0]: + damaged_admission_case(baseline, "resume", condition) + damaged_admission_case(baseline, "step", condition) + for mutation in ["none", "bank", "claims", "gas", "valve", "door", "action_epoch", "control_epoch", "pose", "condition", "production_restore", "pressure_restore", "inventory_restore", "access_restore", "failed_restores"]: + transaction_case(baseline, mutation) + for corruption in ["production_only", "pressure_only", "paid_sum", "material_bill", "missing_item", "wrong_root", "unpaid_bank", "future_production", "future_pressure"]: + restore_case(baseline, corruption) + for blocker in ["empty_bank", "job_cap", "timebase", "support", "retired_control"]: + blocked_preparation_case(baseline, blocker) + + +func owner_records(owners: Array) -> Array: + var result: Array = [] + for owner in owners: result.append(owner.capture_snapshot().value.duplicate(true)) + return result + + +func transaction_case(baseline: String, mutation: String) -> void: + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(target) + var restored: ServiceResult = target.restore_world_snapshot_json(baseline) + check(restored.is_success, "reciprocal paid world restores: " + mutation) + if not restored.is_success: target.free(); return + check(target.start_simulation().is_success, "restored finite world starts") + var clock: SimulationRunner = target.get_node("SimulationRunner") + clock.set_physics_process(false) + var p: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var g: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) + var i: InventorySimulationSystem = target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var a: AccessControlSystem = target.get_simulation_system(AccessControlSystem.SYSTEM_ID) + var actor := AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + var owners: Array = [p, g, i, a] + var paused: Dictionary = ChamberRecoveryRecords.active_job(p.capture_snapshot().value.chamber_recovery) + check(paused.state == "paused", "reload parks running job without debit") + var resume: ServiceResult = p.prepare_chamber_recovery_job(actor) + check(resume.is_success, "fresh authority resumes original budget and target") + if not resume.is_success: target.free(); return + check(resume.value.commit(p, g, i, a).is_success, "resume reciprocal candidates") + var ineligible: Array = owner_records(owners) + check(not p.prepare_chamber_recovery_step(clock.tick_number).is_success and owner_records(owners) == ineligible, "resume cannot bypass sixty-tick eligibility") + for tick in range(60): clock._physics_process(1.0 / 60.0) + var step_candidate: ServiceResult = p.prepare_chamber_recovery_step(clock.tick_number) + check(step_candidate.is_success, "eligible real-owner batch prepares: " + mutation) + if not step_candidate.is_success: target.free(); return + var valid_before: Array = owner_records(owners) + var initial_job: Dictionary = ChamberRecoveryRecords.active_job(valid_before[0].chamber_recovery) + var chamber_id := StringName(g.paid_chamber.volume_id) + var price: ServiceResult = ChamberRecoveryWork.prepare(g.volumes[chamber_id], g.volumes[PressureSystem.PROTOTYPE_HABITAT_ID], minf(0.1, initial_job.target_moles - initial_job.returned_moles)) + var source_before: float = (g.volumes[chamber_id] as PressureVolumeState).gas_moles + var receiver_before: float = (g.volumes[PressureSystem.PROTOTYPE_HABITAT_ID] as PressureVolumeState).gas_moles + match mutation: + "bank": p._state.energy_joules -= 1 + "claims": p._state.construction["fence-negative"] = {"claim": 1} + "gas": (g.volumes[chamber_id] as PressureVolumeState).gas_moles -= 0.01 + "valve": g.room_valves[g.paid_chamber.inner_door_id] = true + "door": a.devices[StringName(g.paid_chamber.inner_door_id)].actuator(PaidDoorConstruction.actuator_id(g.paid_chamber.inner_door_id)).position = 1.0 + "action_epoch": i._bodies[initial_job.body_id].action_epoch += 1 + "control_epoch": i._bodies[initial_job.body_id].control_epoch += 1 + "pose": i._bodies[initial_job.body_id].position_millimetres[0] += 1 + "condition": + var inventory_record: Dictionary = i.capture_snapshot().value + for item in inventory_record.owner.ledger.items: + if item.item_id == ChamberRecoveryRecords.current(p._state.chamber_recovery).item_id: item.condition_permille -= 1 + check(i.restore_snapshot_record(inventory_record, clock.tick_number).is_success, "real Inventory accepts lower retained item condition") + "production_restore": check(p.restore_snapshot_record(valid_before[0], clock.tick_number).is_success, "same Production restore retires preparation") + "pressure_restore": check(g.restore_snapshot_record(valid_before[1], clock.tick_number).is_success, "identical Pressure restore retires preparation") + "inventory_restore": check(i.restore_snapshot_record(valid_before[2], clock.tick_number).is_success, "identical Inventory restore retires preparation") + "access_restore": check(a.restore_snapshot_record(valid_before[3], clock.tick_number).is_success, "identical Access restore retires preparation") + "failed_restores": + for index in range(owners.size()): + var invalid: Dictionary = valid_before[index].duplicate(true) + invalid.schema_version = 999 + check(not owners[index].restore_snapshot_record(invalid, clock.tick_number).is_success, "failed restore rejects before any owner write or fence increment") + var before_commit: Array = owner_records(owners) + var committed: ServiceResult = step_candidate.value.commit(p, g, i, a) + var success_expected: bool = mutation in ["none", "failed_restores"] + check(committed.is_success == success_expected, "publication accepts only unchanged owners: " + mutation) + var after: Array = owner_records(owners) + if success_expected: + check(int(after[0].energy_joules) == int(before_commit[0].energy_joules) - int(price.value.charged_joules), "real bank charged computed work exactly once") + check(after[0].chamber_recovery.accounting == after[1].pump_accounting and after[0].chamber_recovery.accounting.sequence == 1, "identical reciprocal audit published once") + check(after[2] == before_commit[2] and after[3] == before_commit[3], "gas batch leaves Inventory and Access unchanged") + var source_after: float = (g.volumes[chamber_id] as PressureVolumeState).gas_moles + var receiver_after: float = (g.volumes[PressureSystem.PROTOTYPE_HABITAT_ID] as PressureVolumeState).gas_moles + check(absf(source_before - source_after - 0.1) < 1e-10 and absf(receiver_after - receiver_before - 0.1) < 1e-10, "real owners transfer precisely the admitted quantity") + check(absf(source_after + receiver_after - source_before - receiver_before) < 1e-8, "real-owner publication conserves total bulk moles") + else: check(after == before_commit, "rejection leaves every owner and normal receipt unchanged: " + mutation) + check(not step_candidate.value.commit(p, g, i, a).is_success and owner_records(owners) == after, "successful and rejected prepared objects are single-use: " + mutation) + if mutation == "none" and committed.is_success: charged_reload_case(target, after) + target.free() + + +func charged_reload_case(charged_world: WorldRuntime, charged: Array) -> void: + pinned_restore_controls(charged[0], charged[2], "charged") + var saved: ServiceResult = charged_world.capture_world_snapshot_json() + check(saved.is_success, "whole-world capture accepts genuinely charged reciprocal batch") + if not saved.is_success: return + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(target) + var restored: ServiceResult = target.restore_world_snapshot_json(saved.value) + check(restored.is_success, "whole-world reload accepts genuinely charged batch") + if not restored.is_success: target.free(); return + var p: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var g: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) + var i: InventorySimulationSystem = target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var a: AccessControlSystem = target.get_simulation_system(AccessControlSystem.SYSTEM_ID) + var owners: Array = [p, g, i, a] + var expected: Array = charged.duplicate(true) + var original_job: Dictionary = ChamberRecoveryRecords.active_job(charged[0].chamber_recovery) + var expected_job: Dictionary = ChamberRecoveryRecords.active_job(expected[0].chamber_recovery) + expected_job.state = "paused" + expected[0].state_revision += 1 + # Compare complete JSON values after the same lossless JSON numeric decode; + # each owner may normalize its own integral runtime fields independently. + check(json_values(owner_records(owners)) == json_values(expected), "charged reload changes only running-to-paused state and its Production revision; gas, J, audit and physical identity persist") + var parked: Dictionary = ChamberRecoveryRecords.active_job(p.capture_snapshot().value.chamber_recovery) + check(parked.spent_joules > 0 and parked.returned_moles == 0.1 and p.capture_snapshot().value.energy_joules < 27250, "charged finite budget and returned quantity survive reload") + check(p.capture_snapshot().value.chamber_recovery.accounting == g.capture_snapshot().value.pump_accounting, "charged reciprocal audit remains identical after load") + for volume in g.volumes.values(): check(is_finite(volume.gas_moles) and volume.gas_moles >= 0.0, "reloaded gas remains finite and nonnegative") + check(target.start_simulation().is_success, "charged restored world starts") + var clock: SimulationRunner = target.get_node("SimulationRunner") + clock.set_physics_process(false) + var before_resume: Array = owner_records(owners) + check(not p.prepare_chamber_recovery_step(clock.tick_number).is_success and owner_records(owners) == before_resume, "charged reload cannot run before fresh resume") + var actor := AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + var resumed: ServiceResult = p.prepare_chamber_recovery_job(actor) + check(resumed.is_success, "fresh authority can resume a charged job") + if not resumed.is_success: target.free(); return + check(resumed.value.commit(p, g, i, a).is_success, "charged job resume publishes without a new cap or debit") + var continued: Dictionary = ChamberRecoveryRecords.active_job(p.capture_snapshot().value.chamber_recovery) + for field in ["job_id", "assembly_id", "initial_moles", "target_moles", "max_joules", "spent_joules", "returned_moles", "last_batch_tick"]: + check(continued[field] == original_job[field], "charged resume retains immutable identity/accounting: " + field) + check(continued.next_eligible_tick == clock.tick_number + 60, "charged resume starts a fresh sixty-tick interval") + for tick in range(59): clock._physics_process(1.0 / 60.0) + var too_soon: Array = owner_records(owners) + check(not p.prepare_chamber_recovery_step(clock.tick_number).is_success and owner_records(owners) == too_soon, "charged reload cannot catch up before sixty fresh ticks") + clock._physics_process(1.0 / 60.0) + check(p.prepare_chamber_recovery_step(clock.tick_number).is_success, "charged resumed job becomes eligible after exactly sixty fresh ticks") + target.free() + + +func json_values(value: Variant) -> Variant: + return JSON.parse_string(JSON.stringify(value, "", true, true)) + + +func wrong_pinned_quantities(expected: Variant) -> Array: + return [float(expected) + 0.25, float(expected) + 1.0, str(expected), true, null, INF, NAN] + + +## Detached validation controls copied only from the real paid/mounted/charged +## route. They grant no resources, items or initialized owner fixtures. +func pinned_restore_controls(state: Dictionary, physical: Dictionary, stage: String) -> void: + var decoded: Dictionary = json_values(state) + var inventory_record: Dictionary = json_values(physical) + var bank: int = int(decoded.energy_joules) + var tick: int = int(decoded.last_tick) + check(ChamberRecoveryRecords.valid(decoded.chamber_recovery, bank, tick), "complete recovery JSON roundtrip preserves exact bill/accounting: " + stage) + check(ChamberRecoveryRecords.inventory_links(decoded, inventory_record), "real mounted identity, capacity and anchor survive JSON roundtrip: " + stage) + for field in ["plate", "iron"]: + for wrong in wrong_pinned_quantities(decoded.chamber_recovery.assemblies[0].materials[field]): + var changed: Dictionary = decoded.chamber_recovery.duplicate(true) + changed.assemblies[0].materials[field] = wrong + check(not ChamberRecoveryRecords.valid(changed, bank, tick), "restore rejects altered/fractional/wrong-type paid bill: " + field) + for shape in [[], {"plate": 400000}, {"plate": 400000, "iron": 200000, "extra": 0}]: + var changed: Dictionary = decoded.chamber_recovery.duplicate(true) + changed.assemblies[0].materials = shape + check(not ChamberRecoveryRecords.valid(changed, bank, tick), "restore rejects wrong bill shape") + var assembly: Dictionary = ChamberRecoveryRecords.current(decoded.chamber_recovery) + for container in inventory_record.owner.ledger.containers: + if container.container_id != ChamberRecoveryRecords.root_id(assembly.assembly_id): continue + for field in ["max_mass_milligrams", "max_volume_millilitres", "max_items"]: + for wrong in wrong_pinned_quantities(container.capacity[field]): + var original: Variant = container.capacity[field] + container.capacity[field] = wrong + check(not ChamberRecoveryRecords.inventory_links(decoded, inventory_record), "restore rejects changed/fractional/wrong-type mount capacity: " + field) + container.capacity[field] = original + var capacity: Dictionary = container.capacity.duplicate(true) + for shape in [[], {}, {"max_mass_milligrams": 600000, "max_volume_millilitres": 6000, "max_items": 1, "accepted_tags": ["wrong"]}, capacity.merged({"extra": 0})]: + container.capacity = shape + check(not ChamberRecoveryRecords.inventory_links(decoded, inventory_record), "restore rejects wrong capacity shape/tags") + container.capacity = capacity + for location in inventory_record.world_storage: + if location.container_id != ChamberRecoveryRecords.root_id(assembly.assembly_id): continue + var position: Array = location.position_millimetres.duplicate() + for index in range(3): + for wrong in wrong_pinned_quantities(position[index]): + location.position_millimetres[index] = wrong + check(not ChamberRecoveryRecords.inventory_links(decoded, inventory_record), "restore rejects changed/fractional/wrong-type anchor coordinate") + location.position_millimetres[index] = position[index] + for shape in [[], [5600, 0], [5600, 0, -4400, 0], {"x": 5600, "y": 0, "z": -4400}]: + location.position_millimetres = shape + check(not ChamberRecoveryRecords.inventory_links(decoded, inventory_record), "restore rejects wrong anchor shape") + location.position_millimetres = position + if decoded.chamber_recovery.jobs.is_empty(): return + var job: Dictionary = decoded.chamber_recovery.jobs[0] + job.spent_joules = int(job.spent_joules) + check(ChamberRecoveryRecords.valid(decoded.chamber_recovery, bank, tick), "validated float audit matches integral job spend exactly") + job.spent_joules = float(job.spent_joules) + decoded.chamber_recovery.accounting.jobs[0].paid_joules = int(job.spent_joules) + if float(job.returned_moles) == 0.0: decoded.chamber_recovery.accounting.jobs[0].returned_moles = 0 + check(ChamberRecoveryRecords.valid(decoded.chamber_recovery, bank, tick), "validated integer audit quantities match decoded job numbers exactly") + for field in ["paid_joules", "returned_moles"]: + for wrong in wrong_pinned_quantities(decoded.chamber_recovery.accounting.jobs[0][field]): + var changed: Dictionary = decoded.chamber_recovery.duplicate(true) + changed.accounting.jobs[0][field] = wrong + # Preserve the corresponding aggregate so an otherwise valid changed + # quantity must still agree with the immutable job's own summary. + if typeof(wrong) in [TYPE_INT, TYPE_FLOAT] and is_finite(float(wrong)): changed.accounting[field] = wrong + check(not ChamberRecoveryRecords.valid(changed, bank, tick), "summary rejects changed or wrong-type numeric accounting: " + field) + var extra: Dictionary = decoded.chamber_recovery.duplicate(true) + extra.accounting.jobs[0]["extra"] = 0 + check(not ChamberRecoveryRecords.valid(extra, bank, tick), "summary shape remains exact") + + +## Damage is installed through the real Inventory restore before preparing any +## start/resume/step. This exercises admission independently of stale-CAS tests. +func damaged_admission_case(baseline: String, stage: String, condition: int) -> void: + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(target) + var restored: ServiceResult = target.restore_world_snapshot_json(baseline) + check(restored.is_success, "damage control restores authentic paid provenance") + if not restored.is_success: target.free(); return + check(target.start_simulation().is_success, "damage admission world starts") + var clock: SimulationRunner = target.get_node("SimulationRunner") + clock.set_physics_process(false) + var p: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var g: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) + var i: InventorySimulationSystem = target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var a: AccessControlSystem = target.get_simulation_system(AccessControlSystem.SYSTEM_ID) + var actor := AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + if stage == "step": + var resumed: ServiceResult = p.prepare_chamber_recovery_job(actor) + check(resumed.is_success, "undamaged control resumes before damage") + if not resumed.is_success: target.free(); return + check(resumed.value.commit(p, g, i, a).is_success, "undamaged control admits running job") + for tick in range(60): clock._physics_process(1.0 / 60.0) + check(p.prepare_chamber_recovery_step(clock.tick_number).is_success, "undamaged eligible control can prepare before item damage") + var physical: Dictionary = i.capture_snapshot().value + var assembly: Dictionary = ChamberRecoveryRecords.current(p.capture_snapshot().value.chamber_recovery) + for item in physical.owner.ledger.items: + if item.item_id == assembly.item_id: item.condition_permille = condition + check(i.restore_snapshot_record(physical, clock.tick_number).is_success, "real owner retains damaged pump without healing") + var owners: Array = [p, g, i, a] + var before: Array = owner_records(owners) + var attempted: ServiceResult = p.prepare_chamber_recovery_step(clock.tick_number) if stage == "step" else p.prepare_chamber_recovery_job(actor) + check(not attempted.is_success, "damaged item rejects before preparation: %s condition%d" % [stage, condition]) + check(owner_records(owners) == before, "damage admission rejection preserves all owners, gas/J and normal receipts") + var retained: Dictionary = MachineryWorkshop.item(i.capture_snapshot().value.owner, assembly.item_id) + check(retained.condition_permille == condition and retained.item_id == assembly.item_id, "rejection retains the same damaged item and condition") + target.free() + + +func restore_case(baseline: String, corruption: String) -> void: + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(target) + var before: String = target.capture_world_snapshot_json().value + var record: Dictionary = JSON.parse_string(baseline) + var p: Dictionary = record.systems.production + var g: Dictionary = record.systems.pressure + match corruption: + "production_only": g.erase("pump_accounting"); g.schema_version = 5 + "pressure_only": p.erase("chamber_recovery"); p.schema_version = 15 + "paid_sum": g.pump_accounting.paid_joules += 1 + "material_bill": p.chamber_recovery.assemblies[0].materials.iron += 1 + "missing_item": record.systems.inventory.owner.ledger.items = record.systems.inventory.owner.ledger.items.filter(func(item: Dictionary) -> bool: return item.definition_id != ChamberRecoveryRecords.DEFINITION) + "wrong_root": + for location in record.systems.inventory.world_storage: + if String(location.container_id).begins_with("world.chamber-pump-"): location.position_millimetres[0] += 1 + "unpaid_bank": p.energy_joules = 100000 + "future_production": p.schema_version = 17 + "future_pressure": g.schema_version = 7 + check(not target.restore_world_snapshot_json(JSON.stringify(record)).is_success, "forged/one-sided candidate rejected: " + corruption) + check(target.capture_world_snapshot_json().value == before, "rejected whole-world restore changes no owner: " + corruption) + target.free() + + +## Negative-only owner substitutions isolate admission/budget branches. They do +## not provide successful route provenance or create a production fault hook. +func blocked_preparation_case(baseline: String, blocker: String) -> void: + var target: WorldRuntime = FirstPlayableBootstrap.create_playable_world() + root.add_child(target) + var restored: ServiceResult = target.restore_world_snapshot_json(baseline) + check(restored.is_success, "blocked preparation starts from genuine paid snapshot") + if not restored.is_success: target.free(); return + check(target.start_simulation().is_success, "blocked preparation world starts") + var clock: SimulationRunner = target.get_node("SimulationRunner") + clock.set_physics_process(false) + var p: ProductionSimulationSystem = target.get_simulation_system(ProductionSimulationSystem.SYSTEM_ID) + var g: PressureSystem = target.get_simulation_system(PressureSystem.SYSTEM_ID) + var i: InventorySimulationSystem = target.get_simulation_system(InventorySimulationSystem.SYSTEM_ID) + var a: AccessControlSystem = target.get_simulation_system(AccessControlSystem.SYSTEM_ID) + var actor := AuthenticatedCommandContext.for_local_development(context.principal_id, target.world_id, target.region_id) + var resume: ServiceResult = p.prepare_chamber_recovery_job(actor) + check(resume.is_success, "blocked case resumes existing job") + if not resume.is_success: target.free(); return + check(resume.value.commit(p, g, i, a).is_success, "blocked case retains original target and cap") + for tick in range(60): clock._physics_process(1.0 / 60.0) + var job: Dictionary = ChamberRecoveryRecords.active_job(p._state.chamber_recovery) + match blocker: + "empty_bank": p._state.energy_joules = 0 + "job_cap": job.max_joules = 1 + "timebase": p._state.timebase_hz = 72 + "support": p._state.panels.clear() + "retired_control": i._bodies[job.body_id].control_epoch += 1 + var owners: Array = [p, g, i, a] + var before: Array = owner_records(owners) + check(not p.prepare_chamber_recovery_step(clock.tick_number).is_success, "ineligible backend batch rejects: " + blocker) + check(owner_records(owners) == before, "budget/admission stall changes no owner or normal receipt: " + blocker) + target.free() diff --git a/tests/godot/chamber_recovery_work_test.gd b/tests/godot/chamber_recovery_work_test.gd new file mode 100644 index 00000000..b7d1733d --- /dev/null +++ b/tests/godot/chamber_recovery_work_test.gd @@ -0,0 +1,82 @@ +extends SceneTree + +var failures: int = 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + push_error(message) + + +func run() -> void: + var source := PressureVolumeState.from_pressure(&"source", 3.6, 293.15, 101.325) + var receiver := PressureVolumeState.from_pressure(&"receiver", 996.4, 293.15, 101.325) + var before: Array = [source.to_record(), receiver.to_record()] + var first: ServiceResult = ChamberRecoveryWork.prepare(source, receiver, 0.1) + check(first.is_success and first.value.charged_joules >= 1, "equal initial pressure still charges endpoint compression work") + for quantity in [0.0, -0.1, 0.100001, INF, NAN, 1e-30]: + check(not ChamberRecoveryWork.prepare(source, receiver, quantity).is_success, "invalid or unrepresentable quantity rejected") + check([source.to_record(), receiver.to_record()] == before, "successful and rejected proposals leave both live volumes unchanged") + check(not ChamberRecoveryWork.prepare(source, source, 0.1).is_success, "same volume cannot be source and receiver") + var unequal := PressureVolumeState.from_pressure(&"unequal", 996.4, 293.150001, 101.325) + check(not ChamberRecoveryWork.prepare(source, unequal, 0.1).is_success, "temperatures must match exactly") + var high := PressureVolumeState.from_pressure(&"high", 3.6, 293.15, 109.0) + var low := PressureVolumeState.from_pressure(&"low", 996.4, 293.15, 1.0) + var downhill: ServiceResult = ChamberRecoveryWork.prepare(high, low, 0.1) + check(downhill.is_success and downhill.value.charged_joules == 1, "downhill work costs one joule with no energy credit") + var below_floor := PressureVolumeState.from_pressure(&"below", 3.6, 293.15, 1.0) + check(not ChamberRecoveryWork.prepare(below_floor, receiver, 0.1).is_success, "source after-state below one kPa rejected") + var full := PressureVolumeState.from_pressure(&"full", 996.4, 293.15, 110.0) + check(not ChamberRecoveryWork.prepare(source, full, 0.1).is_success, "receiver after-state above 110 kPa rejected") + var ratio_source := PressureVolumeState.from_pressure(&"ratio", 3.6, 293.15, 5.0) + check(not ChamberRecoveryWork.prepare(ratio_source, receiver, 0.00001).is_success, "endpoint ratio above sixteen rejected independently of work cap") + var expensive := PressureVolumeState.from_pressure(&"expensive", 3.6, 293.15, 10.0) + check(not ChamberRecoveryWork.prepare(expensive, receiver, 0.1).is_success, "work above 250 joules rejected even below ratio limit") + # Binary-exact mole increments exercise inclusive pressure endpoints without + # adding a tolerance to production admission. + var unit_volume: float = PressureVolumeState.GAS_CONSTANT_J_PER_MOL_K * 293.15 / 1000.0 + var small: float = 1.0 / 1024.0 + var edge_source := PressureVolumeState.new(&"edge-source", unit_volume, 293.15, 1.0 + small) + var edge_receiver := PressureVolumeState.new(&"edge-receiver", unit_volume, 293.15, 16.0 - small) + check(ChamberRecoveryWork.prepare(edge_source, edge_receiver, small).is_success, "inclusive one-kPa source and ratio-sixteen endpoints") + edge_receiver.gas_moles += small + check(not ChamberRecoveryWork.prepare(edge_source, edge_receiver, small).is_success, "just above ratio-sixteen rejected") + edge_source.gas_moles = 110.0 + small + edge_receiver.gas_moles = 110.0 - small + check(ChamberRecoveryWork.prepare(edge_source, edge_receiver, small).is_success, "inclusive 110-kPa receiver endpoint") + edge_receiver.gas_moles += small + check(not ChamberRecoveryWork.prepare(edge_source, edge_receiver, small).is_success, "just above receiver cap rejected") + for work in [79.9999999, 80.0000001, 248.9999999, 249.0000001]: + var quantity: float = 0.1 + var ratio: float = exp(work * 0.35 / (quantity * PressureVolumeState.GAS_CONSTANT_J_PER_MOL_K * 293.15)) + var priced_source := PressureVolumeState.new(&"priced-source", unit_volume, 293.15, 10.0 + quantity) + var priced_receiver := PressureVolumeState.new(&"priced-receiver", unit_volume, 293.15, 10.0 * ratio - quantity) + var priced: ServiceResult = ChamberRecoveryWork.prepare(priced_source, priced_receiver, quantity) + if work > 249.0: check(not priced.is_success, "just above 250-J price rejects without truncation") + else: check(priced.is_success and priced.value.charged_joules == int(ceil(work)) + 1, "near-integer work rounds upward, including a 250-J batch") + # Independent pinned finite-budget result; use returned detached states only. + var initial: float = source.gas_moles + var total: float = source.gas_moles + receiver.gas_moles + var returned: float = 0.0 + var spent: int = 0 + var batches: int = 0 + while returned < initial * 0.2 and batches < 301: + var quantity: float = minf(0.1, initial * 0.2 - returned) + var proposal: ServiceResult = ChamberRecoveryWork.prepare(source, receiver, quantity) + check(proposal.is_success, "finite twenty-percent route is physically admissible") + if not proposal.is_success: break + source = proposal.value.source_after + receiver = proposal.value.receiver_after + returned += quantity + spent += int(proposal.value.charged_joules) + batches += 1 + check(batches == 300 and spent == 22993, "pinned route costs 22993 J over 300 batches") + check(returned == initial * 0.2, "small final batch ends at the immutable target") + check(absf(source.gas_moles + receiver.gas_moles - total) < 1e-8, "bulk moles conserved within floating-point accumulation error") + check(absf(source.pressure_kpa() - 81.06) < 1e-8, "twenty percent recovery leaves the expected finite source pressure") + quit(failures) diff --git a/tests/godot/paid_chamber_test.gd b/tests/godot/paid_chamber_test.gd index 434e55fd..607551f0 100644 --- a/tests/godot/paid_chamber_test.gd +++ b/tests/godot/paid_chamber_test.gd @@ -113,9 +113,15 @@ func run() -> void: 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() + await verify_chamber_extension() finish() +## Test-only extension point reuses the authentic finite acquisition route. +func verify_chamber_extension() -> void: + pass + + func build_chamber() -> void: for count in range(4): command("materialize_plate", {}) var plates: Array = production.observation(context).value.components.duplicate() diff --git a/tests/schema/chamber_recovery_schema_test.py b/tests/schema/chamber_recovery_schema_test.py new file mode 100644 index 00000000..a83960b0 --- /dev/null +++ b/tests/schema/chamber_recovery_schema_test.py @@ -0,0 +1,72 @@ +"""Phase 1 backend schemas; runtime mode requires the genuine paid-route fixture.""" +from copy import deepcopy +import json +from pathlib import Path +import sys + +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] +names = ["chamber-recovery-accounting:v1", "chamber-recovery-assembly:v1", + "chamber-recovery-job:v1", "chamber-recovery:v1", + "production-system:v16", "pressure-system:v6", "world-snapshot:v1"] +by_id = {schema["$id"]: schema for schema in schemas if "$id" in schema} +for name in names: + assert "urn:frontier-systems:schema:" + name in by_id, name +if "--syntax-only" in sys.argv: + print("Schema JSON syntax and required identities passed; no metaschema or runtime validation") + sys.exit(0) + +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +registry = Registry().with_resources( + [(schema["$id"], Resource.from_contents(schema)) for schema in schemas if "$id" in schema] + + [(path.name, Resource.from_contents(schema)) for path, schema in zip(paths, schemas)] +) + + +def validator(name): + schema = by_id["urn:frontier-systems:schema:" + name] + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema, registry=registry) + + +for name in names: + validator(name) +if "--schema-only" in sys.argv: + print("Phase 1 backend metaschemas passed; runtime fixture not checked") + sys.exit(0) + +world = json.loads((ROOT / "var/chamber-recovery-fixture.json").read_text(encoding="utf-8")) +production = world["systems"]["production"] +pressure = world["systems"]["pressure"] +validator("world-snapshot:v1").validate(world) +validator("production-system:v16").validate(production) +validator("pressure-system:v6").validate(pressure) +assert production["energy_joules"] == 27250 +assert production["store"]["plate"] == production["store"]["iron"] == 200000 +recovery = production["chamber_recovery"] +assert recovery["accounting"] == pressure["pump_accounting"] +assert len(recovery["assemblies"]) == len(recovery["jobs"]) == 1 +assert recovery["assemblies"][0]["state"] == "mounted" +assert recovery["jobs"][0]["max_joules"] == 25000 +assert recovery["jobs"][0]["target_moles"] == recovery["jobs"][0]["initial_moles"] * 0.2 +for field, value in [("assembly_spent_joules", 0), ("definition_revision", 2), + ("materials", {"plate": 400000, "iron": 200001}), ("free_energy", 1)]: + bad = deepcopy(production) + bad["chamber_recovery"]["assemblies"][0][field] = value + assert not validator("production-system:v16").is_valid(bad), field +for field, value in [("max_joules", 25001), ("spent_joules", -1), + ("next_eligible_tick", 9007199254740001), ("target_moles", -1)]: + bad = deepcopy(production) + bad["chamber_recovery"]["jobs"][0][field] = value + assert not validator("production-system:v16").is_valid(bad), field +for field, value in [("sequence", -1), ("last_quantity_moles", 0.100001), + ("last_charged_joules", 251), ("paid_joules", 100001)]: + bad = deepcopy(pressure) + bad["pump_accounting"][field] = value + assert not validator("pressure-system:v6").is_valid(bad), field +assert not validator("production-system:v15").is_valid(production) +assert not validator("pressure-system:v5").is_valid(pressure) +print("Genuine paid-route Phase 1 backend fixture and schema negatives passed")