@shipload/sdk 1.0.0-next.73 → 1.0.0-next.75

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.
Files changed (42) hide show
  1. package/lib/shipload.d.ts +514 -127
  2. package/lib/shipload.js +5952 -4948
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +5936 -4942
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +174 -47
  7. package/lib/testing.js +539 -147
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +539 -147
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/contracts/server.ts +457 -134
  13. package/src/data/component-process.test.ts +119 -0
  14. package/src/data/component-process.ts +33 -0
  15. package/src/data/entities.json +2 -2
  16. package/src/data/metadata.ts +18 -5
  17. package/src/index-module.ts +29 -2
  18. package/src/influence/civic-shuttle.test.ts +58 -0
  19. package/src/influence/civic-shuttle.ts +52 -6
  20. package/src/influence/duration.ts +0 -65
  21. package/src/influence/index.ts +4 -5
  22. package/src/influence/influence.test.ts +51 -136
  23. package/src/influence/valuation.ts +75 -0
  24. package/src/managers/actions.ts +37 -10
  25. package/src/managers/balances.test.ts +33 -0
  26. package/src/managers/balances.ts +15 -0
  27. package/src/managers/context.ts +50 -11
  28. package/src/managers/index.ts +14 -0
  29. package/src/managers/jobs.test.ts +82 -1
  30. package/src/managers/jobs.ts +41 -2
  31. package/src/managers/nft.test.ts +173 -1
  32. package/src/managers/nft.ts +35 -2
  33. package/src/managers/shuttle.test.ts +562 -0
  34. package/src/managers/shuttle.ts +397 -0
  35. package/src/resolution/resolve-item.ts +4 -0
  36. package/src/scheduling/availability.ts +10 -0
  37. package/src/scheduling/energy.ts +55 -1
  38. package/src/scheduling/jobs.test.ts +171 -1
  39. package/src/scheduling/jobs.ts +110 -13
  40. package/src/scheduling/projection.ts +2 -1
  41. package/src/scheduling/task-effects.ts +38 -0
  42. package/src/shipload.ts +23 -4
