@shipload/sdk 1.0.0-next.55 → 1.0.0-next.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shipload.d.ts +45 -25
- package/lib/shipload.js +181 -28
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +178 -29
- package/lib/shipload.m.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities/craftable.test.ts +82 -0
- package/src/capabilities/craftable.ts +36 -5
- package/src/derivation/crafting.test.ts +17 -0
- package/src/derivation/crafting.ts +18 -0
- package/src/index-module.ts +5 -0
- package/src/managers/construction.ts +41 -10
- package/src/scheduling/availability.test.ts +157 -0
- package/src/scheduling/availability.ts +94 -13
- package/src/scheduling/cancel.test.ts +161 -1
- package/src/scheduling/cancel.ts +58 -5
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {ServerContract} from '../contracts'
|
|
3
|
+
import {UInt16, UInt32, UInt64} from '@wharfkit/antelope'
|
|
4
|
+
import {
|
|
5
|
+
ITEM_ENGINE_T1,
|
|
6
|
+
ITEM_GENERATOR_T1,
|
|
7
|
+
ITEM_CRAFTER_T1,
|
|
8
|
+
ITEM_PLASMA_CELL,
|
|
9
|
+
ITEM_SHIP_T1_PACKED,
|
|
10
|
+
} from '../data/item-ids'
|
|
11
|
+
import {getRecipe} from '../data/recipes-runtime'
|
|
12
|
+
import {encodeStats} from '../derivation/crafting'
|
|
13
|
+
import {rollupCrafter} from '../derivation/rollups'
|
|
14
|
+
import {makeEntity} from '../entities/makers'
|
|
15
|
+
import type {IncomingSource} from '../scheduling/availability'
|
|
16
|
+
import {maxCraftable} from './craftable'
|
|
17
|
+
|
|
18
|
+
const NOW = new Date('2026-06-11T17:06:40.000Z')
|
|
19
|
+
|
|
20
|
+
function ship(cargo: ServerContract.Types.cargo_item[] = []) {
|
|
21
|
+
return makeEntity(ITEM_SHIP_T1_PACKED, {
|
|
22
|
+
id: 99,
|
|
23
|
+
owner: 'tester.gm',
|
|
24
|
+
name: 'Craft Tester',
|
|
25
|
+
coordinates: {x: 0, y: 0},
|
|
26
|
+
hullmass: 200_000,
|
|
27
|
+
capacity: 8_000_000,
|
|
28
|
+
cargomass: 0,
|
|
29
|
+
energy: 500_000,
|
|
30
|
+
modules: [
|
|
31
|
+
{itemId: ITEM_GENERATOR_T1, stats: encodeStats([999, 999])},
|
|
32
|
+
{itemId: ITEM_CRAFTER_T1, stats: encodeStats([999, 999])},
|
|
33
|
+
],
|
|
34
|
+
cargo,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function crafterSpeed(entity: ReturnType<typeof ship>): number {
|
|
39
|
+
return rollupCrafter(entity.crafter_lanes)!.speed.toNumber()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function plasmaCell(quantity: number) {
|
|
43
|
+
return ServerContract.Types.cargo_item.from({
|
|
44
|
+
item_id: UInt16.from(ITEM_PLASMA_CELL),
|
|
45
|
+
quantity: UInt32.from(quantity),
|
|
46
|
+
stats: UInt64.from(0),
|
|
47
|
+
modules: [],
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('maxCraftable — incoming', () => {
|
|
52
|
+
test('on-hand-only baseline stays 0 for an empty-cargo entity', () => {
|
|
53
|
+
const recipe = getRecipe(ITEM_ENGINE_T1)!
|
|
54
|
+
const entity = ship([])
|
|
55
|
+
expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW)).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('incoming source alone covers the recipe inputs', () => {
|
|
59
|
+
const recipe = getRecipe(ITEM_ENGINE_T1)!
|
|
60
|
+
const entity = ship([])
|
|
61
|
+
const incoming: IncomingSource[] = [
|
|
62
|
+
{holdId: '1', until: new Date(NOW.getTime() - 1000), items: [plasmaCell(1800)]},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW)).toBe(0)
|
|
66
|
+
expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW, incoming)).toBe(3)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('an incoming source arriving well after craft completion still resolves (eventually craftable)', () => {
|
|
70
|
+
const recipe = getRecipe(ITEM_ENGINE_T1)!
|
|
71
|
+
const entity = ship([])
|
|
72
|
+
const farFuture: IncomingSource[] = [
|
|
73
|
+
{
|
|
74
|
+
holdId: '1',
|
|
75
|
+
until: new Date(NOW.getTime() + 1_000_000_000),
|
|
76
|
+
items: [plasmaCell(1800)],
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
expect(maxCraftable(entity, recipe, crafterSpeed(entity), NOW, farFuture)).toBe(3)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type {ServerContract} from '../contracts'
|
|
2
2
|
import {getItem} from '../data/catalog'
|
|
3
3
|
import type {Recipe} from '../data/recipes-runtime'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
availableForItem,
|
|
6
|
+
projectedCargoAvailableAt,
|
|
7
|
+
taskCargoEffect,
|
|
8
|
+
type IncomingSource,
|
|
9
|
+
} from '../scheduling/availability'
|
|
5
10
|
import {candidateLaneCompletesAt, workerLaneKey} from '../scheduling/lanes'
|
|
11
|
+
import * as schedule from '../scheduling/schedule'
|
|
6
12
|
import type {ScheduleData} from '../scheduling/schedule'
|
|
7
13
|
import {calc_craft_duration} from './crafting'
|
|
8
14
|
|
|
@@ -14,11 +20,35 @@ export interface CraftableEntity extends ScheduleData {
|
|
|
14
20
|
modules: ModuleEntry[]
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
// Recipe inputs aren't stat-pinned (any variant counts, mirroring availableForItem's item-id aggregation).
|
|
24
|
+
function itemReadyAt(
|
|
25
|
+
entity: CraftableEntity,
|
|
26
|
+
itemIds: readonly number[],
|
|
27
|
+
incoming: readonly IncomingSource[]
|
|
28
|
+
): Date {
|
|
29
|
+
let readyMs = 0
|
|
30
|
+
for (const ordered of schedule.orderedTasks(entity)) {
|
|
31
|
+
for (const item of taskCargoEffect(ordered.task).added) {
|
|
32
|
+
if (itemIds.includes(item.item_id.toNumber())) {
|
|
33
|
+
readyMs = Math.max(readyMs, ordered.completesAt.getTime())
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const src of incoming) {
|
|
39
|
+
if (src.items.some((item) => itemIds.includes(item.item_id.toNumber()))) {
|
|
40
|
+
readyMs = Math.max(readyMs, src.until.getTime())
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return new Date(readyMs)
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export function maxCraftable(
|
|
18
47
|
entity: CraftableEntity,
|
|
19
48
|
recipe: Recipe,
|
|
20
49
|
crafterSpeed: number,
|
|
21
|
-
now: Date
|
|
50
|
+
now: Date,
|
|
51
|
+
incoming: readonly IncomingSource[] = []
|
|
22
52
|
): number {
|
|
23
53
|
if (recipe.inputs.length === 0) return 0
|
|
24
54
|
|
|
@@ -30,12 +60,13 @@ export function maxCraftable(
|
|
|
30
60
|
const crafterLane = workerLaneKey(entity.modules, 'crafter', entity.lanes ?? [])
|
|
31
61
|
const naiveCompletesAt = candidateLaneCompletesAt(entity, crafterLane, perUnitDuration, now)
|
|
32
62
|
const laneStartMs = naiveCompletesAt.getTime() - perUnitDuration * 1000
|
|
33
|
-
const readyMs =
|
|
63
|
+
const readyMs = itemReadyAt(
|
|
34
64
|
entity,
|
|
35
|
-
recipe.inputs.map((input) => input.itemId)
|
|
65
|
+
recipe.inputs.map((input) => input.itemId),
|
|
66
|
+
incoming
|
|
36
67
|
).getTime()
|
|
37
68
|
const completesAt = new Date(Math.max(laneStartMs, readyMs) + perUnitDuration * 1000)
|
|
38
|
-
const availability = projectedCargoAvailableAt(entity, completesAt)
|
|
69
|
+
const availability = projectedCargoAvailableAt(entity, completesAt, incoming)
|
|
39
70
|
|
|
40
71
|
let maxUnits: bigint | undefined
|
|
41
72
|
for (const input of recipe.inputs) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {usedInputStatKeys} from './crafting'
|
|
3
|
+
import {ITEM_BUILDER_T1} from '../data/item-ids'
|
|
4
|
+
|
|
5
|
+
describe('usedInputStatKeys', () => {
|
|
6
|
+
test('maps each input to the stat keys the blend actually consumes', () => {
|
|
7
|
+
// Ceramic.hardness feeds the output's resonance slot; a name match would miss it.
|
|
8
|
+
expect(usedInputStatKeys(ITEM_BUILDER_T1)).toEqual([
|
|
9
|
+
['resonance'],
|
|
10
|
+
['hardness', 'fineness'],
|
|
11
|
+
])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('returns an empty array for an unknown recipe', () => {
|
|
15
|
+
expect(usedInputStatKeys(999999)).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -66,6 +66,24 @@ function keyForRecipeInputStat(recipe: Recipe, inputIndex: number, statIndex: nu
|
|
|
66
66
|
return innerKeys[statIndex] ?? ''
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function usedInputStatKeys(outputItemId: number): string[][] {
|
|
70
|
+
const recipe = getRecipe(outputItemId)
|
|
71
|
+
if (!recipe) return []
|
|
72
|
+
const usedIdx = recipe.inputs.map(() => new Set<number>())
|
|
73
|
+
for (const slot of recipe.statSlots) {
|
|
74
|
+
for (const src of slot.sources) {
|
|
75
|
+
usedIdx[src.inputIndex]?.add(src.statIndex)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return recipe.inputs.map((input, i) => {
|
|
79
|
+
const keys = getItemStatKeys(input.itemId)
|
|
80
|
+
return [...usedIdx[i]]
|
|
81
|
+
.sort((a, b) => a - b)
|
|
82
|
+
.map((idx) => keys[idx] ?? '')
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
export function decodeCraftedItemStats(itemId: number, stats: bigint): Record<string, number> {
|
|
70
88
|
const keys = getItemStatKeys(itemId)
|
|
71
89
|
const result: Record<string, number> = {}
|
package/src/index-module.ts
CHANGED
|
@@ -319,7 +319,11 @@ export {
|
|
|
319
319
|
availableForItem,
|
|
320
320
|
cargoReadyAt,
|
|
321
321
|
taskCargoEffect,
|
|
322
|
+
calcCounterpartDelivery,
|
|
323
|
+
cargoKey,
|
|
324
|
+
cargoInputKey,
|
|
322
325
|
} from './scheduling/availability'
|
|
326
|
+
export type {CargoInput, IncomingSource} from './scheduling/availability'
|
|
323
327
|
|
|
324
328
|
export {maxCraftable} from './capabilities/craftable'
|
|
325
329
|
|
|
@@ -412,6 +416,7 @@ export {
|
|
|
412
416
|
decodeStat,
|
|
413
417
|
decodeStats,
|
|
414
418
|
decodeCraftedItemStats,
|
|
419
|
+
usedInputStatKeys,
|
|
415
420
|
blendStacks,
|
|
416
421
|
computeComponentStats,
|
|
417
422
|
blendComponentStacks,
|
|
@@ -95,20 +95,24 @@ export class ConstructionManager extends BaseManager {
|
|
|
95
95
|
now: Date
|
|
96
96
|
): Map<string, InboundTransfer[]> {
|
|
97
97
|
const buckets = new Map<string, Map<string, InboundTransfer>>()
|
|
98
|
+
const entitiesById = new Map(entities.map((entity) => [entity.id.toString(), entity]))
|
|
98
99
|
const nowMs = now.getTime()
|
|
99
100
|
for (const entity of entities) {
|
|
100
|
-
const entityIdStr = entity.id.toString()
|
|
101
|
-
const sourceName = entity.entity_name || entityIdStr
|
|
102
101
|
for (const lane of getLanes(entity)) {
|
|
103
102
|
const startedMs = lane.schedule.started.toDate().getTime()
|
|
104
103
|
let cumulativeSec = 0
|
|
105
104
|
for (const task of lane.schedule.tasks) {
|
|
106
105
|
cumulativeSec += task.duration.toNumber()
|
|
107
|
-
if (!
|
|
108
|
-
|
|
106
|
+
if (!isDeliveryTask(task)) continue
|
|
107
|
+
const target = deliveryTarget(task)
|
|
108
|
+
if (!target) continue
|
|
109
|
+
const source = deliverySource(task, entity, entitiesById)
|
|
110
|
+
if (!source) continue
|
|
109
111
|
const projectedEndMs = startedMs + cumulativeSec * 1000
|
|
110
112
|
if (projectedEndMs < nowMs) continue
|
|
111
|
-
const targetIdStr =
|
|
113
|
+
const targetIdStr = target.entity_id.toString()
|
|
114
|
+
const sourceIdStr = source.id.toString()
|
|
115
|
+
const sourceName = source.entity_name || sourceIdStr
|
|
112
116
|
const etaSeconds = Math.max(0, Math.round((projectedEndMs - nowMs) / 1000))
|
|
113
117
|
let perTarget = buckets.get(targetIdStr)
|
|
114
118
|
if (!perTarget) {
|
|
@@ -119,15 +123,15 @@ export class ConstructionManager extends BaseManager {
|
|
|
119
123
|
const itemId = c.item_id.toNumber()
|
|
120
124
|
const quantity = c.quantity.toNumber()
|
|
121
125
|
if (quantity === 0) continue
|
|
122
|
-
const key = `${
|
|
126
|
+
const key = `${sourceIdStr}#${itemId}`
|
|
123
127
|
const existing = perTarget.get(key)
|
|
124
128
|
if (existing) {
|
|
125
129
|
existing.quantity += quantity
|
|
126
130
|
existing.etaSeconds = Math.min(existing.etaSeconds, etaSeconds)
|
|
127
131
|
} else {
|
|
128
132
|
perTarget.set(key, {
|
|
129
|
-
sourceEntityId:
|
|
130
|
-
sourceEntityType:
|
|
133
|
+
sourceEntityId: source.id,
|
|
134
|
+
sourceEntityType: source.type,
|
|
131
135
|
sourceName,
|
|
132
136
|
itemId,
|
|
133
137
|
quantity,
|
|
@@ -363,7 +367,34 @@ function partitionSources(
|
|
|
363
367
|
return {eligible, unreachable}
|
|
364
368
|
}
|
|
365
369
|
|
|
366
|
-
function
|
|
370
|
+
function isDeliveryTask(task: ServerContract.Types.task): boolean {
|
|
371
|
+
const type = task.type.toNumber()
|
|
372
|
+
return type === TaskType.UNLOAD || type === TaskType.SHUTTLE
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function deliveryTarget(
|
|
376
|
+
task: ServerContract.Types.task
|
|
377
|
+
): ServerContract.Types.entity_ref | undefined {
|
|
378
|
+
if (task.type.toNumber() === TaskType.SHUTTLE) {
|
|
379
|
+
return task.couplings.find((coupling) => coupling.kind.toNumber() === HoldKind.PUSH)
|
|
380
|
+
?.counterpart
|
|
381
|
+
}
|
|
382
|
+
return task.couplings[0]?.counterpart
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function deliverySource(
|
|
386
|
+
task: ServerContract.Types.task,
|
|
387
|
+
transporter: ServerContract.Types.entity_info,
|
|
388
|
+
entitiesById: ReadonlyMap<string, ServerContract.Types.entity_info>
|
|
389
|
+
): ServerContract.Types.entity_info | undefined {
|
|
390
|
+
if (task.type.toNumber() !== TaskType.SHUTTLE) return transporter
|
|
391
|
+
const source = task.couplings.find(
|
|
392
|
+
(coupling) => coupling.kind.toNumber() === HoldKind.PULL
|
|
393
|
+
)?.counterpart
|
|
394
|
+
return source ? entitiesById.get(source.entity_id.toString()) : undefined
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isReservingPushTask(task: ServerContract.Types.task): boolean {
|
|
367
398
|
return task.type.toNumber() === TaskType.UNLOAD
|
|
368
399
|
}
|
|
369
400
|
|
|
@@ -378,7 +409,7 @@ function isBuildOfPlot(task: ServerContract.Types.task, plotId: UInt64): boolean
|
|
|
378
409
|
function reservationsOf(source: ServerContract.Types.entity_info): Reservation[] {
|
|
379
410
|
const out = new Map<string, Reservation>()
|
|
380
411
|
for (const task of getTasks(source)) {
|
|
381
|
-
if (!
|
|
412
|
+
if (!isReservingPushTask(task)) continue
|
|
382
413
|
if (task.couplings.length === 0) continue
|
|
383
414
|
const targetType = task.couplings[0].counterpart.entity_type
|
|
384
415
|
const targetId = task.couplings[0].counterpart.entity_id
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {HoldKind, ServerContract, TaskType} from '../index-module'
|
|
3
|
+
import {
|
|
4
|
+
calcCounterpartDelivery,
|
|
5
|
+
cargoReadyAt,
|
|
6
|
+
type CargoInput,
|
|
7
|
+
type IncomingSource,
|
|
8
|
+
projectedCargoAvailableAt,
|
|
9
|
+
} from './availability'
|
|
10
|
+
|
|
11
|
+
const T0 = '2026-06-19T00:00:00'
|
|
12
|
+
const EPOCH = new Date(0)
|
|
13
|
+
|
|
14
|
+
function cargoItem(itemId: number, stats: number, quantity: number) {
|
|
15
|
+
return ServerContract.Types.cargo_item.from({item_id: itemId, stats, modules: [], quantity})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function task(over: {type?: number; duration?: number; cargo?: ReturnType<typeof cargoItem>[]}) {
|
|
19
|
+
return ServerContract.Types.task.from({
|
|
20
|
+
type: over.type ?? TaskType.LOAD,
|
|
21
|
+
duration: over.duration ?? 60,
|
|
22
|
+
cancelable: 0,
|
|
23
|
+
cargo: over.cargo ?? [],
|
|
24
|
+
couplings: [],
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function coupling(kind: number) {
|
|
29
|
+
return ServerContract.Types.coupling.from({
|
|
30
|
+
counterpart: {entity_type: 'ship', entity_id: 2},
|
|
31
|
+
hold: 1,
|
|
32
|
+
kind,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function entity(
|
|
37
|
+
cargo: ReturnType<typeof cargoItem>[],
|
|
38
|
+
tasks: ReturnType<typeof task>[] = [],
|
|
39
|
+
startedISO = T0
|
|
40
|
+
) {
|
|
41
|
+
return ServerContract.Types.entity_info.from({
|
|
42
|
+
type: 'ship',
|
|
43
|
+
id: 1,
|
|
44
|
+
owner: 'player.gm',
|
|
45
|
+
entity_name: 'Ship 1',
|
|
46
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
47
|
+
item_id: 1,
|
|
48
|
+
cargomass: 0,
|
|
49
|
+
cargo: cargo.map((c) => ({
|
|
50
|
+
item_id: c.item_id,
|
|
51
|
+
stats: c.stats,
|
|
52
|
+
modules: c.modules,
|
|
53
|
+
quantity: c.quantity,
|
|
54
|
+
id: 0,
|
|
55
|
+
})),
|
|
56
|
+
modules: [],
|
|
57
|
+
lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
|
|
58
|
+
gatherer_lanes: [],
|
|
59
|
+
crafter_lanes: [],
|
|
60
|
+
builder_lanes: [],
|
|
61
|
+
loader_lanes: [],
|
|
62
|
+
holds: [],
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('cargoReadyAt', () => {
|
|
67
|
+
test('on-hand covers demand → epoch', () => {
|
|
68
|
+
const e = entity([cargoItem(7, 0, 10)])
|
|
69
|
+
const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
|
|
70
|
+
expect(cargoReadyAt(e, demand)).toEqual(EPOCH)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('own-lane LOAD covers demand → its completion', () => {
|
|
74
|
+
const loadCompletesAt = new Date('2026-06-19T00:01:00.000Z')
|
|
75
|
+
const e = entity(
|
|
76
|
+
[],
|
|
77
|
+
[task({type: TaskType.LOAD, duration: 60, cargo: [cargoItem(7, 0, 10)]})]
|
|
78
|
+
)
|
|
79
|
+
const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
|
|
80
|
+
expect(cargoReadyAt(e, demand)).toEqual(loadCompletesAt)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('incoming source covers demand → its until', () => {
|
|
84
|
+
const until = new Date('2026-06-19T00:00:30.000Z')
|
|
85
|
+
const e = entity([])
|
|
86
|
+
const incoming: IncomingSource[] = [{holdId: '1', until, items: [cargoItem(7, 0, 10)]}]
|
|
87
|
+
const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
|
|
88
|
+
expect(cargoReadyAt(e, demand, incoming)).toEqual(until)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('on-hand covers demand; unrelated same-item different-stats inflow does not delay readiness', () => {
|
|
92
|
+
const laterCompletesAt = new Date('2026-06-19T00:01:00.000Z')
|
|
93
|
+
const e = entity(
|
|
94
|
+
[cargoItem(7, 0, 10)],
|
|
95
|
+
[task({type: TaskType.LOAD, duration: 60, cargo: [cargoItem(7, 99, 10)]})]
|
|
96
|
+
)
|
|
97
|
+
const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
|
|
98
|
+
const result = cargoReadyAt(e, demand)
|
|
99
|
+
expect(result).toEqual(EPOCH)
|
|
100
|
+
expect(result).not.toEqual(laterCompletesAt)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('calcCounterpartDelivery', () => {
|
|
105
|
+
test('coupled CRAFT task returns output cargo only', () => {
|
|
106
|
+
const output = cargoItem(9, 0, 1)
|
|
107
|
+
const t = task({
|
|
108
|
+
type: TaskType.CRAFT,
|
|
109
|
+
cargo: [cargoItem(7, 0, 2), cargoItem(8, 0, 1), output],
|
|
110
|
+
})
|
|
111
|
+
const result = calcCounterpartDelivery(t, coupling(HoldKind.PUSH))
|
|
112
|
+
expect(result.length).toBe(1)
|
|
113
|
+
expect(result[0].item_id.toNumber()).toBe(9)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('CRAFT task with empty cargo returns []', () => {
|
|
117
|
+
const t = task({type: TaskType.CRAFT, cargo: []})
|
|
118
|
+
expect(calcCounterpartDelivery(t, coupling(HoldKind.PUSH))).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('coupled UNLOAD task returns whole cargo', () => {
|
|
122
|
+
const t = task({type: TaskType.UNLOAD, cargo: [cargoItem(7, 0, 2), cargoItem(8, 0, 1)]})
|
|
123
|
+
const result = calcCounterpartDelivery(t, coupling(HoldKind.GATHER))
|
|
124
|
+
expect(result.length).toBe(2)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('non-incoming coupling kind returns []', () => {
|
|
128
|
+
const t = task({type: TaskType.UNLOAD, cargo: [cargoItem(7, 0, 2)]})
|
|
129
|
+
expect(calcCounterpartDelivery(t, coupling(HoldKind.PULL))).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('projectedCargoAvailableAt — incoming', () => {
|
|
134
|
+
const until = new Date('2026-06-19T00:00:30.000Z')
|
|
135
|
+
|
|
136
|
+
function incomingFor(qty: number): IncomingSource[] {
|
|
137
|
+
return [{holdId: '1', until, items: [cargoItem(7, 0, qty)]}]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test('credits an incoming source strictly before its until', () => {
|
|
141
|
+
const e = entity([])
|
|
142
|
+
const avail = projectedCargoAvailableAt(e, new Date(until.getTime() + 1), incomingFor(10))
|
|
143
|
+
expect(avail.get('7:0')).toBe(10n)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('does not credit an incoming source at exactly its until', () => {
|
|
147
|
+
const e = entity([])
|
|
148
|
+
const avail = projectedCargoAvailableAt(e, until, incomingFor(10))
|
|
149
|
+
expect(avail.get('7:0') ?? 0n).toBe(0n)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('does not credit an incoming source before its until', () => {
|
|
153
|
+
const e = entity([])
|
|
154
|
+
const avail = projectedCargoAvailableAt(e, new Date(until.getTime() - 1), incomingFor(10))
|
|
155
|
+
expect(avail.get('7:0') ?? 0n).toBe(0n)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type {ServerContract} from '../contracts'
|
|
2
|
-
import {TaskType} from '../types'
|
|
2
|
+
import {HoldKind, TaskType} from '../types'
|
|
3
3
|
import * as schedule from './schedule'
|
|
4
4
|
|
|
5
5
|
type Task = ServerContract.Types.task
|
|
6
|
+
type Coupling = ServerContract.Types.coupling
|
|
6
7
|
type CargoItem = ServerContract.Types.cargo_item
|
|
8
|
+
type ModuleEntry = ServerContract.Types.module_entry
|
|
7
9
|
|
|
8
10
|
export interface CargoEffect {
|
|
9
11
|
added: CargoItem[]
|
|
@@ -14,6 +16,35 @@ export interface AvailabilityInput extends schedule.ScheduleData {
|
|
|
14
16
|
cargo: CargoItem[]
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
export interface CargoInput {
|
|
20
|
+
itemId: number
|
|
21
|
+
stats: bigint
|
|
22
|
+
modules?: ModuleEntry[]
|
|
23
|
+
quantity: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IncomingSource {
|
|
27
|
+
holdId: string
|
|
28
|
+
until: Date
|
|
29
|
+
items: CargoItem[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const INCOMING_COUPLING_KINDS = new Set<number>([HoldKind.PUSH, HoldKind.GATHER, HoldKind.FLIGHT])
|
|
33
|
+
|
|
34
|
+
// Mirrors is_incoming_hold_kind: PUSH/GATHER/FLIGHT couplings deliver to the counterpart.
|
|
35
|
+
export function isIncomingCouplingKind(kind: number): boolean {
|
|
36
|
+
return INCOMING_COUPLING_KINDS.has(kind)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Mirrors calc_counterpart_delivery: incoming-kind couplings only; a coupled CRAFT yields its output slot.
|
|
40
|
+
export function calcCounterpartDelivery(task: Task, coupling: Coupling): CargoItem[] {
|
|
41
|
+
if (!isIncomingCouplingKind(coupling.kind.toNumber())) return []
|
|
42
|
+
if (task.type.toNumber() === TaskType.CRAFT) {
|
|
43
|
+
return task.cargo.length === 0 ? [] : [task.cargo[task.cargo.length - 1]]
|
|
44
|
+
}
|
|
45
|
+
return task.cargo
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
export function taskCargoEffect(task: Task): CargoEffect {
|
|
18
49
|
switch (task.type.toNumber()) {
|
|
19
50
|
case TaskType.LOAD:
|
|
@@ -21,6 +52,7 @@ export function taskCargoEffect(task: Task): CargoEffect {
|
|
|
21
52
|
case TaskType.UNDEPLOY:
|
|
22
53
|
return {added: task.cargo, removed: []}
|
|
23
54
|
case TaskType.UNLOAD:
|
|
55
|
+
case TaskType.UPGRADE:
|
|
24
56
|
return {added: [], removed: task.cargo}
|
|
25
57
|
case TaskType.GATHER:
|
|
26
58
|
return task.couplings.length > 0
|
|
@@ -37,22 +69,34 @@ export function taskCargoEffect(task: Task): CargoEffect {
|
|
|
37
69
|
}
|
|
38
70
|
}
|
|
39
71
|
|
|
40
|
-
|
|
41
|
-
const base = `${
|
|
42
|
-
const
|
|
43
|
-
const entityId = item.entity_id?.toString()
|
|
44
|
-
const normalizedEntityId = entityId && entityId !== '0' ? entityId : ''
|
|
72
|
+
function refKey(itemId: number, stats: bigint, modules: ModuleEntry[], entityId: string): string {
|
|
73
|
+
const base = `${itemId}:${stats.toString()}`
|
|
74
|
+
const normalizedEntityId = entityId !== '0' ? entityId : ''
|
|
45
75
|
if (modules.length === 0 && normalizedEntityId === '') return base
|
|
46
76
|
return `${base}:modules=${JSON.stringify(modules)}:entity=${normalizedEntityId}`
|
|
47
77
|
}
|
|
48
78
|
|
|
79
|
+
export function cargoKey(item: CargoItem): string {
|
|
80
|
+
return refKey(
|
|
81
|
+
item.item_id.toNumber(),
|
|
82
|
+
BigInt(item.stats.toString()),
|
|
83
|
+
item.modules ?? [],
|
|
84
|
+
item.entity_id?.toString() ?? ''
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cargoInputKey(input: CargoInput): string {
|
|
89
|
+
return refKey(input.itemId, input.stats, input.modules ?? [], '')
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
function cargoQuantity(item: CargoItem): bigint {
|
|
50
93
|
return BigInt(item.quantity.toString())
|
|
51
94
|
}
|
|
52
95
|
|
|
53
96
|
export function projectedCargoAvailableAt(
|
|
54
97
|
entity: AvailabilityInput,
|
|
55
|
-
at: Date
|
|
98
|
+
at: Date,
|
|
99
|
+
incoming: readonly IncomingSource[] = []
|
|
56
100
|
): Map<string, bigint> {
|
|
57
101
|
const avail = new Map<string, bigint>()
|
|
58
102
|
|
|
@@ -73,6 +117,14 @@ export function projectedCargoAvailableAt(
|
|
|
73
117
|
}
|
|
74
118
|
}
|
|
75
119
|
|
|
120
|
+
for (const src of incoming) {
|
|
121
|
+
if (src.until.getTime() >= at.getTime()) continue
|
|
122
|
+
for (const item of src.items) {
|
|
123
|
+
const key = cargoKey(item)
|
|
124
|
+
avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
76
128
|
for (const ordered of tasks) {
|
|
77
129
|
for (const item of taskCargoEffect(ordered.task).removed) {
|
|
78
130
|
const key = cargoKey(item)
|
|
@@ -85,18 +137,47 @@ export function projectedCargoAvailableAt(
|
|
|
85
137
|
return avail
|
|
86
138
|
}
|
|
87
139
|
|
|
88
|
-
//
|
|
89
|
-
export function cargoReadyAt(
|
|
90
|
-
|
|
140
|
+
// Earliest-sufficient candidate (epoch, own-lane producer completions, incoming untils); else the latest candidate.
|
|
141
|
+
export function cargoReadyAt(
|
|
142
|
+
entity: AvailabilityInput,
|
|
143
|
+
inputs: readonly CargoInput[],
|
|
144
|
+
incoming: readonly IncomingSource[] = []
|
|
145
|
+
): Date {
|
|
146
|
+
const demand = new Map<string, bigint>()
|
|
147
|
+
for (const input of inputs) {
|
|
148
|
+
const key = cargoInputKey(input)
|
|
149
|
+
demand.set(key, (demand.get(key) ?? 0n) + BigInt(input.quantity))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const candidates: number[] = [0]
|
|
91
153
|
for (const ordered of schedule.orderedTasks(entity)) {
|
|
92
154
|
for (const item of taskCargoEffect(ordered.task).added) {
|
|
93
|
-
if (
|
|
94
|
-
|
|
155
|
+
if (demand.has(cargoKey(item))) {
|
|
156
|
+
candidates.push(ordered.completesAt.getTime())
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const src of incoming) {
|
|
162
|
+
if (src.items.some((item) => demand.has(cargoKey(item)))) {
|
|
163
|
+
candidates.push(src.until.getTime())
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
candidates.sort((a, b) => a - b)
|
|
167
|
+
|
|
168
|
+
for (const candidateMs of candidates) {
|
|
169
|
+
// +1ms mirrors the contract's candidate+1µs probe: inclusive at the candidate instant.
|
|
170
|
+
const available = projectedCargoAvailableAt(entity, new Date(candidateMs + 1), incoming)
|
|
171
|
+
let sufficient = true
|
|
172
|
+
for (const [key, quantity] of demand) {
|
|
173
|
+
if ((available.get(key) ?? 0n) < quantity) {
|
|
174
|
+
sufficient = false
|
|
95
175
|
break
|
|
96
176
|
}
|
|
97
177
|
}
|
|
178
|
+
if (sufficient) return new Date(candidateMs)
|
|
98
179
|
}
|
|
99
|
-
return new Date(
|
|
180
|
+
return new Date(candidates[candidates.length - 1])
|
|
100
181
|
}
|
|
101
182
|
|
|
102
183
|
export function availableForItem(avail: Map<string, bigint>, itemId: number): bigint {
|