@shipload/sdk 1.0.0-next.4 → 1.0.0-next.40

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 (127) hide show
  1. package/lib/shipload.d.ts +2473 -973
  2. package/lib/shipload.js +11529 -5211
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +11338 -5162
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +970 -0
  7. package/lib/testing.js +4013 -0
  8. package/lib/testing.js.map +1 -0
  9. package/lib/testing.m.js +4007 -0
  10. package/lib/testing.m.js.map +1 -0
  11. package/package.json +15 -2
  12. package/src/capabilities/craftable.ts +51 -0
  13. package/src/capabilities/crafting.test.ts +7 -0
  14. package/src/capabilities/crafting.ts +5 -6
  15. package/src/capabilities/gathering.test.ts +16 -0
  16. package/src/capabilities/gathering.ts +35 -18
  17. package/src/capabilities/index.ts +0 -1
  18. package/src/capabilities/modules.ts +9 -0
  19. package/src/capabilities/storage.ts +16 -1
  20. package/src/contracts/platform.ts +231 -3
  21. package/src/contracts/server.ts +1021 -481
  22. package/src/coordinates/address.ts +88 -0
  23. package/src/coordinates/constants.test.ts +15 -0
  24. package/src/coordinates/constants.ts +23 -0
  25. package/src/coordinates/index.ts +15 -0
  26. package/src/coordinates/memo.test.ts +47 -0
  27. package/src/coordinates/memo.ts +20 -0
  28. package/src/coordinates/permutation.ts +77 -0
  29. package/src/coordinates/regions.ts +48 -0
  30. package/src/coordinates/sectors.ts +115 -0
  31. package/src/data/capabilities.ts +12 -5
  32. package/src/data/capability-formulas.ts +14 -7
  33. package/src/data/catalog.ts +0 -5
  34. package/src/data/colors.ts +14 -47
  35. package/src/data/entities.json +76 -10
  36. package/src/data/item-ids.ts +18 -12
  37. package/src/data/items.json +321 -38
  38. package/src/data/kind-registry.json +109 -0
  39. package/src/data/kind-registry.ts +165 -0
  40. package/src/data/metadata.ts +119 -33
  41. package/src/data/recipes-runtime.ts +3 -23
  42. package/src/data/recipes.json +238 -117
  43. package/src/derivation/build-methods.ts +45 -0
  44. package/src/derivation/capabilities.test.ts +151 -0
  45. package/src/derivation/capabilities.ts +512 -0
  46. package/src/derivation/capability-mappings.ts +9 -12
  47. package/src/derivation/crafting.ts +23 -24
  48. package/src/derivation/index.ts +25 -2
  49. package/src/derivation/recipe-usage.test.ts +78 -0
  50. package/src/derivation/recipe-usage.ts +141 -0
  51. package/src/derivation/reserve-regen.ts +34 -0
  52. package/src/derivation/resources.ts +125 -38
  53. package/src/derivation/rollups.test.ts +55 -0
  54. package/src/derivation/rollups.ts +56 -0
  55. package/src/derivation/stars.test.ts +51 -0
  56. package/src/derivation/stars.ts +15 -0
  57. package/src/derivation/stats.ts +6 -6
  58. package/src/derivation/stratum.ts +17 -20
  59. package/src/derivation/tiers.ts +40 -7
  60. package/src/derivation/wormhole.ts +136 -0
  61. package/src/entities/entity.ts +98 -0
  62. package/src/entities/gamestate.ts +3 -28
  63. package/src/entities/makers.ts +124 -134
  64. package/src/entities/slot-multiplier.ts +43 -0
  65. package/src/errors.ts +12 -16
  66. package/src/format.ts +26 -4
  67. package/src/index-module.ts +267 -47
  68. package/src/managers/actions.ts +528 -95
  69. package/src/managers/base.ts +6 -2
  70. package/src/managers/construction-types.ts +80 -0
  71. package/src/managers/construction.ts +412 -0
  72. package/src/managers/context.ts +20 -1
  73. package/src/managers/coordinates.ts +14 -0
  74. package/src/managers/entities.ts +18 -66
  75. package/src/managers/epochs.ts +40 -0
  76. package/src/managers/index.ts +17 -1
  77. package/src/managers/locations.ts +25 -29
  78. package/src/managers/nft.test.ts +14 -0
  79. package/src/managers/nft.ts +70 -0
  80. package/src/managers/plot.ts +122 -0
  81. package/src/nft/atomicassets.abi.json +1342 -0
  82. package/src/nft/atomicassets.ts +237 -0
  83. package/src/nft/atomicdata.ts +130 -0
  84. package/src/nft/buildImmutableData.ts +338 -0
  85. package/src/nft/description.ts +98 -24
  86. package/src/nft/index.ts +3 -0
  87. package/src/planner/index.ts +127 -0
  88. package/src/planner/planner.test.ts +319 -0
  89. package/src/resolution/describe-module.ts +18 -13
  90. package/src/resolution/display-name.ts +38 -10
  91. package/src/resolution/resolve-item.test.ts +37 -0
  92. package/src/resolution/resolve-item.ts +55 -24
  93. package/src/scheduling/accessor.ts +68 -22
  94. package/src/scheduling/availability.ts +108 -0
  95. package/src/scheduling/cancel.test.ts +348 -0
  96. package/src/scheduling/cancel.ts +209 -0
  97. package/src/scheduling/energy.ts +47 -0
  98. package/src/scheduling/idle-resolve.ts +45 -0
  99. package/src/scheduling/lane-core.ts +128 -0
  100. package/src/scheduling/lanes.test.ts +249 -0
  101. package/src/scheduling/lanes.ts +198 -0
  102. package/src/scheduling/projection.ts +209 -105
  103. package/src/scheduling/schedule.ts +241 -104
  104. package/src/scheduling/task-cargo.ts +46 -0
  105. package/src/shipload.ts +21 -1
  106. package/src/subscriptions/manager.ts +229 -142
  107. package/src/subscriptions/mappers.ts +5 -8
  108. package/src/subscriptions/types.ts +11 -3
  109. package/src/testing/catalog-hash.ts +19 -0
  110. package/src/testing/index.ts +2 -0
  111. package/src/testing/projection-parity.ts +167 -0
  112. package/src/travel/reach.ts +23 -0
  113. package/src/travel/route-planner.ts +196 -0
  114. package/src/travel/travel.ts +200 -112
  115. package/src/types/capabilities.ts +29 -6
  116. package/src/types/entity.ts +3 -3
  117. package/src/types/index.ts +0 -1
  118. package/src/types.ts +28 -13
  119. package/src/utils/cargo.ts +27 -0
  120. package/src/utils/display-name.ts +70 -0
  121. package/src/utils/system.ts +36 -24
  122. package/src/capabilities/loading.ts +0 -8
  123. package/src/entities/container.ts +0 -108
  124. package/src/entities/ship-deploy.ts +0 -259
  125. package/src/entities/ship.ts +0 -204
  126. package/src/entities/warehouse.ts +0 -119
  127. package/src/types/entity-traits.ts +0 -69
