@shipload/sdk 1.0.0-next.34 → 1.0.0-next.36
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 +398 -51
- package/lib/shipload.js +1481 -400
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +1442 -401
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.d.ts +101 -20
- package/lib/testing.js +201 -57
- package/lib/testing.js.map +1 -1
- package/lib/testing.m.js +201 -57
- package/lib/testing.m.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities/crafting.ts +2 -3
- package/src/capabilities/gathering.test.ts +16 -0
- package/src/capabilities/gathering.ts +8 -11
- package/src/contracts/server.ts +147 -29
- package/src/coordinates/address.ts +88 -0
- package/src/coordinates/constants.test.ts +15 -0
- package/src/coordinates/constants.ts +23 -0
- package/src/coordinates/index.ts +15 -0
- package/src/coordinates/memo.test.ts +47 -0
- package/src/coordinates/memo.ts +20 -0
- package/src/coordinates/permutation.ts +77 -0
- package/src/coordinates/regions.ts +48 -0
- package/src/coordinates/sectors.ts +115 -0
- package/src/data/capability-formulas.ts +0 -1
- package/src/data/entities.json +4 -4
- package/src/data/items.json +5 -5
- package/src/data/recipes.json +39 -65
- package/src/derivation/capabilities.test.ts +133 -0
- package/src/derivation/capabilities.ts +66 -14
- package/src/derivation/rollups.test.ts +55 -0
- package/src/derivation/rollups.ts +56 -0
- package/src/derivation/wormhole.ts +115 -0
- package/src/entities/makers.ts +30 -3
- package/src/errors.ts +2 -0
- package/src/index-module.ts +38 -2
- package/src/managers/actions.ts +79 -5
- package/src/managers/construction.ts +6 -4
- package/src/managers/context.ts +9 -0
- package/src/managers/coordinates.ts +14 -0
- package/src/managers/plot.ts +2 -4
- package/src/nft/description.ts +25 -6
- package/src/planner/index.ts +127 -0
- package/src/planner/planner.test.ts +319 -0
- package/src/resolution/resolve-item.ts +4 -1
- package/src/scheduling/availability.ts +1 -1
- package/src/scheduling/cancel.test.ts +348 -0
- package/src/scheduling/cancel.ts +209 -0
- package/src/scheduling/lanes.test.ts +249 -0
- package/src/scheduling/lanes.ts +140 -2
- package/src/scheduling/projection.ts +75 -16
- package/src/scheduling/schedule.ts +3 -1
- package/src/shipload.ts +5 -0
- package/src/testing/projection-parity.ts +26 -2
- package/src/travel/travel.ts +116 -105
- package/src/types/capabilities.ts +23 -6
- package/src/types/entity.ts +3 -3
- package/src/types.ts +2 -1
- package/src/utils/system.ts +11 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import {UInt8, UInt16, UInt32, UInt64} from '@wharfkit/antelope'
|
|
2
|
+
import {expect, test, describe} from 'bun:test'
|
|
3
|
+
import type {GathererStats} from '../types/capabilities'
|
|
4
|
+
import {ServerContract} from '../contracts'
|
|
5
|
+
import {calc_gather_duration} from '../capabilities/gathering'
|
|
6
|
+
import {planParallelGather, planParallelTransfer, type GatherPlanEntity} from './index'
|
|
7
|
+
|
|
8
|
+
function gathererLane(
|
|
9
|
+
slotIndex: number,
|
|
10
|
+
yieldVal: number,
|
|
11
|
+
drain: number,
|
|
12
|
+
depth: number
|
|
13
|
+
): ServerContract.Types.gatherer_lane {
|
|
14
|
+
return ServerContract.Types.gatherer_lane.from({
|
|
15
|
+
slot_index: UInt8.from(slotIndex),
|
|
16
|
+
yield: UInt16.from(yieldVal),
|
|
17
|
+
drain: UInt32.from(drain),
|
|
18
|
+
depth: UInt16.from(depth),
|
|
19
|
+
output_pct: UInt16.from(100),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loaderLane(
|
|
24
|
+
slotIndex: number,
|
|
25
|
+
mass: number,
|
|
26
|
+
thrust: number
|
|
27
|
+
): ServerContract.Types.loader_lane {
|
|
28
|
+
return ServerContract.Types.loader_lane.from({
|
|
29
|
+
slot_index: UInt8.from(slotIndex),
|
|
30
|
+
mass: UInt32.from(mass),
|
|
31
|
+
thrust: UInt16.from(thrust),
|
|
32
|
+
output_pct: UInt16.from(100),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function energyStats(capacity: number, recharge: number): ServerContract.Types.energy_stats {
|
|
37
|
+
return ServerContract.Types.energy_stats.from({
|
|
38
|
+
capacity: UInt32.from(capacity),
|
|
39
|
+
recharge: UInt32.from(recharge),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EntityOverrides {
|
|
44
|
+
gatherer_lanes?: ServerContract.Types.gatherer_lane[]
|
|
45
|
+
loader_lanes?: ServerContract.Types.loader_lane[]
|
|
46
|
+
generator?: ServerContract.Types.energy_stats
|
|
47
|
+
energy?: number
|
|
48
|
+
lanes?: ServerContract.Types.lane[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function entity(overrides: EntityOverrides = {}): GatherPlanEntity {
|
|
52
|
+
return {
|
|
53
|
+
gatherer_lanes: overrides.gatherer_lanes ?? [],
|
|
54
|
+
loader_lanes: overrides.loader_lanes ?? [],
|
|
55
|
+
generator: overrides.generator,
|
|
56
|
+
energy: overrides.energy !== undefined ? UInt16.from(overrides.energy) : undefined,
|
|
57
|
+
lanes: overrides.lanes ?? [],
|
|
58
|
+
coordinates: ServerContract.Types.coordinates.from({x: 0, y: 0}),
|
|
59
|
+
cargo: [],
|
|
60
|
+
cargomass: UInt32.from(0),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NOW = new Date('2026-06-21T00:00:00.000Z')
|
|
65
|
+
|
|
66
|
+
describe('planParallelGather', () => {
|
|
67
|
+
test('sanity: single-gatherer qty 20 = ~35s matches calc_gather_duration', () => {
|
|
68
|
+
const gatherer: GathererStats = {
|
|
69
|
+
yield: UInt16.from(57),
|
|
70
|
+
drain: UInt32.from(500),
|
|
71
|
+
depth: UInt16.from(5000),
|
|
72
|
+
}
|
|
73
|
+
const dur = calc_gather_duration(gatherer, 228, 20, 0, 1000)
|
|
74
|
+
expect(Number(dur)).toBeCloseTo(35, 0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('two gatherers: quantities proportional to yield, durations within 1s', () => {
|
|
78
|
+
const YIELD1 = 200
|
|
79
|
+
const YIELD2 = 400
|
|
80
|
+
const DEPTH = 5000
|
|
81
|
+
const DRAIN = 500
|
|
82
|
+
const QUANTITY = 60
|
|
83
|
+
const STRATUM = 0
|
|
84
|
+
|
|
85
|
+
const e = entity({
|
|
86
|
+
gatherer_lanes: [
|
|
87
|
+
gathererLane(0, YIELD1, DRAIN, DEPTH),
|
|
88
|
+
gathererLane(1, YIELD2, DRAIN, DEPTH),
|
|
89
|
+
],
|
|
90
|
+
generator: energyStats(10000, 100),
|
|
91
|
+
energy: 10000,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const plan = planParallelGather(e, {quantity: QUANTITY}, STRATUM, NOW)
|
|
95
|
+
|
|
96
|
+
expect(plan).toHaveLength(2)
|
|
97
|
+
expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
|
|
98
|
+
|
|
99
|
+
const q1 = plan.find((p) => p.slot === 0)!.quantity
|
|
100
|
+
const q2 = plan.find((p) => p.slot === 1)!.quantity
|
|
101
|
+
expect(q1 + q2).toBe(QUANTITY)
|
|
102
|
+
expect(q2 / q1).toBeCloseTo(YIELD2 / YIELD1, 0)
|
|
103
|
+
|
|
104
|
+
const ITEM_MASS = 228
|
|
105
|
+
const RICHNESS = 1000
|
|
106
|
+
const g1: GathererStats = {
|
|
107
|
+
yield: UInt16.from(YIELD1),
|
|
108
|
+
drain: UInt32.from(DRAIN),
|
|
109
|
+
depth: UInt16.from(DEPTH),
|
|
110
|
+
}
|
|
111
|
+
const g2: GathererStats = {
|
|
112
|
+
yield: UInt16.from(YIELD2),
|
|
113
|
+
drain: UInt32.from(DRAIN),
|
|
114
|
+
depth: UInt16.from(DEPTH),
|
|
115
|
+
}
|
|
116
|
+
const dur1 = Number(calc_gather_duration(g1, ITEM_MASS, q1, STRATUM, RICHNESS))
|
|
117
|
+
const dur2 = Number(calc_gather_duration(g2, ITEM_MASS, q2, STRATUM, RICHNESS))
|
|
118
|
+
expect(Math.abs(dur1 - dur2)).toBeLessThan(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("'max' target: uses all reaching lanes, each slot gets >= 1 unit", () => {
|
|
122
|
+
const e = entity({
|
|
123
|
+
gatherer_lanes: [gathererLane(0, 200, 500, 5000), gathererLane(1, 300, 500, 5000)],
|
|
124
|
+
generator: energyStats(10000, 100),
|
|
125
|
+
energy: 10000,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const plan = planParallelGather(e, 'max', 0, NOW)
|
|
129
|
+
|
|
130
|
+
expect(plan.length).toBeGreaterThan(0)
|
|
131
|
+
for (const entry of plan) {
|
|
132
|
+
expect(entry.quantity).toBeGreaterThanOrEqual(1)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('energy-starved: drops the lowest-yield lane(s) until the pool sustains the plan', () => {
|
|
137
|
+
// Full-Q energy: 3 lanes=>144, 2 lanes(drop yield=50)=>120; pool 130 fits 2 but not 3.
|
|
138
|
+
const e = entity({
|
|
139
|
+
gatherer_lanes: [
|
|
140
|
+
gathererLane(0, 50, 10000, 5000),
|
|
141
|
+
gathererLane(1, 100, 10000, 5000),
|
|
142
|
+
gathererLane(2, 100, 10000, 5000),
|
|
143
|
+
],
|
|
144
|
+
generator: energyStats(10000, 1),
|
|
145
|
+
energy: 130,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
|
|
149
|
+
|
|
150
|
+
// Lowest-yield lane (slot 0) dropped; the two yield-100 lanes survive.
|
|
151
|
+
expect(plan).toHaveLength(2)
|
|
152
|
+
expect(plan.find((p) => p.slot === 0)).toBeUndefined()
|
|
153
|
+
expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(120)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('energy-starved: single lane caps quantity to the sustainable max', () => {
|
|
157
|
+
// energyPerUnit=1, full Q=120 costs 120 > pool 50, so quantity caps at 50.
|
|
158
|
+
const e = entity({
|
|
159
|
+
gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
|
|
160
|
+
generator: energyStats(10000, 1),
|
|
161
|
+
energy: 50,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
|
|
165
|
+
|
|
166
|
+
expect(plan).toHaveLength(1)
|
|
167
|
+
expect(plan[0].slot).toBe(0)
|
|
168
|
+
expect(plan[0].quantity).toBe(50)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('energy-starved: projected energy nets out a queued gather task', () => {
|
|
172
|
+
// A queued task costing 9970 leaves 30 projected energy => quantity caps at 30.
|
|
173
|
+
const queued = ServerContract.Types.lane.from({
|
|
174
|
+
lane_key: UInt8.from(0),
|
|
175
|
+
schedule: {
|
|
176
|
+
started: NOW.toISOString().slice(0, -1),
|
|
177
|
+
tasks: [
|
|
178
|
+
ServerContract.Types.task.from({
|
|
179
|
+
type: UInt8.from(5),
|
|
180
|
+
duration: UInt32.from(100),
|
|
181
|
+
cancelable: 0,
|
|
182
|
+
cargo: [],
|
|
183
|
+
entitytarget: {entity_type: 'ship', entity_id: UInt64.from(1)},
|
|
184
|
+
energy_cost: UInt32.from(9970),
|
|
185
|
+
}),
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const e = entity({
|
|
191
|
+
gatherer_lanes: [gathererLane(0, 100, 10000, 5000)],
|
|
192
|
+
generator: energyStats(10000, 1),
|
|
193
|
+
energy: 10000,
|
|
194
|
+
lanes: [queued],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const plan = planParallelGather(e, {quantity: 120}, 0, NOW)
|
|
198
|
+
|
|
199
|
+
expect(plan).toHaveLength(1)
|
|
200
|
+
expect(plan[0].quantity).toBe(30)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('stratum filter: shallow lane excluded, only deep lane used', () => {
|
|
204
|
+
const e = entity({
|
|
205
|
+
gatherer_lanes: [gathererLane(0, 200, 500, 500), gathererLane(1, 300, 500, 5000)],
|
|
206
|
+
generator: energyStats(10000, 100),
|
|
207
|
+
energy: 10000,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const plan = planParallelGather(e, {quantity: 10}, 2000, NOW)
|
|
211
|
+
|
|
212
|
+
expect(plan).toHaveLength(1)
|
|
213
|
+
expect(plan[0].slot).toBe(1)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('no reaching gatherers throws', () => {
|
|
217
|
+
const e = entity({
|
|
218
|
+
gatherer_lanes: [gathererLane(0, 200, 500, 100)],
|
|
219
|
+
generator: energyStats(10000, 100),
|
|
220
|
+
energy: 10000,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(() => planParallelGather(e, {quantity: 10}, 2000, NOW)).toThrow(
|
|
224
|
+
'no gatherer reaches this stratum'
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('two identical gatherers: per-lane quantity halved, durations equal and within 1s', () => {
|
|
229
|
+
const YIELD = 200
|
|
230
|
+
const DEPTH = 5000
|
|
231
|
+
const DRAIN = 500
|
|
232
|
+
const QUANTITY = 60
|
|
233
|
+
const STRATUM = 0
|
|
234
|
+
|
|
235
|
+
const eSingle = entity({
|
|
236
|
+
gatherer_lanes: [gathererLane(0, YIELD, DRAIN, DEPTH)],
|
|
237
|
+
generator: energyStats(10000, 100),
|
|
238
|
+
energy: 10000,
|
|
239
|
+
})
|
|
240
|
+
const eDouble = entity({
|
|
241
|
+
gatherer_lanes: [
|
|
242
|
+
gathererLane(0, YIELD, DRAIN, DEPTH),
|
|
243
|
+
gathererLane(1, YIELD, DRAIN, DEPTH),
|
|
244
|
+
],
|
|
245
|
+
generator: energyStats(10000, 100),
|
|
246
|
+
energy: 10000,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const planSingle = planParallelGather(eSingle, {quantity: QUANTITY}, STRATUM, NOW)
|
|
250
|
+
const planDouble = planParallelGather(eDouble, {quantity: QUANTITY}, STRATUM, NOW)
|
|
251
|
+
|
|
252
|
+
expect(planSingle).toHaveLength(1)
|
|
253
|
+
expect(planDouble).toHaveLength(2)
|
|
254
|
+
|
|
255
|
+
const singleQ = planSingle[0].quantity
|
|
256
|
+
const doubleQ1 = planDouble[0].quantity
|
|
257
|
+
const doubleQ2 = planDouble[1].quantity
|
|
258
|
+
expect(doubleQ1 + doubleQ2).toBe(QUANTITY)
|
|
259
|
+
|
|
260
|
+
const ITEM_MASS = 228
|
|
261
|
+
const RICHNESS = 1000
|
|
262
|
+
const g: GathererStats = {
|
|
263
|
+
yield: UInt16.from(YIELD),
|
|
264
|
+
drain: UInt32.from(DRAIN),
|
|
265
|
+
depth: UInt16.from(DEPTH),
|
|
266
|
+
}
|
|
267
|
+
const durSingle = Number(calc_gather_duration(g, ITEM_MASS, singleQ, STRATUM, RICHNESS))
|
|
268
|
+
const durDouble1 = Number(calc_gather_duration(g, ITEM_MASS, doubleQ1, STRATUM, RICHNESS))
|
|
269
|
+
const durDouble2 = Number(calc_gather_duration(g, ITEM_MASS, doubleQ2, STRATUM, RICHNESS))
|
|
270
|
+
|
|
271
|
+
expect(durDouble1).toBeCloseTo(durSingle / 2, 0)
|
|
272
|
+
expect(durDouble2).toBeCloseTo(durSingle / 2, 0)
|
|
273
|
+
expect(Math.abs(durDouble1 - durDouble2)).toBeLessThan(1)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('planParallelTransfer', () => {
|
|
278
|
+
test('two loader lanes: quantities proportional to thrust, sums to target', () => {
|
|
279
|
+
const THRUST1 = 100
|
|
280
|
+
const THRUST2 = 200
|
|
281
|
+
const QUANTITY = 90
|
|
282
|
+
|
|
283
|
+
const e = entity({
|
|
284
|
+
loader_lanes: [loaderLane(0, 500, THRUST1), loaderLane(1, 500, THRUST2)],
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const plan = planParallelTransfer(e, {quantity: QUANTITY})
|
|
288
|
+
|
|
289
|
+
expect(plan).toHaveLength(2)
|
|
290
|
+
expect(plan.reduce((s, p) => s + p.quantity, 0)).toBe(QUANTITY)
|
|
291
|
+
|
|
292
|
+
const q1 = plan.find((p) => p.slot === 0)!.quantity
|
|
293
|
+
const q2 = plan.find((p) => p.slot === 1)!.quantity
|
|
294
|
+
expect(q2 / q1).toBeCloseTo(THRUST2 / THRUST1, 0)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('no loader lanes: returns empty plan', () => {
|
|
298
|
+
const plan = planParallelTransfer(entity({loader_lanes: []}), {quantity: 10})
|
|
299
|
+
expect(plan).toHaveLength(0)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('thrust=0 loader lane (no-loader/mobility case): returns empty plan', () => {
|
|
303
|
+
const plan = planParallelTransfer(entity({loader_lanes: [loaderLane(0, 500, 0)]}), {
|
|
304
|
+
quantity: 10,
|
|
305
|
+
})
|
|
306
|
+
expect(plan).toHaveLength(0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("'max' target: each loader lane gets >= 1 unit", () => {
|
|
310
|
+
const e = entity({
|
|
311
|
+
loader_lanes: [loaderLane(0, 500, 100), loaderLane(1, 500, 200)],
|
|
312
|
+
})
|
|
313
|
+
const plan = planParallelTransfer(e, 'max')
|
|
314
|
+
expect(plan.length).toBeGreaterThan(0)
|
|
315
|
+
for (const entry of plan) {
|
|
316
|
+
expect(entry.quantity).toBeGreaterThanOrEqual(1)
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
})
|
|
@@ -287,7 +287,10 @@ function resolveEntity(
|
|
|
287
287
|
let moduleSlots: ResolvedModuleSlot[] | undefined
|
|
288
288
|
|
|
289
289
|
if (stats !== undefined) {
|
|
290
|
-
const
|
|
290
|
+
const bigStats = toBigStats(stats)
|
|
291
|
+
const decoded = decodeCraftedItemStats(id, bigStats)
|
|
292
|
+
if (decoded.strength === undefined) decoded.strength = decodeStat(bigStats, 0)
|
|
293
|
+
if (decoded.hardness === undefined) decoded.hardness = decodeStat(bigStats, 2)
|
|
291
294
|
const hullCaps = hullCapsForEntity(id, decoded)
|
|
292
295
|
attributes = [
|
|
293
296
|
{
|
|
@@ -36,7 +36,7 @@ export function taskCargoEffect(task: Task): CargoEffect {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function cargoKey(item: CargoItem): string {
|
|
39
|
+
export function cargoKey(item: CargoItem): string {
|
|
40
40
|
const base = `${item.item_id.toNumber()}:${item.stats.toString()}`
|
|
41
41
|
const modules = item.modules ?? []
|
|
42
42
|
const entityId = item.entity_id?.toString()
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {ServerContract, TaskType, TaskCancelable} from '../index-module'
|
|
3
|
+
import {cancelEligibility, CancelBlockReason} from './cancel'
|
|
4
|
+
|
|
5
|
+
const T0 = '2026-06-19T00:00:00'
|
|
6
|
+
|
|
7
|
+
function task(over: Partial<{type: number; duration: number; cancelable: number; group: number}>) {
|
|
8
|
+
return ServerContract.Types.task.from({
|
|
9
|
+
type: over.type ?? TaskType.TRAVEL,
|
|
10
|
+
duration: over.duration ?? 100,
|
|
11
|
+
cancelable: over.cancelable ?? TaskCancelable.BEFORE_START,
|
|
12
|
+
cargo: [],
|
|
13
|
+
...(over.group ? {entitygroup: over.group} : {}),
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function entity(tasks: ReturnType<typeof task>[], startedISO = T0) {
|
|
18
|
+
return ServerContract.Types.entity_info.from({
|
|
19
|
+
type: 'ship',
|
|
20
|
+
id: 1,
|
|
21
|
+
owner: 'player.gm',
|
|
22
|
+
entity_name: 'Ship 1',
|
|
23
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
24
|
+
item_id: 1,
|
|
25
|
+
cargomass: 0,
|
|
26
|
+
cargo: [],
|
|
27
|
+
modules: [],
|
|
28
|
+
lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
|
|
29
|
+
gatherer_lanes: [],
|
|
30
|
+
crafter_lanes: [],
|
|
31
|
+
loader_lanes: [],
|
|
32
|
+
holds: [],
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('cancelEligibility — local gates', () => {
|
|
37
|
+
const now = new Date('2026-06-19T00:00:10.000Z') // 10s in: task 0 running, task 1 upcoming
|
|
38
|
+
|
|
39
|
+
test('cancelling the last upcoming task: ok, count 1', () => {
|
|
40
|
+
const e = entity([task({}), task({})])
|
|
41
|
+
const plan = cancelEligibility(e, 0, 1, {now})
|
|
42
|
+
expect(plan.ok).toBe(true)
|
|
43
|
+
expect(plan.range.count).toBe(1)
|
|
44
|
+
expect(plan.range.taskIndices).toEqual([1])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('NEVER task is blocked', () => {
|
|
48
|
+
const e = entity([
|
|
49
|
+
task({}),
|
|
50
|
+
task({cancelable: TaskCancelable.NEVER, type: TaskType.GATHER}),
|
|
51
|
+
])
|
|
52
|
+
expect(cancelEligibility(e, 0, 1, {now}).blockedReason).toBe(CancelBlockReason.TASK_NEVER)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('BEFORE_START task that is running is blocked', () => {
|
|
56
|
+
const e = entity([task({cancelable: TaskCancelable.BEFORE_START, duration: 100})])
|
|
57
|
+
// task 0 is running at now=10s
|
|
58
|
+
expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
|
|
59
|
+
CancelBlockReason.BEFORE_START_RUNNING
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('done task is blocked', () => {
|
|
64
|
+
const e = entity([task({duration: 5})]) // completes at 5s, now=10s
|
|
65
|
+
expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(CancelBlockReason.DONE)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('unknown lane: count 0, ok false', () => {
|
|
69
|
+
const e = entity([task({})])
|
|
70
|
+
const plan = cancelEligibility(e, 9, 0, {now})
|
|
71
|
+
expect(plan.ok).toBe(false)
|
|
72
|
+
expect(plan.range.count).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('multi-task range: 4 tasks, fromIndex 1 yields count 3', () => {
|
|
76
|
+
const e = entity([
|
|
77
|
+
task({duration: 100}),
|
|
78
|
+
task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
|
|
79
|
+
task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
|
|
80
|
+
task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
|
|
81
|
+
])
|
|
82
|
+
const plan = cancelEligibility(e, 0, 1, {now})
|
|
83
|
+
expect(plan.ok).toBe(true)
|
|
84
|
+
expect(plan.range.count).toBe(3)
|
|
85
|
+
expect(plan.range.taskIndices).toEqual([1, 2, 3])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('cancelEligibility — linked tasks', () => {
|
|
90
|
+
const now = new Date('2026-06-19T00:00:10.000Z')
|
|
91
|
+
test('range containing a linked (entitygroup) task is blocked', () => {
|
|
92
|
+
const e = entity([task({}), task({group: 42}), task({})])
|
|
93
|
+
expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
|
|
94
|
+
CancelBlockReason.CONTAINS_LINKED_TASK
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
test('task after the linked one cancels normally', () => {
|
|
98
|
+
const e = entity([task({}), task({group: 42}), task({})])
|
|
99
|
+
expect(cancelEligibility(e, 0, 2, {now}).ok).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const HOLD_PULL = 1
|
|
104
|
+
function loadTask(giverType: string, giverId: number, holdId: number, qty: number) {
|
|
105
|
+
return ServerContract.Types.task.from({
|
|
106
|
+
type: TaskType.LOAD,
|
|
107
|
+
duration: 100,
|
|
108
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
109
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: qty}],
|
|
110
|
+
entitytarget: {entity_type: giverType, entity_id: giverId},
|
|
111
|
+
hold: holdId,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('cancelEligibility — effects', () => {
|
|
116
|
+
const now = new Date('2026-06-19T00:00:10.000Z')
|
|
117
|
+
|
|
118
|
+
test('abandonsRunning true when the front of range is a running ALWAYS task', () => {
|
|
119
|
+
const e = entity([
|
|
120
|
+
task({cancelable: TaskCancelable.ALWAYS, type: TaskType.RECHARGE, duration: 100}),
|
|
121
|
+
])
|
|
122
|
+
expect(cancelEligibility(e, 0, 0, {now}).effects.abandonsRunning).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('buildplot cancel reports keepsPlotDeposits', () => {
|
|
126
|
+
const e = entity([
|
|
127
|
+
ServerContract.Types.task.from({
|
|
128
|
+
type: TaskType.BUILDPLOT,
|
|
129
|
+
duration: 100,
|
|
130
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
131
|
+
cargo: [],
|
|
132
|
+
entitytarget: {entity_type: 'plot', entity_id: 55},
|
|
133
|
+
}),
|
|
134
|
+
])
|
|
135
|
+
const plan = cancelEligibility(e, 0, 0, {now})
|
|
136
|
+
expect(plan.effects.keepsPlotDeposits?.plot.entity_id.toNumber()).toBe(55)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('PULL load cancel refunds cargo to the giver', () => {
|
|
140
|
+
const lt = loadTask('warehouse', 6, 1, 4)
|
|
141
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
142
|
+
const e = ServerContract.Types.entity_info.from({
|
|
143
|
+
type: 'ship',
|
|
144
|
+
id: 1,
|
|
145
|
+
owner: 'player.gm',
|
|
146
|
+
entity_name: 'Ship 1',
|
|
147
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
148
|
+
item_id: 1,
|
|
149
|
+
cargomass: 0,
|
|
150
|
+
cargo: [],
|
|
151
|
+
modules: [],
|
|
152
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
153
|
+
gatherer_lanes: [],
|
|
154
|
+
crafter_lanes: [],
|
|
155
|
+
loader_lanes: [],
|
|
156
|
+
holds: [
|
|
157
|
+
{
|
|
158
|
+
id: 1,
|
|
159
|
+
kind: HOLD_PULL,
|
|
160
|
+
counterpart: {entity_type: 'warehouse', entity_id: 6},
|
|
161
|
+
until: T0,
|
|
162
|
+
incoming_mass: 0,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
})
|
|
166
|
+
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
167
|
+
expect(plan.effects.refunds[0]?.giver.entity_id.toNumber()).toBe(6)
|
|
168
|
+
expect(plan.effects.refunds[0]?.cargo[0].quantity.toNumber()).toBe(4)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('PULL load cancel populates releasedHolds with kind and counterpart', () => {
|
|
172
|
+
const lt = loadTask('warehouse', 6, 1, 4)
|
|
173
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
174
|
+
const e = ServerContract.Types.entity_info.from({
|
|
175
|
+
type: 'ship',
|
|
176
|
+
id: 1,
|
|
177
|
+
owner: 'player.gm',
|
|
178
|
+
entity_name: 'Ship 1',
|
|
179
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
180
|
+
item_id: 1,
|
|
181
|
+
cargomass: 0,
|
|
182
|
+
cargo: [],
|
|
183
|
+
modules: [],
|
|
184
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
185
|
+
gatherer_lanes: [],
|
|
186
|
+
crafter_lanes: [],
|
|
187
|
+
loader_lanes: [],
|
|
188
|
+
holds: [
|
|
189
|
+
{
|
|
190
|
+
id: 1,
|
|
191
|
+
kind: HOLD_PULL,
|
|
192
|
+
counterpart: {entity_type: 'warehouse', entity_id: 6},
|
|
193
|
+
until: T0,
|
|
194
|
+
incoming_mass: 0,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
})
|
|
198
|
+
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
199
|
+
expect(plan.effects.releasedHolds[0]?.kind).toBe(1)
|
|
200
|
+
expect(plan.effects.releasedHolds[0]?.counterpart.entity_id.toNumber()).toBe(6)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('unresolvable hold emits no releasedHolds entry', () => {
|
|
204
|
+
const lt = loadTask('warehouse', 6, 99, 4)
|
|
205
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
206
|
+
const e = ServerContract.Types.entity_info.from({
|
|
207
|
+
type: 'ship',
|
|
208
|
+
id: 1,
|
|
209
|
+
owner: 'player.gm',
|
|
210
|
+
entity_name: 'Ship 1',
|
|
211
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
212
|
+
item_id: 1,
|
|
213
|
+
cargomass: 0,
|
|
214
|
+
cargo: [],
|
|
215
|
+
modules: [],
|
|
216
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
217
|
+
gatherer_lanes: [],
|
|
218
|
+
crafter_lanes: [],
|
|
219
|
+
loader_lanes: [],
|
|
220
|
+
holds: [],
|
|
221
|
+
})
|
|
222
|
+
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
223
|
+
expect(plan.effects.releasedHolds).toHaveLength(0)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('cancelEligibility — feasibility', () => {
|
|
228
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
229
|
+
|
|
230
|
+
test('WOULD_STRAND when cancelling a producer a later consumer needs', () => {
|
|
231
|
+
const producer = ServerContract.Types.task.from({
|
|
232
|
+
type: TaskType.LOAD,
|
|
233
|
+
duration: 50,
|
|
234
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
235
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
|
|
236
|
+
})
|
|
237
|
+
const consumer = ServerContract.Types.task.from({
|
|
238
|
+
type: TaskType.CRAFT,
|
|
239
|
+
duration: 50,
|
|
240
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
241
|
+
cargo: [
|
|
242
|
+
{item_id: 7, stats: 0, modules: [], quantity: 2},
|
|
243
|
+
{item_id: 9, stats: 0, modules: [], quantity: 1},
|
|
244
|
+
],
|
|
245
|
+
})
|
|
246
|
+
const e = ServerContract.Types.entity_info.from({
|
|
247
|
+
type: 'ship',
|
|
248
|
+
id: 1,
|
|
249
|
+
owner: 'player.gm',
|
|
250
|
+
entity_name: 'S',
|
|
251
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
252
|
+
item_id: 1,
|
|
253
|
+
cargomass: 0,
|
|
254
|
+
cargo: [],
|
|
255
|
+
modules: [],
|
|
256
|
+
lanes: [
|
|
257
|
+
{
|
|
258
|
+
lane_key: 1,
|
|
259
|
+
schedule: {started: '2026-06-19T00:00:00', tasks: [producer]},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
lane_key: 2,
|
|
263
|
+
schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
gatherer_lanes: [],
|
|
267
|
+
crafter_lanes: [],
|
|
268
|
+
loader_lanes: [],
|
|
269
|
+
holds: [],
|
|
270
|
+
})
|
|
271
|
+
expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
|
|
272
|
+
CancelBlockReason.WOULD_STRAND
|
|
273
|
+
)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('benign cancel on independent lane does NOT strand', () => {
|
|
277
|
+
const producer = ServerContract.Types.task.from({
|
|
278
|
+
type: TaskType.LOAD,
|
|
279
|
+
duration: 50,
|
|
280
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
281
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
|
|
282
|
+
})
|
|
283
|
+
const independent = ServerContract.Types.task.from({
|
|
284
|
+
type: TaskType.TRAVEL,
|
|
285
|
+
duration: 50,
|
|
286
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
287
|
+
cargo: [],
|
|
288
|
+
})
|
|
289
|
+
const e = ServerContract.Types.entity_info.from({
|
|
290
|
+
type: 'ship',
|
|
291
|
+
id: 1,
|
|
292
|
+
owner: 'player.gm',
|
|
293
|
+
entity_name: 'S',
|
|
294
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
295
|
+
item_id: 1,
|
|
296
|
+
cargomass: 0,
|
|
297
|
+
cargo: [],
|
|
298
|
+
modules: [],
|
|
299
|
+
lanes: [
|
|
300
|
+
{lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
|
|
301
|
+
{lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
|
|
302
|
+
],
|
|
303
|
+
gatherer_lanes: [],
|
|
304
|
+
crafter_lanes: [],
|
|
305
|
+
loader_lanes: [],
|
|
306
|
+
holds: [],
|
|
307
|
+
})
|
|
308
|
+
expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('WOULD_STRAND when cancelling producer of a MODULAR cargo the consumer needs', () => {
|
|
312
|
+
const moduledCargo = {item_id: 7, stats: 0, modules: [{type: 3}], quantity: 2}
|
|
313
|
+
const producer = ServerContract.Types.task.from({
|
|
314
|
+
type: TaskType.LOAD,
|
|
315
|
+
duration: 50,
|
|
316
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
317
|
+
cargo: [moduledCargo],
|
|
318
|
+
})
|
|
319
|
+
const consumer = ServerContract.Types.task.from({
|
|
320
|
+
type: TaskType.CRAFT,
|
|
321
|
+
duration: 50,
|
|
322
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
323
|
+
cargo: [moduledCargo, {item_id: 9, stats: 0, modules: [], quantity: 1}],
|
|
324
|
+
})
|
|
325
|
+
const e = ServerContract.Types.entity_info.from({
|
|
326
|
+
type: 'ship',
|
|
327
|
+
id: 1,
|
|
328
|
+
owner: 'player.gm',
|
|
329
|
+
entity_name: 'S',
|
|
330
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
331
|
+
item_id: 1,
|
|
332
|
+
cargomass: 0,
|
|
333
|
+
cargo: [],
|
|
334
|
+
modules: [],
|
|
335
|
+
lanes: [
|
|
336
|
+
{lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
|
|
337
|
+
{lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
|
|
338
|
+
],
|
|
339
|
+
gatherer_lanes: [],
|
|
340
|
+
crafter_lanes: [],
|
|
341
|
+
loader_lanes: [],
|
|
342
|
+
holds: [],
|
|
343
|
+
})
|
|
344
|
+
expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
|
|
345
|
+
CancelBlockReason.WOULD_STRAND
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
})
|