Route trade purchases through checkpoints

This commit is contained in:
Meta Vens 2026-06-16 02:57:45 +01:00
commit f58e738cf8
5 changed files with 166 additions and 61 deletions

Binary file not shown.

View file

@ -19,6 +19,7 @@ RequireHullConductivityCommitAuthority(root);
RequireInventoryEntityRenameCommitAuthority(root);
RequireWeaponGroupCommitAuthority(root);
RequireInventoryDoubleClickTransferCommitAuthority(root);
RequireTradePurchaseCommitAuthority(root);
await using var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-state-verify");
@ -467,6 +468,7 @@ Console.WriteLine("Hull conductivity authority: inventory UI toggles flow throug
Console.WriteLine("Inventory entity rename authority: UI rename flows through gameplay checkpoint commits");
Console.WriteLine("Weapon group authority: UI assignment flows through gameplay checkpoint commits");
Console.WriteLine("Inventory transfer authority: UI transfer and drag/drop requests flow through gameplay checkpoint commits");
Console.WriteLine("Trade purchase authority: UI buy requests flow through gameplay checkpoint commits");
static void RequireCount(AetheriaMigrationLedger ledger, string documentType, int actual)
{
@ -1005,6 +1007,64 @@ static void RequireInventoryDoubleClickTransferCommitAuthority(string root)
}
}
static void RequireTradePurchaseCommitAuthority(string root)
{
var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs");
var actionGameManager = File.Exists(actionGameManagerPath)
? File.ReadAllText(actionGameManagerPath)
: throw new InvalidOperationException("Cannot verify trade purchase authority; ActionGameManager.cs is missing.");
var requiredCommits = new[]
{
"CommitTradePurchase",
"QueueRunCheckpoint(\"trade-purchase\")",
"Credits -=",
"new Ship("
};
var missingCommits = requiredCommits
.Where(symbol => !actionGameManager.Contains(symbol, StringComparison.Ordinal))
.ToArray();
if (missingCommits.Length > 0)
{
throw new InvalidOperationException(
"Trade purchase no longer has a gameplay-owned checkpoint commit primitive: " +
string.Join(", ", missingCommits));
}
var tradeMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "Menu", "TradeMenu.cs");
var tradeMenu = File.Exists(tradeMenuPath)
? File.ReadAllText(tradeMenuPath)
: throw new InvalidOperationException("Cannot verify trade purchase authority; TradeMenu.cs is missing.");
var forbiddenSymbols = new[]
{
"GameManager.Credits -=",
"Inventory.TryTransferItem(",
"new Ship(",
".SetParent(GameManager.DockedEntity)"
};
var hits = File.ReadLines(tradeMenuPath)
.Select((line, index) => new { LineNumber = index + 1, Line = line })
.Where(line => forbiddenSymbols.Any(symbol => line.Line.Contains(symbol, StringComparison.Ordinal)))
.Select(line => $"{Path.GetRelativePath(root, tradeMenuPath)}:{line.LineNumber}: {line.Line.Trim()}")
.ToArray();
if (hits.Length > 0)
{
throw new InvalidOperationException(
"TradeMenu still owns direct purchase mutation: " +
string.Join("; ", hits));
}
if (!tradeMenu.Contains("GameManager.CommitTradePurchase", StringComparison.Ordinal))
{
throw new InvalidOperationException("TradeMenu no longer routes purchases through ActionGameManager.");
}
}
static string ExtractMethodBody(string source, string methodName)
{
var signature = "private void " + methodName + "()";

View file

@ -1675,6 +1675,76 @@ public class ActionGameManager : MonoBehaviour
return true;
}
public bool CommitTradePurchase(
EquippedCargoBay stationInventory,
EquippedCargoBay targetCargo,
CraftedItemInstance item,
int price,
bool createsDockedShip)
{
if (item == null ||
price < 0 ||
price > Credits)
{
return false;
}
if (createsDockedShip)
{
if (!(item is EquippableItem hull) ||
Zone == null ||
DockedEntity == null)
{
return false;
}
var ship = new Ship(ItemManager, Zone, hull, NewEntitySettings) { IsPlayerShip = true };
ship.SetParent(DockedEntity);
Credits -= price;
QueueRunCheckpoint("trade-purchase");
return true;
}
if (stationInventory == null ||
targetCargo == null ||
!stationInventory.TryTransferItem(targetCargo, item))
{
return false;
}
Credits -= price;
QueueRunCheckpoint("trade-purchase");
return true;
}
public bool CommitTradePurchase(
EquippedCargoBay stationInventory,
EquippedCargoBay targetCargo,
SimpleCommodity item,
int quantity,
int unitPrice)
{
if (stationInventory == null ||
targetCargo == null ||
item == null ||
quantity <= 0 ||
unitPrice < 0)
{
return false;
}
var totalPrice = (long)quantity * unitPrice;
if (totalPrice > Credits || totalPrice > int.MaxValue)
return false;
if (!stationInventory.TryTransferItem(targetCargo, item, quantity))
return false;
Credits -= (int)totalPrice;
QueueRunCheckpoint("trade-purchase");
return true;
}
public void CommitEquippedItemOverrideShutdown(EquippedItem item, bool enabled)
{
if (item?.EquippableItem == null)

View file

@ -528,41 +528,27 @@ public class TradeMenu : MonoBehaviour
}
var price = GetTypedTradePrice(item, typedItem);
if (price < GameManager.Credits)
var isShipHull = !string.IsNullOrWhiteSpace(typedItem?.HullType);
if (isShipHull &&
!string.Equals(typedItem.HullType, nameof(HullType.Ship), StringComparison.Ordinal))
{
if (!string.IsNullOrWhiteSpace(typedItem?.HullType))
{
if (!string.Equals(typedItem.HullType, nameof(HullType.Ship), StringComparison.Ordinal))
throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!");
var ship = new Ship(GameManager.ItemManager, GameManager.Zone, item as EquippableItem, GameManager.NewEntitySettings) { IsPlayerShip = true };
ship.SetParent(GameManager.DockedEntity);
GameManager.Credits -= price;
UpdateCreditsLabel();
}
else if (Inventory.TryTransferItem(_targetCargo, item))
{
GameManager.Credits -= price;
UpdateCreditsLabel();
}
else
{
Dialog.Clear();
Dialog.Title.text = "Unable to buy: Insufficient Cargo Space!";
Dialog.Show();
Dialog.MoveToCursor();
return;
}
}
else
{
Dialog.Clear();
Dialog.Title.text = "Unable to buy: Insufficient Credits!";
Dialog.Show();
Dialog.MoveToCursor();
ShowUnableToBuy("Unsupported hull purchase!");
return;
}
if (price > GameManager.Credits)
{
ShowUnableToBuy("Insufficient Credits!");
return;
}
if (!GameManager.CommitTradePurchase(Inventory, _targetCargo, item, price, isShipHull))
{
ShowUnableToBuy(isShipHull ? "Unable to create ship!" : "Insufficient Cargo Space!");
return;
}
UpdateCreditsLabel();
}
private void Buy(SimpleCommodity simpleCommodity, int quantity)
@ -574,40 +560,22 @@ public class TradeMenu : MonoBehaviour
return;
}
var maxStack = typedItem.MaxStack > 0 ? typedItem.MaxStack : 1;
var price = typedItem.Price;
// Up-rounded integer division from https://stackoverflow.com/a/503201
int lots = (quantity - 1) / maxStack + 1;
int remaining = quantity;
for (int i = 0; i < lots; i++)
var clampedQuantity = min(quantity, simpleCommodity.Quantity);
var totalPrice = (long)clampedQuantity * price;
if (totalPrice > GameManager.Credits)
{
int q = min(remaining, maxStack);
if (q * price < GameManager.Credits)
{
if (Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity))
{
GameManager.Credits -= q * price;
UpdateCreditsLabel();
remaining -= q;
}
else
{
Dialog.Clear();
Dialog.Title.text = "Unable to buy: Insufficient Cargo Space!";
Dialog.Show();
Dialog.MoveToCursor();
return;
}
}
else
{
Dialog.Clear();
Dialog.Title.text = "Unable to buy: Insufficient Credits!";
Dialog.Show();
Dialog.MoveToCursor();
return;
}
ShowUnableToBuy("Insufficient Credits!");
return;
}
if (!GameManager.CommitTradePurchase(Inventory, _targetCargo, simpleCommodity, clampedQuantity, price))
{
ShowUnableToBuy("Insufficient Cargo Space!");
return;
}
UpdateCreditsLabel();
}
private int GetTypedTradePrice(CraftedItemInstance item, AetheriaRuntimeCatalogItem typedItem)

