diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index b05263970..d1d1442d9 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -6865,6 +6865,122 @@ internal sealed class AetheriaDaemonYmirSmokeChecks "daemon catalog price must own trade credit mutation"); RequireEqual(5, CargoQuantity(ship, ore.ItemKey), "daemon cargo capacity and placement must own delivery"); RequireEqual(2, zone.Entities.Count, "forged ship-creation opinion must not materialize an entity"); + + AetheriaRuntimeDaemonCommandDocument Contender(string clientId, string commandId) + { + var contender = AetheriaRuntimeDaemonCommandDocument.Create( + AetheriaRuntimeDaemonCommandKinds.TradePurchase, + clientId, + run.RunId, + 2, + run.CurrentEntityKey); + contender.CommandId = commandId; + contender.TradePurchase.ItemKey = ore.ItemKey; + contender.TradePurchase.Quantity = 5; + contender.TradePurchase.StationCargoIndex = 0; + contender.TradePurchase.TargetCargoIndex = 0; + contender.TradePurchase.SourceX = 2; + contender.TradePurchase.SourceY = 3; + return contender; + } + + var firstClient = Contender("pilot-a", "trade-contested-a"); + var secondClient = Contender("pilot-b", "trade-contested-b"); + var creditsBeforeContest = run.Credits; + var contested = AetheriaRuntimeDaemonOperations.Execute( + run, + [firstClient, secondClient], + new AetheriaRuntimeDaemonOperationContext { Catalog = catalog }); + + Require(contested.AppliedCommandIds.SequenceEqual([firstClient.CommandId]), + "the daemon batch reducer must serialize contested stock to exactly one winner"); + Require(contested.RejectedCommandIds.SequenceEqual([secondClient.CommandId]), + "the command observing exhausted stock must be rejected exactly once"); + RequireEqual(AetheriaRuntimeDaemonRejectionReasons.TradeStockUnavailable, + contested.RejectedCommandReasons[secondClient.CommandId], + "contested stock must retain the exact daemon-owned rejection reason"); + RequireEqual(creditsBeforeContest - daemonUnitPrice * 5, run.Credits, + "contested stock must debit the shared run wallet exactly once"); + RequireEqual(10, CargoQuantity(ship, ore.ItemKey), + "contested stock must deliver exactly one winning stack quantity"); + RequireEqual(0, CargoQuantity(station, ore.ItemKey), + "the winning purchase must exhaust authoritative station stock"); + + var frame = AetheriaRuntimeDaemonFrameDocument.Create( + run, "daemon", "trade-contest", 2, 0.04, 0.02); + frame.AppliedCommandIds = contested.AppliedCommandIds; + frame.RejectedCommandIds = contested.RejectedCommandIds; + frame.RejectedCommandReasons = contested.RejectedCommandReasons; + var winnerFact = AetheriaRuntimeCommittedCommandFactDocument.FromAppliedCommand( + frame, firstClient, "aetheria.local"); + var loserFact = AetheriaRuntimeCommittedCommandFactDocument.FromRejectedCommand( + frame, secondClient, "aetheria.local"); + var winnerReceipt = AetheriaRuntimeDaemonReceiptProjector.Project( + winnerFact, AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId); + var loserReceipt = AetheriaRuntimeDaemonReceiptProjector.Project( + loserFact, AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId); + RequireEqual("reconciled", winnerReceipt.State, + "the winning client must receive an authoritative generic Eve receipt"); + Require(loserReceipt.State == "denied" && + loserReceipt.Message.Contains( + AetheriaRuntimeDaemonRejectionReasons.TradeStockUnavailable, + StringComparison.Ordinal), + "the losing client must receive the exact contested-stock reason through generic Eve"); + + var root = Path.Combine(Path.GetTempPath(), "aetheria-trade-contest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var statePath = Path.Combine(root, "world.cc"); + try + { + using (var node = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult()) + { + node.MutableDocument( + AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest) + .ReplaceAsync(frame).GetAwaiter().GetResult(); + node.MutableDocument( + new CultRecordKey(AetheriaRuntimeCommittedCommandFactDocument.CreateRecordKey(winnerFact.FactId))) + .ReplaceAsync(winnerFact).GetAwaiter().GetResult(); + node.MutableDocument( + new CultRecordKey(AetheriaRuntimeCommittedCommandFactDocument.CreateRecordKey(loserFact.FactId))) + .ReplaceAsync(loserFact).GetAwaiter().GetResult(); + node.FlushAsync(soft: false).GetAwaiter().GetResult(); + } + + using (var node = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult()) + { + var durableFrame = node.MutableDocument( + AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest) + .ReadAsync().GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Durable contested-trade frame is missing."); + var durableWinner = node.MutableDocument( + new CultRecordKey(AetheriaRuntimeCommittedCommandFactDocument.CreateRecordKey(winnerFact.FactId))) + .ReadAsync().GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Durable winning trade fact is missing."); + var durableLoser = node.MutableDocument( + new CultRecordKey(AetheriaRuntimeCommittedCommandFactDocument.CreateRecordKey(loserFact.FactId))) + .ReadAsync().GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Durable rejected trade fact is missing."); + var durableStation = durableFrame.Run.Zones.Single().Entities.Single(entity => entity.EntityIndex == 0); + var durableShip = durableFrame.Run.Zones.Single().Entities.Single(entity => entity.EntityIndex == 1); + RequireEqual(run.Credits, durableFrame.Run.Credits, + "CultCache reconnect must preserve the single authoritative debit"); + RequireEqual(0, CargoQuantity(durableStation, ore.ItemKey), + "CultCache reconnect must not resurrect contested station stock"); + RequireEqual(10, CargoQuantity(durableShip, ore.ItemKey), + "CultCache reconnect must preserve the winning cargo delivery exactly once"); + RequireEqual("reconciled", AetheriaRuntimeDaemonReceiptProjector.Project( + durableWinner, AetheriaRuntimeDaemonGameSurfaceBuilder.SurfaceId).State, + "the winning generic Eve receipt must survive reconnect"); + RequireEqual(AetheriaRuntimeDaemonRejectionReasons.TradeStockUnavailable, + durableLoser.RejectionReason, + "the losing generic Eve receipt reason must survive reconnect"); + } + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } } private static void TradeSaleIsDaemonPricedSpatialAndAtomic() diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index d1f87b2b5..f33b654b4 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -84,7 +84,11 @@ namespace GameCult.Aetheria.State.Verse public const string CargoNoFit = "cargo-no-fit"; public const string CargoStackLimit = "cargo-stack-limit"; public const string TradeRequiresDocked = "trade-requires-docked"; + public const string InvalidTradeItem = "invalid-trade-item"; public const string InvalidTradeSource = "invalid-trade-source"; + public const string TradeStockUnavailable = "trade-stock-unavailable"; + public const string TradeInsufficientCredits = "trade-insufficient-credits"; + public const string TradeTargetNoFit = "trade-target-no-fit"; public const string TradeStationNoFit = "trade-station-no-fit"; public const string InvalidTradePayout = "invalid-trade-payout"; } @@ -204,7 +208,7 @@ namespace GameCult.Aetheria.State.Verse case AetheriaRuntimeDaemonCommandKinds.ToggleHullConductivity: return ApplyToggleHullConductivity(run, command); case AetheriaRuntimeDaemonCommandKinds.TradePurchase: - return ApplyTradePurchase(run, command, context.Catalog); + return ApplyTradePurchase(run, command, context); case AetheriaRuntimeDaemonCommandKinds.TradeSale: return ApplyTradeSale(run, command, context); case AetheriaRuntimeDaemonCommandKinds.RestoreLoadout: @@ -1008,15 +1012,25 @@ namespace GameCult.Aetheria.State.Verse private static bool ApplyTradePurchase( AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeDaemonCommandDocument command, - AetheriaRuntimeCatalogSnapshot? catalog) + AetheriaRuntimeDaemonOperationContext context) { var purchase = command.TradePurchase ?? new AetheriaRuntimeTradePurchaseCommand(); var itemKey = purchase.ItemKey ?? ""; - var typedItem = catalog?.FindItem(itemKey); + var catalog = context.Catalog; var quantity = Math.Max(1, purchase.Quantity); - if (typedItem == null || - !TryResolveDockParent(run, run.CurrentEntityKey, out var dockParentKey, out var dockParent)) - return false; + if (catalog == null) + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidTradeItem); + + var typedItem = catalog.FindItem(itemKey); + if (typedItem == null) + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.InvalidTradeItem); + + var actorKey = ResolveActorEntityKey(run, command); + if (!TryResolveDockParent(run, actorKey, out var dockParentKey, out var dockParent) || + !IsEntityDockedAt(run, actorKey, dockParent)) + { + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.TradeRequiresDocked); + } if (!TryFindStationStock( dockParent, @@ -1027,7 +1041,7 @@ namespace GameCult.Aetheria.State.Verse out var stationCargoIndex, out var stockSlot)) { - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.TradeStockUnavailable); } var stationEntity = dockParent; @@ -1037,13 +1051,13 @@ namespace GameCult.Aetheria.State.Verse catalog.TradeValueSettings).Price; var totalPrice = checked(unitPrice * quantity); if (unitPrice < 0 || run.Credits < totalPrice) - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.TradeInsufficientCredits); var createsDockedShip = !string.IsNullOrWhiteSpace(typedItem.HullType); if (createsDockedShip) { if (quantity != 1 || !HasAvailableDockingBay(dockParent)) - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.NoEligibleDockingBay); if (!TryRemoveCargoItemQuantity( stationEntity, stationCargoIndex, @@ -1052,11 +1066,11 @@ namespace GameCult.Aetheria.State.Verse purchase.SourceY, 1, out var purchasedHull)) - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.TradeStockUnavailable); if (!ApplyCreateDockedShipPurchase(run, dockParentKey, itemKey, out var purchasedShipKey)) { AddCargoItem(stationEntity, stationCargoIndex, purchasedHull); - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.NoEligibleDockingBay); } run.CurrentEntityKey = purchasedShipKey; run.Credits -= totalPrice; @@ -1065,15 +1079,14 @@ namespace GameCult.Aetheria.State.Verse if (!TryResolveCargoBay( run, - run.CurrentEntityKey, + actorKey, Math.Max(0, purchase.TargetCargoIndex), out var targetEntity, out var targetCargoIndex, out _) || - !IsEntityDockedAt(run, run.CurrentEntityKey, dockParent) || quantity > AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(targetEntity, catalog, itemKey, targetCargoIndex)) { - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.TradeTargetNoFit); } if (!TryRemoveCargoItemQuantity( @@ -1085,7 +1098,7 @@ namespace GameCult.Aetheria.State.Verse quantity, out var purchasedSlot)) { - return false; + return context.Reject(AetheriaRuntimeDaemonRejectionReasons.TradeStockUnavailable); } purchasedSlot.X = 0; diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index eab8836b0..a20243178 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -3039,7 +3039,13 @@ and destination bay. The daemon derives the dock parent, current catalog price, product kind, capacity, hull-vs-cargo result, credit changes, cargo transfer, ship creation, and checkpoint. Historical price, station, target, purchase-kind, and `CreatesDockedShip` fields remain serialization tombstones only; live clients -do not write them and daemon operations do not read them. Runtime loadout +do not write them and daemon operations do not read them. The daemon's ordered +command reducer is also the sole contested-stock serialization point. Each +purchase reads the stock left by the preceding command in that frame, so two +clients requesting the final stack produce one atomic debit/delivery and one +typed `trade-stock-unavailable` rejection. World state and both committed +command facts survive a hard CultCache close/reopen; generic Eve reconstructs +the reconciled and denied receipts without owning economy logic. Runtime loadout restore follows the same rule: UI requests restoration, and the daemon owns instantiation, credits, dock assignment, current entity, and checkpoint. Docked current-ship selection follows the same rule: UI requests selection, and the diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index 48f57b699..241f271aa 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -30,7 +30,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | daemon docking now preserves the fossil transaction: stable zone order selects the first entity with a real empty bay inside the fixed 25-unit XZ center radius; undock checks installed Cockpit, Thruster-or-AetherDrive, Reactor, then empty bay cargo in source order; rejection reasons survive committed command facts into generic Eve receipts; success removes parentage without moving, stopping, or rotating the ship. Accepted transitions additionally publish retained `ship.docked` / `ship.undocked` feedback with exact actor, parent, bay, frame and position; rejected operations publish no false transition. Docking clears held-group and transient weapon state, docked actors reject later fire commands, and combat subtracts docked child identities from both attacker iteration and target lookup, so stale input, forged pulses, and turret controllers cannot fire through a bay. Ymir world entities retain gravity/query bodies but do not invent ship/station rigidbody collision response that would make the 25-unit rule unreachable; ship/pickup Begin contacts remain enabled. The live generic Unity witness also proves advertised actions, bay assignment, dock-parent SoA inclusion and sensor sharing, camera retargeting, controlled-subject hiding, movement suppression, and restoration on undock | reconnect, docked inventory/trade surfaces, and authored dock-camera blend/effect lowering | | Wormhole travel | `ActionGameManager.EnterWormhole` plus `Ship` enter/exit state | daemon fixed-step transition plus Ymir exclusion | current-entity transition state and feedback chronology | provider-selected generic transition lowering | command acceptance no longer moves an entity immediately; player interact, explicit travel, and agent routing derive the same canonical adjacent-zone wormhole and proximity gate, then persist four-second `entering`, authoritative transfer, four-second `exiting`, and one-frame `completed` phases. The daemon owns smootherstep XZ motion, the fossil 1000-unit visual-depth lever, deterministic 20-unit exit direction, 50-unit exit radius, target-adjacency discovery, and exactly-once start/transfer/completion events. Flight and every world-physics implementation receive no transitioning body; Ymir resumes on the tick after completion. An autonomous three-zone route now hard-flushes and closes CultCache during the first `entering` phase, reopens into a fresh run graph and Ymir owner, preserves the assigned task/carrier/phase, crosses both edges, emits exactly two start/transfer/completion triplets, and never clones the worker | generic provider transition effect/camera lowering and live multi-zone pilot witness | | Inventory/equipment | `Entity.ItemFits`, `TryEquip`, `TryUnequip`, `EquippedCargoBay.ItemFits`/`TryStore`, inventory panels + ServerShared | daemon atomic inventory transactions | typed inventory/equipment state, commands, exact receipts | generic UI | generated cargo and docking bays now occupy their typed collections instead of masquerading as ordinary equipment; docked equip/reposition/store validates the complete destination on cloned state before committing either side, uses authored hull interior and hardpoint geometry, forces hardpoint rotation, rejects occupied cells and deployed ships, preserves exact item instance fields, surviving equipment behavior/weapon state, and weapon-group membership, routes bay categories with aligned content/assignment arrays, and refuses to unequip nonempty/occupied bays. Cargo transfer now uses the same plan-first rule with typed bay interiors, rotated item footprints, deterministic free-cell search, exact stack maxima, and no partial source/destination mutation on rejection; exact reasons reach generic Eve receipts. The generic Eve equip request-to-transaction-to-CultCache-restart path is covered, including source non-resurrection | live generic refit control lowering, reconnect after rejected moves, commodity multi-stack chronology, and full fossil inventory feedback parity | -| Trade | trade UI/economy | daemon | stock, quote, purchase/sale commands | generic UI | daemon derives station, stock row, catalog item, current price, quantity, credits, target capacity, and hull-vs-cargo result; purchases ignore client opinions about price and destination kind. Docked sales identify only the source stack and quantity: the daemon derives station, instance-sensitive value, payout, deterministic typed stock placement, and commits cargo/credits atomically; generic Eve advertises available buy and sell actions. The generic Unity live witness receives an authoritative purchase receipt while paused | live sale receipt witness, quote expiry, contested stock, and full trade surface parity | +| Trade | trade UI/economy | daemon | stock, quote, purchase/sale commands | generic UI | daemon derives station, stock row, catalog item, current price, quantity, credits, target capacity, and hull-vs-cargo result; purchases ignore client opinions about price and destination kind. The sequential daemon reducer now serializes two client purchases for the same final stock in one frame: exactly one command debits/delivers, the other commits `trade-stock-unavailable`, and a hard CultCache close/reopen preserves exhausted stock, the single wallet debit, delivered cargo, and both generic Eve receipts. Docked sales identify only the source stack and quantity: the daemon derives station, instance-sensitive value, payout, deterministic typed stock placement, and commits cargo/credits atomically; generic Eve advertises available buy and sell actions. The generic Unity live witness receives an authoritative purchase receipt while paused | live sale receipt witness, quote expiry, and full trade surface parity | | Action bar/input | input shell and rebinding UI | daemon advertises actions; client owns bindings | input capability and action descriptors | universal binding UI | contracts wired; consumable rows are catalog-filtered, expose daemon-owned cargo quantity, active-effect count, exact duration/remaining duration, and the fossil `remaining / duration` fill as `remaining-ratio.v1`. A depleted or non-stackable active consumable remains visible with unavailable command state until its effect ends, so a lowerer never queries Unity gameplay objects. Weapon groups advertise the fossil press/release lifecycle as generic `button-hold.v1` values routed to `SetWeaponGroupActive`; daemon entity state persists the active-group latch, press edges pulse instant/charged weapons once, constant weapons remain selected without client keepalives, release clears the latch, and the one-shot compatibility command is not the public input contract. Equipment actions derive from exact installed catalog payload/state pairs instead of blindly activating behavior zero: Switch and Trigger expose generic hold values, adjustable Thermotoggle exposes a canonical `scalar.v1` Kelvin target/current value, fixed/non-interactive rows are omitted, and offline rows retain identity as unavailable. Target nearest/previous/next/reticle/clear actions expose daemon-owned current selection, visible-hostile count, and availability; the default profile preserves fossil `R/T/Y/U`, restores the shared wormhole-first/then-dock interaction to `F`, and routes `view-direction.v1` world XYZ through the released generic runtime while target ranking remains daemon policy. Released EveUnity lowers provider action-bar suggestions with provider-owned icon asset keys, availability, one-shot, held, view-direction, and explicit scalar controls without importing Aetheria. Every value form translates through generic Eve requests into typed daemon commands under provider identity `aetheria` | generic slot editing/persistence, consumable quantity/progress chrome, provider styling, and live action-bar witness | | Pilot camera | ARPG follow/dock cameras | Eve view semantics + runtime | camera rig and semantic view id | EveUnity camera rig | daemon dock state selects distinct contracts instead of collapsing both fossil cameras: undocked flight advertises the entity-forward `Third Person Rig` framing (distance 30, screen 0.64/0.19, FOV 60, lens 1-4096) and the shared `aim.presentation` convergence point as its look-at; Aetheria translates the fossil Cinemachine top-origin screen Y of `0.81` to Eve's bottom-origin viewport Y of `0.19`; EveUnity solves native pose while preserving both optical aim and advertised entity framing, without provider types; the retained dock projection remains separate | reproduce the dock camera's separate follow/look-at subjects, blend timeline, noise, and reconnect state | | Render channels | Unity layers/culling masks | provider surface policy + runtime asset variant | semantic channel exclusions | EveUnity native layer mapping | wired, exclusive, and lowered: the pilot surface excludes the semantic `map` channel; the Unity asset variant maps `map` to its authored native layer; EveUnity subtracts that layer from the configured camera mask; portable state contains no Unity layer or mask; provider conversion preserves the source icon shader's unlit lighting model; the released witness proves the pilot camera excludes map glyphs while the map camera retains readable white glyphs without a scene light | preserve the channel proof while world presentation and transport continue to evolve |