@shipload/sdk 1.0.0-next.56 → 1.0.0-next.59
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 +57 -36
- package/lib/shipload.js +385 -245
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +371 -236
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.js +25 -25
- package/lib/testing.js.map +1 -1
- package/lib/testing.m.js +25 -25
- package/lib/testing.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/capabilities/modules.ts +10 -0
- package/src/data/entities.json +32 -42
- package/src/data/item-ids.ts +10 -10
- package/src/data/items.json +12 -12
- package/src/data/kind-registry.json +10 -10
- package/src/data/metadata.ts +17 -17
- package/src/data/recipes.json +111 -111
- package/src/derivation/capabilities.test.ts +15 -17
- package/src/derivation/crafting.test.ts +17 -0
- package/src/derivation/crafting.ts +18 -0
- package/src/derivation/recipe-usage.test.ts +8 -10
- package/src/index-module.ts +5 -0
- package/src/nft/description.ts +23 -23
- package/src/resolution/resolve-item.ts +12 -0
- 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/src/types.ts +1 -0
|
@@ -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 {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {describe, expect, test} from 'bun:test'
|
|
2
|
-
import {ServerContract, TaskType, TaskCancelable} from '../index-module'
|
|
2
|
+
import {ServerContract, TaskType, TaskCancelable, HoldKind} from '../index-module'
|
|
3
3
|
import {cancelEligibility, CancelBlockReason} from './cancel'
|
|
4
|
+
import type {IncomingSource} from './availability'
|
|
4
5
|
|
|
5
6
|
const T0 = '2026-06-19T00:00:00'
|
|
6
7
|
|
|
@@ -380,3 +381,162 @@ describe('cancelEligibility — feasibility', () => {
|
|
|
380
381
|
)
|
|
381
382
|
})
|
|
382
383
|
})
|
|
384
|
+
|
|
385
|
+
describe('cancelEligibility — cross-entity strand (counterpart queued consumer)', () => {
|
|
386
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
387
|
+
|
|
388
|
+
function entityWithId(
|
|
389
|
+
id: number,
|
|
390
|
+
tasks: ReturnType<typeof ServerContract.Types.task.from>[],
|
|
391
|
+
cargo: {
|
|
392
|
+
id: number
|
|
393
|
+
item_id: number
|
|
394
|
+
stats: number
|
|
395
|
+
modules: never[]
|
|
396
|
+
quantity: number
|
|
397
|
+
}[] = []
|
|
398
|
+
) {
|
|
399
|
+
return ServerContract.Types.entity_info.from({
|
|
400
|
+
type: 'ship',
|
|
401
|
+
id,
|
|
402
|
+
owner: 'player.gm',
|
|
403
|
+
entity_name: `Ship ${id}`,
|
|
404
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
405
|
+
item_id: 1,
|
|
406
|
+
cargomass: 0,
|
|
407
|
+
cargo,
|
|
408
|
+
modules: [],
|
|
409
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks}}],
|
|
410
|
+
gatherer_lanes: [],
|
|
411
|
+
crafter_lanes: [],
|
|
412
|
+
builder_lanes: [],
|
|
413
|
+
loader_lanes: [],
|
|
414
|
+
holds: [],
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function pushTask() {
|
|
419
|
+
return ServerContract.Types.task.from({
|
|
420
|
+
type: TaskType.UNLOAD,
|
|
421
|
+
duration: 50,
|
|
422
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
423
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
|
|
424
|
+
couplings: [
|
|
425
|
+
{counterpart: {entity_type: 'ship', entity_id: 2}, hold: 1, kind: HoldKind.PUSH},
|
|
426
|
+
],
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function craftConsumerTask() {
|
|
431
|
+
return ServerContract.Types.task.from({
|
|
432
|
+
type: TaskType.CRAFT,
|
|
433
|
+
duration: 100,
|
|
434
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
435
|
+
cargo: [
|
|
436
|
+
{item_id: 7, stats: 0, modules: [], quantity: 2},
|
|
437
|
+
{item_id: 9, stats: 0, modules: [], quantity: 1},
|
|
438
|
+
],
|
|
439
|
+
couplings: [],
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function incoming(): IncomingSource[] {
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
holdId: '1',
|
|
447
|
+
until: new Date('2026-06-19T00:00:50.000Z'),
|
|
448
|
+
items: [
|
|
449
|
+
ServerContract.Types.cargo_item.from({
|
|
450
|
+
item_id: 7,
|
|
451
|
+
stats: 0,
|
|
452
|
+
modules: [],
|
|
453
|
+
quantity: 2,
|
|
454
|
+
}),
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
]
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function upgradeConsumerTask() {
|
|
461
|
+
return ServerContract.Types.task.from({
|
|
462
|
+
type: TaskType.UPGRADE,
|
|
463
|
+
duration: 100,
|
|
464
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
465
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
|
|
466
|
+
couplings: [],
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
test('blocked: counterpart consumer loses coverage without this delivery', () => {
|
|
471
|
+
const producer = entityWithId(1, [pushTask()])
|
|
472
|
+
const counterpart = entityWithId(2, [craftConsumerTask()])
|
|
473
|
+
const plan = cancelEligibility(producer, 0, 0, {
|
|
474
|
+
now: upcoming,
|
|
475
|
+
counterparts: new Map([['2', counterpart]]),
|
|
476
|
+
counterpartIncoming: new Map([['2', incoming()]]),
|
|
477
|
+
})
|
|
478
|
+
expect(plan.ok).toBe(false)
|
|
479
|
+
expect(plan.blockedReason).toBe(CancelBlockReason.WOULD_STRAND_COUNTERPART)
|
|
480
|
+
expect(plan.blockedByCounterpart?.entity_id.toNumber()).toBe(2)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('blocked: counterpart upgrade consumer loses coverage without this delivery', () => {
|
|
484
|
+
const producer = entityWithId(1, [pushTask()])
|
|
485
|
+
const counterpart = entityWithId(2, [upgradeConsumerTask()])
|
|
486
|
+
const plan = cancelEligibility(producer, 0, 0, {
|
|
487
|
+
now: upcoming,
|
|
488
|
+
counterparts: new Map([['2', counterpart]]),
|
|
489
|
+
counterpartIncoming: new Map([['2', incoming()]]),
|
|
490
|
+
})
|
|
491
|
+
expect(plan.ok).toBe(false)
|
|
492
|
+
expect(plan.blockedReason).toBe(CancelBlockReason.WOULD_STRAND_COUNTERPART)
|
|
493
|
+
expect(plan.blockedByCounterpart?.entity_id.toNumber()).toBe(2)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
test('allowed: counterpart upgrade consumer already covered on-hand', () => {
|
|
497
|
+
const producer = entityWithId(1, [pushTask()])
|
|
498
|
+
const counterpart = entityWithId(
|
|
499
|
+
2,
|
|
500
|
+
[upgradeConsumerTask()],
|
|
501
|
+
[{id: 1, item_id: 7, stats: 0, modules: [], quantity: 2}]
|
|
502
|
+
)
|
|
503
|
+
const plan = cancelEligibility(producer, 0, 0, {
|
|
504
|
+
now: upcoming,
|
|
505
|
+
counterparts: new Map([['2', counterpart]]),
|
|
506
|
+
counterpartIncoming: new Map([['2', incoming()]]),
|
|
507
|
+
})
|
|
508
|
+
expect(plan.ok).toBe(true)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test('allowed: counterpart consumer already covered on-hand (surplus coverage)', () => {
|
|
512
|
+
const producer = entityWithId(1, [pushTask()])
|
|
513
|
+
const counterpart = entityWithId(
|
|
514
|
+
2,
|
|
515
|
+
[craftConsumerTask()],
|
|
516
|
+
[{id: 1, item_id: 7, stats: 0, modules: [], quantity: 2}]
|
|
517
|
+
)
|
|
518
|
+
const plan = cancelEligibility(producer, 0, 0, {
|
|
519
|
+
now: upcoming,
|
|
520
|
+
counterparts: new Map([['2', counterpart]]),
|
|
521
|
+
counterpartIncoming: new Map([['2', incoming()]]),
|
|
522
|
+
})
|
|
523
|
+
expect(plan.ok).toBe(true)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
test('allowed: counterpart data not loaded, check skipped gracefully', () => {
|
|
527
|
+
const producer = entityWithId(1, [pushTask()])
|
|
528
|
+
const plan = cancelEligibility(producer, 0, 0, {now: upcoming})
|
|
529
|
+
expect(plan.ok).toBe(true)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('allowed: counterpart has no queued consumer', () => {
|
|
533
|
+
const producer = entityWithId(1, [pushTask()])
|
|
534
|
+
const idleCounterpart = entityWithId(2, [])
|
|
535
|
+
const plan = cancelEligibility(producer, 0, 0, {
|
|
536
|
+
now: upcoming,
|
|
537
|
+
counterparts: new Map([['2', idleCounterpart]]),
|
|
538
|
+
counterpartIncoming: new Map([['2', incoming()]]),
|
|
539
|
+
})
|
|
540
|
+
expect(plan.ok).toBe(true)
|
|
541
|
+
})
|
|
542
|
+
})
|
package/src/scheduling/cancel.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type {ServerContract} from '../contracts'
|
|
2
2
|
import {HoldKind, TaskCancelable, TaskType} from '../types'
|
|
3
3
|
import {calcCargoItemMass} from '../capabilities/storage'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
taskCargoEffect,
|
|
6
|
+
cargoKey,
|
|
7
|
+
isIncomingCouplingKind,
|
|
8
|
+
type IncomingSource,
|
|
9
|
+
} from './availability'
|
|
5
10
|
import * as schedule from './schedule'
|
|
6
11
|
import {validateSchedule, type Projectable} from './projection'
|
|
7
12
|
|
|
@@ -11,6 +16,7 @@ export enum CancelBlockReason {
|
|
|
11
16
|
DONE = 'DONE',
|
|
12
17
|
CONTAINS_LINKED_TASK = 'CONTAINS_LINKED_TASK',
|
|
13
18
|
WOULD_STRAND = 'WOULD_STRAND',
|
|
19
|
+
WOULD_STRAND_COUNTERPART = 'WOULD_STRAND_COUNTERPART',
|
|
14
20
|
WOULD_OVERFILL = 'WOULD_OVERFILL',
|
|
15
21
|
NOT_OWNER = 'NOT_OWNER',
|
|
16
22
|
}
|
|
@@ -38,12 +44,14 @@ export interface CancelEffects {
|
|
|
38
44
|
export interface CancelPlan {
|
|
39
45
|
ok: boolean
|
|
40
46
|
blockedReason?: CancelBlockReason
|
|
47
|
+
blockedByCounterpart?: EntityRef
|
|
41
48
|
range: {count: number; taskIndices: number[]}
|
|
42
49
|
effects: CancelEffects
|
|
43
50
|
}
|
|
44
51
|
export interface CancelEligibilityInput {
|
|
45
52
|
now: Date
|
|
46
53
|
counterparts?: Map<string, EntityInfo>
|
|
54
|
+
counterpartIncoming?: Map<string, readonly IncomingSource[]>
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const EMPTY_EFFECTS: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
|
|
@@ -57,15 +65,18 @@ function postCancelEntity(entity: EntityInfo, laneKey: number, fromTaskIndex: nu
|
|
|
57
65
|
return clone
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
// C1: every pending consumer keeps its inputs covered by on-hand + own-lane producers strictly-before + incoming strictly-before.
|
|
69
|
+
function queueFeasible(entity: EntityInfo, incoming: readonly IncomingSource[] = []): boolean {
|
|
70
|
+
const ordered = schedule.orderedTasks(entity as unknown as Projectable)
|
|
62
71
|
const base = new Map<string, number>()
|
|
63
|
-
for (const c of
|
|
72
|
+
for (const c of entity.cargo ?? []) {
|
|
64
73
|
const k = cargoKey(c)
|
|
65
74
|
base.set(k, (base.get(k) ?? 0) + c.quantity.toNumber())
|
|
66
75
|
}
|
|
67
76
|
const isConsumer = (t: Task) =>
|
|
68
|
-
t.type.toNumber() === TaskType.CRAFT ||
|
|
77
|
+
t.type.toNumber() === TaskType.CRAFT ||
|
|
78
|
+
t.type.toNumber() === TaskType.UNLOAD ||
|
|
79
|
+
t.type.toNumber() === TaskType.UPGRADE
|
|
69
80
|
for (const self of ordered) {
|
|
70
81
|
if (!isConsumer(self.task)) continue
|
|
71
82
|
const map = new Map(base)
|
|
@@ -75,6 +86,12 @@ function feasibleAfterCancel(post: EntityInfo): boolean {
|
|
|
75
86
|
map.set(cargoKey(out), (map.get(cargoKey(out)) ?? 0) + out.quantity.toNumber())
|
|
76
87
|
}
|
|
77
88
|
}
|
|
89
|
+
for (const src of incoming) {
|
|
90
|
+
if (src.until.getTime() >= self.completesAt.getTime()) continue
|
|
91
|
+
for (const item of src.items) {
|
|
92
|
+
map.set(cargoKey(item), (map.get(cargoKey(item)) ?? 0) + item.quantity.toNumber())
|
|
93
|
+
}
|
|
94
|
+
}
|
|
78
95
|
for (const other of ordered) {
|
|
79
96
|
if (other === self) continue
|
|
80
97
|
for (const inp of taskCargoEffect(other.task).removed) {
|
|
@@ -86,6 +103,11 @@ function feasibleAfterCancel(post: EntityInfo): boolean {
|
|
|
86
103
|
if ((map.get(cargoKey(inp)) ?? 0) < inp.quantity.toNumber()) return false
|
|
87
104
|
}
|
|
88
105
|
}
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function feasibleAfterCancel(post: EntityInfo): boolean {
|
|
110
|
+
if (!queueFeasible(post)) return false
|
|
89
111
|
try {
|
|
90
112
|
validateSchedule(post as unknown as Projectable)
|
|
91
113
|
} catch {
|
|
@@ -162,6 +184,37 @@ export function cancelEligibility(
|
|
|
162
184
|
const post = postCancelEntity(entity, laneKey, fromTaskIndex)
|
|
163
185
|
if (!feasibleAfterCancel(post)) return block(CancelBlockReason.WOULD_STRAND)
|
|
164
186
|
|
|
187
|
+
// cross-entity C1: a counterpart losing this delivery must still feed its own queued consumers.
|
|
188
|
+
const releasedByCounterpart = new Map<string, {counterpart: EntityRef; holdIds: Set<string>}>()
|
|
189
|
+
for (const i of taskIndices) {
|
|
190
|
+
for (const c of tasks[i].couplings ?? []) {
|
|
191
|
+
if (!isIncomingCouplingKind(c.kind.toNumber())) continue
|
|
192
|
+
const id = c.counterpart.entity_id.toString()
|
|
193
|
+
const entry = releasedByCounterpart.get(id) ?? {
|
|
194
|
+
counterpart: c.counterpart,
|
|
195
|
+
holdIds: new Set<string>(),
|
|
196
|
+
}
|
|
197
|
+
entry.holdIds.add(c.hold.toString())
|
|
198
|
+
releasedByCounterpart.set(id, entry)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const [counterpartId, released] of releasedByCounterpart) {
|
|
202
|
+
const counterpart = input.counterparts?.get(counterpartId)
|
|
203
|
+
if (!counterpart) continue
|
|
204
|
+
const incoming = (input.counterpartIncoming?.get(counterpartId) ?? []).filter(
|
|
205
|
+
(src) => !released.holdIds.has(src.holdId)
|
|
206
|
+
)
|
|
207
|
+
if (!queueFeasible(counterpart, incoming)) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
blockedReason: CancelBlockReason.WOULD_STRAND_COUNTERPART,
|
|
211
|
+
blockedByCounterpart: released.counterpart,
|
|
212
|
+
range,
|
|
213
|
+
effects: {...EMPTY_EFFECTS},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
165
218
|
const effects: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
|
|
166
219
|
let energyForfeited = 0
|
|
167
220
|
for (const i of taskIndices) {
|