View file

@ -1004,6 +1004,11 @@ First Aetheria surfaces to publish:
- Done: move trade menu buy price, ship-hull classification, and simple
commodity stack-size decisions onto typed catalog rows; inventory transfer
and ship construction remain the runtime mutation owners.
- Done: route trade purchase mutation through gameplay-owned checkpoint
commits. `TradeMenu` still presents typed trade rows and calculates display
prices, but no longer subtracts credits, transfers purchased cargo, or
constructs purchased ships directly. `ActionGameManager.CommitTradePurchase`
owns the purchase mutation and queues a typed run checkpoint.
- Done: move TradeMenu dynamic behavior columns onto typed behavior payload
fields; the menu no longer hydrates `ItemData` or `BehaviorData` for row
display, filtering, sorting, or buy decisions.
@ -1719,7 +1724,9 @@ collects text, gameplay owns the name mutation and checkpoint. Weapon group
assignment follows the same rule: UI requests membership, gameplay owns the
group mutation and checkpoint. Inventory double-click transfer follows the same
rule, and drag/drop placement now shares that commit family: UI requests
cargo/equipment movement, gameplay owns the move and queues the checkpoint. Any
cargo/equipment movement, gameplay owns the move and queues the checkpoint.
Trade purchases follow the same rule: UI requests the purchase, gameplay owns
credit changes, cargo transfer, ship creation, and checkpoint. Any
predicate that still needs legacy DTO objects must earn that dependency by
using behavior objects or simulation-only methods that typed facets do not yet
expose.