Merge eureka/aetheria-release-transfer-verb: give-only ship-to-ship transfer; partial cargo moves conserve units

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 21:03:37 +02:00
commit 7e2fff1ff1
7 changed files with 688 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 (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();
@ -2697,10 +2666,11 @@ public class EquippedCargoBay : EquippedItem
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;
// Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts 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 { } give)
{
if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped);
else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.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

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