@@ -0,0 +1,562 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {Name, type NameType, TimePoint, UInt16, UInt64} from '@wharfkit/antelope'
3
+ import {ITEM_LOADER_T1} from '../data/item-ids'
4
+ import {ENTITY_DEPOT, ENTITY_SHIP, ENTITY_WORKSHOP} from '../data/kind-registry'
5
+ import type {CandidateEntity, ShuttleReasonCode} from './shuttle'
6
+ import {
7
+ BOOKING_LEVEL_CODES,
8
+ OMITTED_REASONS,
9
+ rankShuttleOptions,
10
+ ShuttleManager,
11
+ shuttleCandidates,
12
+ shuttleReasonCode,
13
+ } from './shuttle'
14
+
15
+ function managerWithReadonly(impl: (name: string, data: unknown) => Promise<unknown>) {
16
+ const ctx = {server: {readonly: impl}} as never
17
+ return new ShuttleManager(ctx)
18
+ }
19
+
20
+ const t = (s: number) => TimePoint.fromMilliseconds(s * 1000)
21
+ const zero = TimePoint.fromMilliseconds(0)
22
+
23
+ function rawOption(over: Record<string, unknown> = {}) {
24
+ return {
25
+ shuttled_by: null,
26
+ mode: 0,
27
+ host_id: UInt64.from(5),
28
+ lane_key: 0,
29
+ duration: 60,
30
+ start: t(1000),
31
+ finish: t(1060),
32
+ bays: 0,
33
+ window_starts_at: null,
34
+ window_completes_at: null,
35
+ energy_cost: null,
36
+ rejection: null,
37
+ resolved: true,
38
+ ...over,
39
+ }
40
+ }
41
+
42
+ describe('ShuttleManager', () => {
43
+ it('craft calls getcraftopts with the signed fields and maps the result', async () => {
44
+ let called: {name: string; data: unknown} | null = null
45
+ const m = managerWithReadonly(async (name, data) => {
46
+ called = {name, data}
47
+ return {
48
+ options: [
49
+ rawOption(),
50
+ rawOption({
51
+ shuttled_by: UInt64.from(5),
52
+ mode: 1,
53
+ host_id: UInt64.from(5),
54
+ finish: t(1030),
55
+ }),
56
+ ],
57
+ }
58
+ })
59
+ const out = await m.craft({
60
+ shipId: 5,
61
+ workshopId: 9,
62
+ recipeId: 1,
63
+ quantity: 2,
64
+ inputs: [],
65
+ candidates: [],
66
+ recharge: true,
67
+ })
68
+ expect(called!.name).toBe('getcraftopts')
69
+ expect((called!.data as {recharge: boolean}).recharge).toBe(true)
70
+ expect(out.options.map((o) => o.mode)).toEqual(['own', 'internal'])
71
+ expect(out.auto?.mode).toBe('own')
72
+ expect(out.blocked).toBeUndefined()
73
+ })
74
+
75
+ it('build, claim, cancelBuild, store and take call their own action with every field', async () => {
76
+ const calls: {name: string; data: unknown}[] = []
77
+ const m = managerWithReadonly(async (name, data) => {
78
+ calls.push({name, data})
79
+ return {options: [rawOption()]}
80
+ })
81
+
82
+ const buildInputs = [{item_id: 1}] as never
83
+ const storeItems = [{item_id: 2}] as never
84
+ const takeItems = [{item_id: 3}] as never
85
+
86
+ await m.build({
87
+ targetId: 1,
88
+ dockId: 2,
89
+ targetItemId: 3,
90
+ inputs: buildInputs,
91
+ candidates: [4],
92
+ })
93
+ await m.claim({jobId: 5, shipId: 6, candidates: [7]})
94
+ await m.cancelBuild({jobId: 8, candidates: [9]})
95
+ await m.store({shipId: 10, depotId: 11, items: storeItems, candidates: [12]})
96
+ await m.take({shipId: 13, depotId: 14, items: takeItems, candidates: [15]})
97
+
98
+ expect(calls.map((c) => c.name)).toEqual([
99
+ 'getbuildopts',
100
+ 'getclaimopts',
101
+ 'getcbldopts',
102
+ 'getstoreopts',
103
+ 'gettakeopts',
104
+ ])
105
+ expect(calls[0].data).toEqual({
106
+ target_id: UInt64.from(1),
107
+ dock_id: UInt64.from(2),
108
+ target_item_id: UInt16.from(3),
109
+ inputs: buildInputs,
110
+ candidates: [UInt64.from(4)],
111
+ })
112
+ expect(calls[1].data).toEqual({
113
+ job_id: UInt64.from(5),
114
+ ship_id: UInt64.from(6),
115
+ candidates: [UInt64.from(7)],
116
+ })
117
+ expect(calls[2].data).toEqual({
118
+ job_id: UInt64.from(8),
119
+ candidates: [UInt64.from(9)],
120
+ })
121
+ expect(calls[3].data).toEqual({
122
+ ship_id: UInt64.from(10),
123
+ depot_id: UInt64.from(11),
124
+ items: storeItems,
125
+ candidates: [UInt64.from(12)],
126
+ })
127
+ expect(calls[4].data).toEqual({
128
+ ship_id: UInt64.from(13),
129
+ depot_id: UInt64.from(14),
130
+ items: takeItems,
131
+ candidates: [UInt64.from(15)],
132
+ })
133
+ })
134
+
135
+ it('a rejection maps to its code and values', async () => {
136
+ const m = managerWithReadonly(async () => ({
137
+ options: [
138
+ rawOption({
139
+ rejection: {
140
+ reason: "the building's shuttle bays are fully booked",
141
+ at: t(2000),
142
+ until: null,
143
+ have: null,
144
+ need: null,
145
+ cap: null,
146
+ task_type: null,
147
+ },
148
+ }),
149
+ ],
150
+ }))
151
+ const out = await m.store({shipId: 5, depotId: 9, items: [], candidates: []})
152
+ expect(out.options[0].blocked?.code).toBe('bays-booked')
153
+ expect(out.options[0].blocked?.at?.getTime()).toBe(2000 * 1000)
154
+ })
155
+
156
+ it('a never-shuttle reason is omitted', async () => {
157
+ const m = managerWithReadonly(async () => ({
158
+ options: [
159
+ rawOption(),
160
+ rawOption({
161
+ shuttled_by: UInt64.from(7),
162
+ rejection: {
163
+ reason: 'that ship belongs to another player',
164
+ at: null,
165
+ until: null,
166
+ have: null,
167
+ need: null,
168
+ cap: null,
169
+ task_type: null,
170
+ },
171
+ }),
172
+ ],
173
+ }))
174
+ const out = await m.take({shipId: 5, depotId: 9, items: [], candidates: [7]})
175
+ expect(out.options.length).toBe(1)
176
+ })
177
+
178
+ it('a booking-level reason on every option becomes blocked', async () => {
179
+ const rej = {
180
+ reason: 'player storage allowance at this depot is exceeded',
181
+ at: null,
182
+ until: null,
183
+ have: 900,
184
+ need: 200,
185
+ cap: 1000,
186
+ task_type: null,
187
+ }
188
+ const m = managerWithReadonly(async () => ({
189
+ options: [
190
+ rawOption({rejection: rej}),
191
+ rawOption({shuttled_by: UInt64.from(5), mode: 1, rejection: rej}),
192
+ ],
193
+ }))
194
+ const out = await m.store({shipId: 5, depotId: 9, items: [], candidates: []})
195
+ expect(out.blocked?.code).toBe('depot-full')
196
+ expect(out.blocked?.cap).toBe(1000)
197
+ expect(out.auto).toBeUndefined()
198
+ })
199
+
200
+ it('unresolved booking-level rejections carry zero times and never win over a real option', async () => {
201
+ const rej = {
202
+ reason: 'workshop has a pending plan-capper; cannot accept new jobs',
203
+ at: null,
204
+ until: null,
205
+ have: null,
206
+ need: null,
207
+ cap: null,
208
+ task_type: 3,
209
+ }
210
+ const m = managerWithReadonly(async () => ({
211
+ options: [
212
+ rawOption({
213
+ start: zero,
214
+ finish: zero,
215
+ duration: 0,
216
+ rejection: rej,
217
+ resolved: false,
218
+ }),
219
+ rawOption({
220
+ shuttled_by: UInt64.from(5),
221
+ mode: 1,
222
+ start: zero,
223
+ finish: zero,
224
+ duration: 0,
225
+ rejection: rej,
226
+ resolved: false,
227
+ }),
228
+ rawOption({
229
+ shuttled_by: UInt64.from(7),
230
+ mode: 2,
231
+ host_id: UInt64.from(7),
232
+ }),
233
+ ],
234
+ }))
235
+ const out = await m.craft({
236
+ shipId: 5,
237
+ workshopId: 9,
238
+ recipeId: 1,
239
+ quantity: 1,
240
+ inputs: [],
241
+ candidates: [7],
242
+ })
243
+ expect(out.blocked).toBeUndefined()
244
+ expect(out.auto?.mode).toBe('ship')
245
+ expect(out.options[0].mode).toBe('ship')
246
+ expect(out.options[0].blocked).toBeUndefined()
247
+ const unresolved = out.options.filter((o) => !o.resolved)
248
+ expect(unresolved.length).toBe(2)
249
+ expect(unresolved.every((o) => o.blocked && !o.start && !o.finish)).toBe(true)
250
+ expect(out.options[0].resolved).toBe(true)
251
+ expect(out.options[0].finish?.getTime()).toBe(1060 * 1000)
252
+ })
253
+
254
+ it('ranking puts an option with no finish after every timed option', () => {
255
+ const timed = {
256
+ mode: 'ship',
257
+ hostId: '9',
258
+ finish: new Date(9000),
259
+ blocked: undefined,
260
+ } as never
261
+ const untimed = {
262
+ mode: 'internal',
263
+ hostId: '1',
264
+ finish: undefined,
265
+ blocked: undefined,
266
+ } as never
267
+ expect(
268
+ [untimed, timed].sort(rankShuttleOptions).map((o: {hostId: string}) => o.hostId)
269
+ ).toEqual(['9', '1'])
270
+ })
271
+
272
+ it('an unmapped reason keeps the raw string', () => {
273
+ expect(shuttleReasonCode('some new contract error')).toBe('unknown')
274
+ })
275
+
276
+ it('pins every mapped reason string to its code', () => {
277
+ const table: [string, ShuttleReasonCode][] = [
278
+ ['workshop has a pending plan-capper; cannot accept new jobs', 'workshop-capped'],
279
+ ['ship needs an energy source to book a craft job', 'no-generator'],
280
+ ['entity cannot recharge', 'no-generator'],
281
+ ['workshop has no fabricator installed', 'not-equipped'],
282
+ ['dock has no assembly arm installed', 'not-equipped'],
283
+ ['too many bookings waiting on materials at this building', 'job-cap'],
284
+ ['upgrade target is busy', 'target-busy'],
285
+ ['Entity must be idle to modify modules.', 'target-busy'],
286
+ ['target cargo would not fit the upgraded capacity', 'cargo-wont-fit'],
287
+ ['player storage allowance at this depot is exceeded', 'depot-full'],
288
+ ['player has no such item stored at this depot', 'not-stored'],
289
+ ['player has fewer of that item stored at this depot', 'not-stored'],
290
+ ['entity has no storage', 'no-storage'],
291
+ ['target entity has no storage', 'no-storage'],
292
+ ["the building's shuttle bays are fully booked", 'bays-booked'],
293
+ ['player already has the most transfers this building takes', 'player-cap'],
294
+ ['fabricator queue is full', 'queue-full'],
295
+ ['Cannot unload cargo that is not loaded.', 'inputs-unavailable'],
296
+ ['Insufficient inputs for recipe.', 'inputs-unavailable'],
297
+ ['Cargo debit exceeds available quantity.', 'inputs-unavailable'],
298
+ ['Craft requires more energy than entity has.', 'ship-energy'],
299
+ [
300
+ 'identical Workshop delivery already scheduled at this time; finish that delivery or change the shipment',
301
+ 'dropoff-collision',
302
+ ],
303
+ ['cannot append: schedule is capped by a pending plan-capper', 'ship-capped'],
304
+ ['lane queue is full', 'schedule-full'],
305
+ ['ship departs before the shuttle completes', 'departs'],
306
+ ['giver has insufficient giveable cargo', 'cargo-not-aboard'],
307
+ ['Entity cargo capacity would be exceeded.', 'no-capacity'],
308
+ ['No recipe found for plot target.', 'plot-recipe'],
309
+ ['Item is not part of the plot target recipe.', 'plot-recipe'],
310
+ ['Deposit would exceed plot recipe requirement.', 'plot-recipe'],
311
+ ['craft job is not finished yet', 'not-ready'],
312
+ ]
313
+ for (const [reason, code] of table) {
314
+ expect(shuttleReasonCode(reason)).toBe(code)
315
+ }
316
+ })
317
+
318
+ it('lists every omitted reason and booking-level code from the spec', () => {
319
+ expect([...OMITTED_REASONS]).toEqual([
320
+ 'that ship has no shuttle bay',
321
+ 'that ship belongs to another player',
322
+ 'that ship is not here',
323
+ "only a Depot's public shuttle bays can take this",
324
+ "choose the building's own shuttle instead",
325
+ 'counterpart has a pending plan-capper; cannot place a hold on it',
326
+ 'entity not found',
327
+ ])
328
+ expect([...BOOKING_LEVEL_CODES]).toEqual([
329
+ 'workshop-capped',
330
+ 'no-generator',
331
+ 'not-equipped',
332
+ 'job-cap',
333
+ 'target-busy',
334
+ 'cargo-wont-fit',
335
+ 'depot-full',
336
+ 'not-stored',
337
+ 'no-storage',
338
+ 'not-ready',
339
+ ])
340
+ })
341
+
342
+ it('ranking: unblocked first, earliest finish, endpoints before third parties, lowest id', () => {
343
+ const a = {mode: 'ship', hostId: '9', finish: new Date(3000), blocked: undefined} as never
344
+ const b = {
345
+ mode: 'internal',
346
+ hostId: '1',
347
+ finish: new Date(3000),
348
+ blocked: undefined,
349
+ } as never
350
+ const c = {
351
+ mode: 'own',
352
+ hostId: '5',
353
+ finish: new Date(2000),
354
+ blocked: {code: 'departs'},
355
+ } as never
356
+ const d = {mode: 'bays', hostId: '2', finish: new Date(1000), blocked: undefined} as never
357
+ expect(
358
+ [a, b, c, d].sort(rankShuttleOptions).map((o: {hostId: string}) => o.hostId)
359
+ ).toEqual(['2', '1', '9', '5'])
360
+ })
361
+
362
+ it('two strings under one booking-level code still fold into blocked', async () => {
363
+ const rej = (reason: string) => ({
364
+ reason,
365
+ at: null,
366
+ until: null,
367
+ have: null,
368
+ need: null,
369
+ cap: null,
370
+ task_type: null,
371
+ })
372
+ const m = managerWithReadonly(async () => ({
373
+ options: [
374
+ rawOption({rejection: rej('entity has no storage'), resolved: false}),
375
+ rawOption({
376
+ shuttled_by: UInt64.from(9),
377
+ mode: 3,
378
+ host_id: UInt64.from(9),
379
+ rejection: rej('target entity has no storage'),
380
+ }),
381
+ ],
382
+ }))
383
+ const out = await m.take({shipId: 5, depotId: 9, items: [], candidates: []})
384
+ expect(out.blocked?.code).toBe('no-storage')
385
+ expect(out.auto).toBeUndefined()
386
+ })
387
+
388
+ it('a shared option-level reason never becomes blocked', async () => {
389
+ const rej = {
390
+ reason: 'ship departs before the shuttle completes',
391
+ at: null,
392
+ until: null,
393
+ have: null,
394
+ need: null,
395
+ cap: null,
396
+ task_type: null,
397
+ }
398
+ const m = managerWithReadonly(async () => ({
399
+ options: [
400
+ rawOption({
401
+ shuttled_by: UInt64.from(7),
402
+ mode: 2,
403
+ host_id: UInt64.from(7),
404
+ rejection: rej,
405
+ }),
406
+ rawOption({
407
+ shuttled_by: UInt64.from(9),
408
+ mode: 3,
409
+ host_id: UInt64.from(9),
410
+ rejection: rej,
411
+ }),
412
+ ],
413
+ }))
414
+ const out = await m.take({shipId: 5, depotId: 9, items: [], candidates: [7, 9]})
415
+ expect(out.blocked).toBeUndefined()
416
+ expect(out.options.length).toBe(2)
417
+ })
418
+
419
+ it('a query the chain rejects is a blocked result carrying the assertion text', async () => {
420
+ const m = managerWithReadonly(async () => {
421
+ throw new Error('assertion failure with message: craft job is not finished yet')
422
+ })
423
+ const out = await m.claim({jobId: 1, shipId: 5, candidates: []})
424
+ expect(out.options).toEqual([])
425
+ expect(out.auto).toBeUndefined()
426
+ expect(out.blocked?.code).toBe('not-ready')
427
+ expect(out.blocked?.reason).toBe('craft job is not finished yet')
428
+ })
429
+
430
+ it('a non-assertion throw from the readonly call is rethrown, not blocked', async () => {
431
+ const m = managerWithReadonly(async () => {
432
+ throw new Error('fetch failed')
433
+ })
434
+ await expect(m.claim({jobId: 1, shipId: 5, candidates: []})).rejects.toThrow('fetch failed')
435
+ })
436
+
437
+ it('a chain error surfaces the assertion text from details when message lacks it', async () => {
438
+ const m = managerWithReadonly(async () => {
439
+ const err = new Error('unknown error') as Error & {
440
+ details?: {message?: string}[]
441
+ }
442
+ err.details = [
443
+ {message: 'assertion failure with message: craft job is not finished yet'},
444
+ ]
445
+ throw err
446
+ })
447
+ const out = await m.claim({jobId: 1, shipId: 5, candidates: []})
448
+ expect(out.blocked?.code).toBe('not-ready')
449
+ expect(out.blocked?.reason).toBe('craft job is not finished yet')
450
+ })
451
+
452
+ it('resolved comes from the wire, not from the times', async () => {
453
+ const m = managerWithReadonly(async () => ({
454
+ options: [
455
+ rawOption({start: zero, finish: zero, resolved: true}),
456
+ rawOption({
457
+ shuttled_by: UInt64.from(7),
458
+ mode: 2,
459
+ host_id: UInt64.from(7),
460
+ resolved: false,
461
+ }),
462
+ ],
463
+ }))
464
+ const out = await m.store({shipId: 5, depotId: 9, items: [], candidates: []})
465
+ const internal = out.options.find((o) => o.mode === 'internal')
466
+ const third = out.options.find((o) => o.hostId === '7')
467
+ expect(internal?.resolved).toBe(true)
468
+ expect(third?.resolved).toBe(false)
469
+ })
470
+ })
471
+
472
+ describe('shuttleCandidates', () => {
473
+ const civicOwner = 'nex.shipload'
474
+ const player = 'alice'
475
+ const site = {x: 10, y: 20}
476
+ const elsewhere = {x: 99, y: 99}
477
+
478
+ function entity(over: {
479
+ id: number
480
+ owner?: NameType
481
+ kind?: NameType
482
+ item_id?: number
483
+ coordinates?: {x: number; y: number}
484
+ modules?: CandidateEntity['modules']
485
+ }): CandidateEntity {
486
+ return {
487
+ id: UInt64.from(over.id),
488
+ owner: Name.from(over.owner ?? player),
489
+ kind: Name.from(over.kind ?? ENTITY_SHIP),
490
+ item_id: UInt16.from(over.item_id ?? 1),
491
+ coordinates: (over.coordinates ?? site) as never,
492
+ modules: over.modules ?? [],
493
+ lanes: [],
494
+ }
495
+ }
496
+
497
+ const loaderModule = {installed: {item_id: ITEM_LOADER_T1, stats: 0}} as never
498
+ const emptyModule = {installed: undefined} as never
499
+
500
+ const building = entity({id: 1, owner: civicOwner, kind: ENTITY_WORKSHOP})
501
+
502
+ it('includes a same-owner ship with a loader at the site', () => {
503
+ const ship = entity({id: 2, modules: [loaderModule]})
504
+ const out = shuttleCandidates({building, shipId: 999, player}, [ship], civicOwner)
505
+ expect(out).toEqual(['2'])
506
+ })
507
+
508
+ it('excludes a same-owner ship with no loader module', () => {
509
+ const ship = entity({id: 2, modules: [emptyModule]})
510
+ const out = shuttleCandidates({building, shipId: 999, player}, [ship], civicOwner)
511
+ expect(out).toEqual([])
512
+ })
513
+
514
+ it('excludes a ship at another site', () => {
515
+ const ship = entity({id: 2, modules: [loaderModule], coordinates: elsewhere})
516
+ const out = shuttleCandidates({building, shipId: 999, player}, [ship], civicOwner)
517
+ expect(out).toEqual([])
518
+ })
519
+
520
+ it('excludes a ship owned by another player', () => {
521
+ const ship = entity({id: 2, owner: 'mallory', modules: [loaderModule]})
522
+ const out = shuttleCandidates({building, shipId: 999, player}, [ship], civicOwner)
523
+ expect(out).toEqual([])
524
+ })
525
+
526
+ it('includes a Depot at the site regardless of owner', () => {
527
+ const depot = entity({id: 3, owner: civicOwner, kind: ENTITY_DEPOT})
528
+ const out = shuttleCandidates({building, shipId: 999, player}, [depot], civicOwner)
529
+ expect(out).toEqual(['3'])
530
+ })
531
+
532
+ it('excludes a Workshop and any other non-Depot civic building', () => {
533
+ const otherWorkshop = entity({id: 3, owner: civicOwner, kind: ENTITY_WORKSHOP})
534
+ const out = shuttleCandidates({building, shipId: 999, player}, [otherWorkshop], civicOwner)
535
+ expect(out).toEqual([])
536
+ })
537
+
538
+ it('excludes the building itself', () => {
539
+ const out = shuttleCandidates({building, shipId: 999, player}, [building], civicOwner)
540
+ expect(out).toEqual([])
541
+ })
542
+
543
+ it('excludes the booking ship', () => {
544
+ const ship = entity({id: 2, modules: [loaderModule]})
545
+ const out = shuttleCandidates({building, shipId: 2, player}, [ship], civicOwner)
546
+ expect(out).toEqual([])
547
+ })
548
+
549
+ it('returns unique ids in deterministic order, Depots first then ships ascending, capped at 8', () => {
550
+ const depotA = entity({id: 20, owner: civicOwner, kind: ENTITY_DEPOT})
551
+ const depotB = entity({id: 5, owner: civicOwner, kind: ENTITY_DEPOT})
552
+ const ships = Array.from({length: 9}, (_, i) =>
553
+ entity({id: 100 + i, modules: [loaderModule]})
554
+ )
555
+ const out = shuttleCandidates(
556
+ {building, shipId: 999, player},
557
+ [depotA, depotB, ...ships, ships[0]],
558
+ civicOwner
559
+ )
560
+ expect(out).toEqual(['5', '20', '100', '101', '102', '103', '104', '105'])
561
+ })
562
+ })