@@ -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
+ })
@@ -0,0 +1,209 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {TaskCancelable, TaskType} from '../types'
3
+ import {calcCargoItemMass} from '../capabilities/storage'
4
+ import {taskCargoEffect, cargoKey} from './availability'
5
+ import * as schedule from './schedule'
6
+ import {validateSchedule, type Projectable} from './projection'
7
+
8
+ export enum CancelBlockReason {
9
+ TASK_NEVER = 'TASK_NEVER',
10
+ BEFORE_START_RUNNING = 'BEFORE_START_RUNNING',
11
+ DONE = 'DONE',
12
+ CONTAINS_LINKED_TASK = 'CONTAINS_LINKED_TASK',
13
+ WOULD_STRAND = 'WOULD_STRAND',
14
+ WOULD_OVERFILL = 'WOULD_OVERFILL',
15
+ NOT_OWNER = 'NOT_OWNER',
16
+ }
17
+
18
+ type Task = InstanceType<typeof ServerContract.Types.task>
19
+ type EntityInfo = InstanceType<typeof ServerContract.Types.entity_info>
20
+ type EntityRef = InstanceType<typeof ServerContract.Types.entity_ref>
21
+ type CargoItem = InstanceType<typeof ServerContract.Types.cargo_item>
22
+
23
+ export interface CancelRefund {
24
+ giver: EntityRef
25
+ cargo: CargoItem[]
26
+ }
27
+ export interface CancelReleasedHold {
28
+ counterpart: EntityRef
29
+ kind: number
30
+ }
31
+ export interface CancelEffects {
32
+ refunds: CancelRefund[]
33
+ releasedHolds: CancelReleasedHold[]
34
+ abandonsRunning: boolean
35
+ keepsPlotDeposits?: {plot: EntityRef}
36
+ energyForfeited?: number
37
+ }
38
+ export interface CancelPlan {
39
+ ok: boolean
40
+ blockedReason?: CancelBlockReason
41
+ range: {count: number; taskIndices: number[]}
42
+ effects: CancelEffects
43
+ }
44
+ export interface CancelEligibilityInput {
45
+ now: Date
46
+ counterparts?: Map<string, EntityInfo>
47
+ }
48
+
49
+ const EMPTY_EFFECTS: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
50
+
51
+ function postCancelEntity(entity: EntityInfo, laneKey: number, fromTaskIndex: number): EntityInfo {
52
+ const clone = (entity.constructor as typeof ServerContract.Types.entity_info).from(
53
+ JSON.parse(JSON.stringify(entity.toJSON()))
54
+ ) as EntityInfo
55
+ const lane = clone.lanes.find((l) => l.lane_key.toNumber() === laneKey)!
56
+ lane.schedule.tasks = lane.schedule.tasks.slice(0, fromTaskIndex)
57
+ return clone
58
+ }
59
+
60
+ function feasibleAfterCancel(post: EntityInfo): boolean {
61
+ const ordered = schedule.orderedTasks(post as unknown as Projectable)
62
+ const base = new Map<string, number>()
63
+ for (const c of post.cargo ?? []) {
64
+ const k = cargoKey(c)
65
+ base.set(k, (base.get(k) ?? 0) + c.quantity.toNumber())
66
+ }
67
+ const isConsumer = (t: Task) =>
68
+ t.type.toNumber() === TaskType.CRAFT || t.type.toNumber() === TaskType.UNLOAD
69
+ for (const self of ordered) {
70
+ if (!isConsumer(self.task)) continue
71
+ const map = new Map(base)
72
+ for (const other of ordered) {
73
+ if (other.completesAt.getTime() >= self.completesAt.getTime()) continue
74
+ for (const out of taskCargoEffect(other.task).added) {
75
+ map.set(cargoKey(out), (map.get(cargoKey(out)) ?? 0) + out.quantity.toNumber())
76
+ }
77
+ }
78
+ for (const other of ordered) {
79
+ if (other === self) continue
80
+ for (const inp of taskCargoEffect(other.task).removed) {
81
+ const cur = map.get(cargoKey(inp)) ?? 0
82
+ map.set(cargoKey(inp), Math.max(0, cur - inp.quantity.toNumber()))
83
+ }
84
+ }
85
+ for (const inp of taskCargoEffect(self.task).removed) {
86
+ if ((map.get(cargoKey(inp)) ?? 0) < inp.quantity.toNumber()) return false
87
+ }
88
+ }
89
+ try {
90
+ validateSchedule(post as unknown as Projectable)
91
+ } catch {
92
+ return false
93
+ }
94
+ return true
95
+ }
96
+
97
+ interface Timing {
98
+ startsAt: number
99
+ completesAt: number
100
+ running: boolean
101
+ done: boolean
102
+ }
103
+
104
+ function laneTiming(
105
+ lane: {schedule: {started: {toDate(): Date}; tasks: Task[]}},
106
+ nowMs: number
107
+ ): Timing[] {
108
+ const startedMs = lane.schedule.started.toDate().getTime()
109
+ let endSec = 0
110
+ return lane.schedule.tasks.map((t) => {
111
+ const startsAt = startedMs + endSec * 1000
112
+ endSec += t.duration.toNumber()
113
+ const completesAt = startedMs + endSec * 1000
114
+ return {
115
+ startsAt,
116
+ completesAt,
117
+ running: nowMs >= startsAt && nowMs < completesAt,
118
+ done: nowMs >= completesAt,
119
+ }
120
+ })
121
+ }
122
+
123
+ export function cancelEligibility(
124
+ entity: EntityInfo,
125
+ laneKey: number,
126
+ fromTaskIndex: number,
127
+ input: CancelEligibilityInput
128
+ ): CancelPlan {
129
+ const lane = (entity.lanes ?? []).find((l) => l.lane_key.equals(laneKey))
130
+ if (!lane || fromTaskIndex < 0 || fromTaskIndex >= lane.schedule.tasks.length) {
131
+ return {ok: false, range: {count: 0, taskIndices: []}, effects: {...EMPTY_EFFECTS}}
132
+ }
133
+
134
+ const tasks = lane.schedule.tasks
135
+ const timing = laneTiming(lane, input.now.getTime())
136
+ const taskIndices: number[] = []
137
+ for (let i = fromTaskIndex; i < tasks.length; i++) taskIndices.push(i)
138
+ const range = {count: taskIndices.length, taskIndices}
139
+
140
+ const block = (blockedReason: CancelBlockReason): CancelPlan => ({
141
+ ok: false,
142
+ blockedReason,
143
+ range,
144
+ effects: {...EMPTY_EFFECTS},
145
+ })
146
+
147
+ for (const i of taskIndices) {
148
+ const t = tasks[i]
149
+ if (t.entitygroup && !t.entitygroup.equals(0))
150
+ return block(CancelBlockReason.CONTAINS_LINKED_TASK)
151
+ }
152
+
153
+ for (const i of taskIndices) {
154
+ const t = tasks[i]
155
+ if (timing[i].done) return block(CancelBlockReason.DONE)
156
+ if (t.cancelable.equals(TaskCancelable.NEVER) && !t.type.equals(TaskType.IDLE))
157
+ return block(CancelBlockReason.TASK_NEVER)
158
+ if (t.cancelable.equals(TaskCancelable.BEFORE_START) && timing[i].running)
159
+ return block(CancelBlockReason.BEFORE_START_RUNNING)
160
+ }
161
+
162
+ const post = postCancelEntity(entity, laneKey, fromTaskIndex)
163
+ if (!feasibleAfterCancel(post)) return block(CancelBlockReason.WOULD_STRAND)
164
+
165
+ const effects: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
166
+ let energyForfeited = 0
167
+ for (const i of taskIndices) {
168
+ const t = tasks[i]
169
+ if (timing[i].running && t.cancelable.equals(TaskCancelable.ALWAYS))
170
+ effects.abandonsRunning = true
171
+ if (t.energy_cost) energyForfeited += t.energy_cost.toNumber()
172
+ if (t.type.equals(TaskType.BUILDPLOT) && t.entitytarget)
173
+ effects.keepsPlotDeposits = {plot: t.entitytarget}
174
+ if (t.hold && t.entitytarget) {
175
+ const counterpart = input.counterparts?.get(t.entitytarget.entity_id.toString())
176
+ const hold =
177
+ (counterpart?.holds ?? []).find((h) => h.id.equals(t.hold!)) ??
178
+ (entity.holds ?? []).find((h) => h.id.equals(t.hold!))
179
+ if (hold) {
180
+ const kind = hold.kind.toNumber()
181
+ effects.releasedHolds.push({counterpart: t.entitytarget, kind})
182
+ if (kind === 1 /* HOLD_PULL */) {
183
+ effects.refunds.push({giver: t.entitytarget, cargo: t.cargo})
184
+ const giver = counterpart
185
+ if (giver) {
186
+ const returned = t.cargo.reduce(
187
+ (s, c) => s + calcCargoItemMass(c).toNumber(),
188
+ 0
189
+ )
190
+ const cap = giver.capacity
191
+ ? giver.capacity.toNumber()
192
+ : Number.MAX_SAFE_INTEGER
193
+ if (giver.cargomass.toNumber() + returned > cap) {
194
+ return {
195
+ ok: false,
196
+ blockedReason: CancelBlockReason.WOULD_OVERFILL,
197
+ range,
198
+ effects: {...EMPTY_EFFECTS},
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ if (energyForfeited > 0) effects.energyForfeited = energyForfeited
207
+
208
+ return {ok: true, range, effects}
209
+ }
@@ -0,0 +1,47 @@
1
+ import {TaskType} from '../types'
2
+ import {createProjectedEntity, type Projectable} from './projection'
3
+ import {orderedTasks} from './schedule'
4
+
5
+ export function energyAtTime(entity: Projectable, now: Date): number {
6
+ const projected = createProjectedEntity(entity)
7
+ const capacity = projected.generator ? Number(projected.generator.capacity) : undefined
8
+
9
+ const clamp = (value: number): number => {
10
+ const floored = Math.max(0, value)
11
+ return capacity !== undefined ? Math.min(capacity, floored) : floored
12
+ }
13
+
14
+ let running = Number(projected.energy)
15
+
16
+ const ordered = orderedTasks(entity)
17
+ if (ordered.length === 0) return clamp(running)
18
+
19
+ const nowMs = now.getTime()
20
+
21
+ for (const {task, startsAt} of ordered) {
22
+ const duration = task.duration.toNumber()
23
+ const elapsed = Math.min(
24
+ Math.max(0, Math.floor((nowMs - startsAt.getTime()) / 1000)),
25
+ duration
26
+ )
27
+ const complete = elapsed >= duration
28
+ const inProgress = !complete && elapsed > 0 && elapsed < duration
29
+
30
+ if (!complete && !inProgress) continue
31
+
32
+ const fraction = complete ? 1 : duration === 0 ? 1 : elapsed / duration
33
+
34
+ if (task.type.toNumber() === TaskType.RECHARGE) {
35
+ if (capacity !== undefined) {
36
+ running = complete ? capacity : running + (capacity - running) * fraction
37
+ }
38
+ } else {
39
+ const cost = Number(task.energy_cost ?? 0)
40
+ running -= cost * fraction
41
+ }
42
+
43
+ running = clamp(running)
44
+ }
45
+
46
+ return clamp(running)
47
+ }
@@ -0,0 +1,45 @@
1
+ import type {Action, UInt64} from '@wharfkit/antelope'
2
+ import type {ServerContract} from '../contracts'
3
+ import type {ActionsManager} from '../managers/actions'
4
+ import {hasResolvable, type ScheduleData} from './schedule'
5
+
6
+ type EntityInfo = ServerContract.Types.entity_info
7
+
8
+ export type CounterpartLookup = (entityId: UInt64) => EntityInfo | undefined
9
+
10
+ export type IdleResolveTarget = ScheduleData & {id: UInt64}
11
+
12
+ // A hold's driving task lives on its counterpart, so a hold resolves the counterpart, never the blocker.
13
+ export function composeIdleResolve(
14
+ blocker: IdleResolveTarget,
15
+ action: Action,
16
+ actions: ActionsManager,
17
+ now: Date,
18
+ lookupCounterpart?: CounterpartLookup
19
+ ): Action[] {
20
+ const ids: UInt64[] = []
21
+ const seen = new Set<string>()
22
+
23
+ const add = (id: UInt64) => {
24
+ const key = id.toString()
25
+ if (seen.has(key)) return
26
+ seen.add(key)
27
+ ids.push(id)
28
+ }
29
+
30
+ if (hasResolvable(blocker, now)) {
31
+ add(blocker.id)
32
+ }
33
+
34
+ // Without a lookup we cannot confirm the counterpart has a completed task, so skip it.
35
+ if (lookupCounterpart) {
36
+ for (const hold of blocker.holds ?? []) {
37
+ const counterpartId = hold.counterpart.entity_id
38
+ const counterpart = lookupCounterpart(counterpartId)
39
+ if (!counterpart || !hasResolvable(counterpart, now)) continue
40
+ add(counterpartId)
41
+ }
42
+ }
43
+
44
+ return [...ids.map((id) => actions.resolve(id)), action]
45
+ }