Transfer verb: a pilot gives cargo to a ship alongside, committed through the conserving bay move

TryTransferItem(SimpleCommodity) checks the target can take the part and stores the part removed, not the stack
left behind (it duplicated and lost units on every partial move, the player's commodity buy among them).
TryTransferItems goes. CargoTransfer.Step owns a give's progress and end; PilotCommand.Give and StopGiving are applied
by PilotApplier; TransferRate is the new tunable, DockingDistance the range.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 16:38:17 +02:00
commit cada2fde3e
6 changed files with 171 additions and 40 deletions

View file

@ -0,0 +1,124 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System.Linq;
using CultMath;
using static CultMath.math;
// Why a give ended. Notification only: nothing reads it to decide anything.
public enum GiveEnd
{
Done,
Stopped,
OutOfRange,
ReceiverGone,
ReceiverFull,
NothingLeft,
GiverDocked
}
// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved.
public sealed class GiveOrder
{
public readonly Entity Receiver;
// A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance.
public readonly ItemInstance Item;
// Units still to move.
public int Remaining { get; internal set; }
// Units earned and not yet moved, fractional between steps.
internal double Budget;
internal GiveOrder(Entity receiver, ItemInstance item, int quantity)
{
Receiver = receiver;
Item = item;
Remaining = quantity;
}
}
// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through
// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling
// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit.
public static class CargoTransfer
{
internal static void Begin(Entity giver, GiveOrder order)
{
End(giver, GiveEnd.Stopped);
giver.Giving = order;
}
// Ends the giver's give, if it has one, for the reason.
internal static void End(Entity giver, GiveEnd reason)
{
if (giver.Giving == null) return;
giver.Giving = null;
giver.LastGiveEnd = reason;
}
// One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved
// into the first receiver bay that takes it.
public static void Step(Zone zone, float deltaTime)
{
foreach (var giver in zone.Entities.ToArray())
{
var order = giver.Giving;
if (order == null) continue;
var settings = giver.ItemManager.GameplaySettings;
var range = settings.DockingDistance;
if (!zone.Entities.Contains(order.Receiver))
{
End(giver, GiveEnd.ReceiverGone);
continue;
}
if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range)
{
End(giver, GiveEnd.OutOfRange);
continue;
}
order.Budget += (double) settings.TransferRate * deltaTime;
while (order.Budget >= 1 && order.Remaining > 0)
{
var source = Source(giver, order);
if (source.Bay == null)
{
End(giver, GiveEnd.NothingLeft);
break;
}
if (!Move(source.Bay, source.Item, order.Receiver))
{
End(giver, GiveEnd.ReceiverFull);
break;
}
order.Budget -= 1;
order.Remaining--;
}
if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done);
}
}
// Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance.
private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order)
{
foreach (var bay in giver.CargoBays)
{
if (order.Item is CraftedItemInstance crafted)
{
if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted);
}
else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks))
{
var stack = stacks.OfType<SimpleCommodity>().FirstOrDefault(s => s.Quantity > 0);
if (stack != null) return (bay, stack);
}
}
return (null, null);
}
// One unit of a commodity, or the crafted item, into the first receiver bay that takes it.
private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) =>
item is SimpleCommodity commodity
? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1))
: receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item));
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7be41c09d3a84f5e9a206d18c4f3b5a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -191,6 +191,10 @@ public abstract class Entity
// The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it.
public float TractorCommand { get; internal set; }
// The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both.
public GiveOrder Giving { get; internal set; }
public GiveEnd? LastGiveEnd { get; internal set; }
// The beam's spun-up state: Pickup's, or 0 without one.
public float TractorPower => GetBehavior<Pickup>()?.Power ?? 0f;
@ -1460,42 +1464,6 @@ public abstract class Entity
}
}
// Attempts to move a given number of items of the given type to the target Entity
// Returns the number of items successfully transferred
public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity)
{
int quantityTransferred = 0;
while (quantityTransferred < quantity)
{
EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID));
if (originInventory == null) break;
var itemInstance = originInventory.ItemsOfType[itemDataID][0];
if (itemInstance is SimpleCommodity simpleCommodity)
{
var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred);
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity)))
{
quantityTransferred += targetQuantity - simpleCommodity.Quantity;
break;
}
quantityTransferred += targetQuantity;
}
else if (itemInstance is CraftedItemInstance craftedItemInstance)
{
if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance)))
break;
quantityTransferred++;
}
}
return quantityTransferred;
}
public EquippableItem TryUnequip(EquippedItem item)
{
// Don't allow unequipping when the entity is active
@ -1752,6 +1720,7 @@ public abstract class Entity
if (bay != null)
{
bay.DockedShip = ship;
CargoTransfer.End(ship, GiveEnd.GiverDocked);
ship.SetParent(this);
Zone.Remove(ship);
ship.Deactivate();
@ -2695,12 +2664,17 @@ public class EquippedCargoBay : EquippedItem
return false;
}
// Whole or nothing: the target must be able to take the part before it leaves the stack.
var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation };
if (!target.TryFindSpace(part, out _)) return false;
var oldPos = Cargo[item];
var newItem = Remove(item, quantity);
if (target.TryStore(item)) return true;
// Failed to transfer full quantity, move the remaining items back to their old slot
// The part removed goes across, never the stack left behind.
if (target.TryStore(newItem)) return true;
// The target refused after all: put the part back where it came from.
TryStore(newItem, oldPos);
return false;
}

View file

@ -56,6 +56,8 @@ public sealed class PilotCommand
internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>();
internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>();
internal TargetVerb TargetVerb;
// The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins.
internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb;
internal int AimCycles;
internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings;
@ -77,6 +79,17 @@ public sealed class PilotCommand
public void ToggleHeatsinks() => HeatsinkToggles++;
public void ToggleShield() => ShieldToggles++;
public void Ping() => Pings++;
// Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity,
// or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and
// so is one to oneself when applied. The last give or stop in a step wins.
public void Give(Entity receiver, ItemInstance item, int quantity)
{
if (receiver == null || item == null) return;
var count = item is CraftedItemInstance ? 1 : quantity;
if (count < 1) return;
GiveVerb = (receiver, item, count);
}
public void StopGiving() => GiveVerb = (null, null, 0);
// The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state).
public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null;
@ -98,6 +111,7 @@ public sealed class PilotCommand
Triggers.Clear();
Iffs.Clear();
TargetVerb = TargetVerb.None;
GiveVerb = null;
AimCycles = 0;
ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0;
}
@ -134,6 +148,11 @@ public static class PilotApplier
for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping();
foreach (var hail in command.Hails) entity.SetMessage(hail);
foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable);
if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity))
{
if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped);
else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity));
}
if (command.Aim is float3 aim) entity.Aim = aim;
foreach (var (behavior, pull) in command.Triggers)
if (pull) behavior.Activate();

View file

@ -205,6 +205,8 @@ public class GameplaySettings
public int WeaponGroupCount = 6;
public float WarpDistance = 25;
public float DockingDistance = 25;
// Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item.
public float TransferRate = 2;
public float ProductionPersonalityLerp = .05f;
public float MessageDuration = 4f;
public float TargetPersistenceDuration = 3;

View file

@ -385,6 +385,7 @@ public class Zone
foreach (var entity in entities) entity.Sense();
_departures.Clear();
CargoTransfer.Step(this, deltaTime);
StepMines(deltaTime);
StepClouds(deltaTime);
StepFloatingItems(deltaTime);