test batch system

This commit is contained in:
Marshal
2026-07-09 17:04:25 +00:00
parent 1690f9e498
commit a7d05fba34
9 changed files with 413 additions and 111 deletions

View File

@@ -52,6 +52,12 @@ export type DerivedTrainCapacity = {
maxLengthMeters: number;
/** Length-derived slot count. Weight is enforced separately against real cargo. */
maxWagonSlots: number;
/** Caps WITHOUT the overage tolerance — what batch filling budgets against. */
baseWeightTons: number;
baseLengthMeters: number;
/** Overage spendable only by admitting a booking whole, never by a split. */
toleranceTons: number;
toleranceMeters: number;
};
/** What a consist currently uses, and what is left on each axis. */
@@ -88,28 +94,48 @@ export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' |
/**
* Hard caps for a train: the locomotive's own limits, floored by the global rule
* caps, then widened by the locomotive's overage tolerance.
*
* `base*` are the caps BEFORE the tolerance is added. The tolerance is not
* general-purpose headroom: batch filling budgets against the base caps and may
* spend the tolerance only to admit a booking WHOLE (never to size a split), so
* both figures are returned. `base + tolerance === max` always holds, including
* the fallback path.
*/
export function trainHardCaps(
locomotive: LocomotiveLimits,
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): { maxWeightTons: number; maxLengthMeters: number } {
): {
maxWeightTons: number;
maxLengthMeters: number;
baseWeightTons: number;
baseLengthMeters: number;
toleranceTons: number;
toleranceMeters: number;
} {
const overageTons = num(locomotive.overageToleranceTons);
const overageMeters = num(locomotive.overageToleranceMeters);
const weight =
Math.min(
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
) + overageTons;
const length =
Math.min(
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
) + overageMeters;
const baseWeight = Math.min(
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
);
const baseLength = Math.min(
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
);
const baseWeightTons = Number.isFinite(baseWeight) ? baseWeight : MAX_FALLBACK_WEIGHT;
const baseLengthMeters = Number.isFinite(baseLength) ? baseLength : MAX_FALLBACK_LENGTH;
const toleranceTons = Number.isFinite(baseWeight) ? overageTons : 0;
const toleranceMeters = Number.isFinite(baseLength) ? overageMeters : 0;
return {
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
maxWeightTons: baseWeightTons + toleranceTons,
maxLengthMeters: baseLengthMeters + toleranceMeters,
baseWeightTons,
baseLengthMeters,
toleranceTons,
toleranceMeters,
};
}
@@ -129,7 +155,7 @@ export function deriveTrainCapacityFromLocomotive(
wagonTypes: WagonTypeDimensions[],
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): DerivedTrainCapacity {
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
const caps = trainHardCaps(locomotive, ruleCaps);
const lengths = wagonTypes
.map((w) => num(w.lengthMeters))
@@ -137,9 +163,9 @@ export function deriveTrainCapacityFromLocomotive(
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
const maxWagonSlots =
minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0;
minLength > 0 ? Math.max(0, Math.floor(caps.maxLengthMeters / minLength)) : 0;
return { maxWeightTons, maxLengthMeters, maxWagonSlots };
return { ...caps, maxWagonSlots };
}
/**
@@ -265,17 +291,33 @@ export function bookingGrossWeightTons(
* returns the count that maximizes the cargo carried, with the cargo cap the
* caller should apply. Null when not even one part-loaded wagon fits. The
* offer is a strict subset of the booking: never all `bookingWagons`.
*
* `fullWagonsOnly` (bulk): every offered wagon rides at its full rated payload,
* so each wagon costs `capacityTons + tareWeightTons` of gross weight room and
* the offer is the largest whole-wagon count whose gross fits — never a
* part-loaded last wagon squeezed into leftover pull weight.
*/
export function sizePartialOfferWagons(
room: { wagons: number; weightTons: number; lengthMeters: number },
bookingWagons: number,
perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number },
opts?: { fullWagonsOnly?: boolean },
): { wagons: number; maxCargoTons: number } | null {
const maxByLength =
perWagon.lengthMeters > 0
? Math.floor(room.lengthMeters / perWagon.lengthMeters)
: room.wagons;
const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1);
if (opts?.fullWagonsOnly) {
const grossPerWagon = perWagon.capacityTons + perWagon.tareWeightTons;
const maxByWeight =
grossPerWagon > 0 ? Math.floor(room.weightTons / grossPerWagon) : 0;
const wagons = Math.min(ceiling, maxByWeight);
if (wagons < 1) return null;
return { wagons, maxCargoTons: round3(wagons * perWagon.capacityTons) };
}
let wagons = 0;
let bestCargoTons = 0;
for (let w = 1; w <= ceiling; w += 1) {