@shipload/sdk 1.0.0-next.7 → 1.0.0-next.71

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 (203) hide show
  1. package/lib/scan.d.ts +52 -0
  2. package/lib/scan.js +162 -0
  3. package/lib/scan.js.map +1 -0
  4. package/lib/scan.m.js +152 -0
  5. package/lib/scan.m.js.map +1 -0
  6. package/lib/shipload.d.ts +5822 -1484
  7. package/lib/shipload.js +20838 -5419
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +20674 -5669
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1691 -0
  12. package/lib/testing.js +7109 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +7103 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.test.ts +82 -0
  18. package/src/capabilities/craftable.ts +82 -0
  19. package/src/capabilities/crafting.test.ts +80 -0
  20. package/src/capabilities/crafting.ts +50 -8
  21. package/src/capabilities/gathering.test.ts +29 -0
  22. package/src/capabilities/gathering.ts +38 -20
  23. package/src/capabilities/hauling.ts +0 -5
  24. package/src/capabilities/index.ts +0 -1
  25. package/src/capabilities/modules.ts +27 -30
  26. package/src/capabilities/movement.ts +1 -1
  27. package/src/capabilities/storage.test.ts +26 -0
  28. package/src/capabilities/storage.ts +22 -7
  29. package/src/contracts/fund.ts +306 -0
  30. package/src/contracts/platform.ts +312 -3
  31. package/src/contracts/server.ts +2793 -478
  32. package/src/contracts/token.ts +170 -0
  33. package/src/contracts.ts +2 -0
  34. package/src/coordinates/address.ts +88 -0
  35. package/src/coordinates/constants.test.ts +15 -0
  36. package/src/coordinates/constants.ts +23 -0
  37. package/src/coordinates/index.ts +15 -0
  38. package/src/coordinates/memo.test.ts +47 -0
  39. package/src/coordinates/memo.ts +20 -0
  40. package/src/coordinates/permutation.ts +77 -0
  41. package/src/coordinates/regions.ts +48 -0
  42. package/src/coordinates/sectors.ts +115 -0
  43. package/src/data/capabilities.ts +49 -18
  44. package/src/data/capability-formulas.ts +26 -14
  45. package/src/data/catalog.test.ts +19 -0
  46. package/src/data/catalog.ts +13 -3
  47. package/src/data/charters.json +396 -0
  48. package/src/data/citizenry-adjectives.json +42 -0
  49. package/src/data/citizenry-nouns.json +50 -0
  50. package/src/data/colors.ts +14 -32
  51. package/src/data/entities.json +421 -15
  52. package/src/data/item-ids.ts +53 -13
  53. package/src/data/items.json +330 -77
  54. package/src/data/kind-registry.json +212 -0
  55. package/src/data/kind-registry.ts +192 -0
  56. package/src/data/metadata.ts +312 -51
  57. package/src/data/recipes-runtime.ts +6 -23
  58. package/src/data/recipes.json +2173 -173
  59. package/src/data/tiers.ts +9 -6
  60. package/src/derivation/build-methods.test.ts +71 -0
  61. package/src/derivation/build-methods.ts +50 -0
  62. package/src/derivation/capabilities.test.ts +348 -0
  63. package/src/derivation/capabilities.ts +707 -0
  64. package/src/derivation/capability-mappings.ts +60 -16
  65. package/src/derivation/crafting.test.ts +17 -0
  66. package/src/derivation/crafting.ts +57 -34
  67. package/src/derivation/index.ts +27 -2
  68. package/src/derivation/recipe-usage.test.ts +88 -0
  69. package/src/derivation/recipe-usage.ts +141 -0
  70. package/src/derivation/reserve-regen.ts +34 -0
  71. package/src/derivation/resources.ts +9 -1
  72. package/src/derivation/rollups.test.ts +55 -0
  73. package/src/derivation/rollups.ts +72 -0
  74. package/src/derivation/stars.test.ts +67 -0
  75. package/src/derivation/stars.ts +25 -0
  76. package/src/derivation/stat-scaling.ts +12 -0
  77. package/src/derivation/stats.ts +6 -6
  78. package/src/derivation/strata.ts +5 -3
  79. package/src/derivation/stratum.ts +26 -22
  80. package/src/derivation/tiers.ts +40 -7
  81. package/src/derivation/upgrades.ts +14 -0
  82. package/src/derivation/wormhole.ts +154 -0
  83. package/src/entities/entity.ts +102 -0
  84. package/src/entities/gamestate.ts +3 -28
  85. package/src/entities/makers.ts +144 -136
  86. package/src/entities/slot-multiplier.ts +43 -0
  87. package/src/entity/hydrate.test.ts +156 -0
  88. package/src/entity/hydrate.ts +86 -0
  89. package/src/errors.ts +21 -16
  90. package/src/format.ts +42 -9
  91. package/src/index-module.ts +425 -32
  92. package/src/influence/ballot.test.ts +103 -0
  93. package/src/influence/ballot.ts +98 -0
  94. package/src/influence/categories.ts +32 -0
  95. package/src/influence/charter-metadata.test.ts +136 -0
  96. package/src/influence/charter-metadata.ts +236 -0
  97. package/src/influence/charters.test.ts +112 -0
  98. package/src/influence/charters.ts +150 -0
  99. package/src/influence/citizenry.ts +38 -0
  100. package/src/influence/civic-shuttle.test.ts +202 -0
  101. package/src/influence/civic-shuttle.ts +142 -0
  102. package/src/influence/civic.test.ts +287 -0
  103. package/src/influence/civic.ts +269 -0
  104. package/src/influence/constants.ts +84 -0
  105. package/src/influence/decay.ts +20 -0
  106. package/src/influence/decomp.ts +95 -0
  107. package/src/influence/demand.ts +106 -0
  108. package/src/influence/duration.ts +101 -0
  109. package/src/influence/index.ts +118 -0
  110. package/src/influence/influence.test.ts +450 -0
  111. package/src/influence/quality.ts +25 -0
  112. package/src/influence/valuation.ts +154 -0
  113. package/src/managers/actions.ts +950 -93
  114. package/src/managers/balances.test.ts +161 -0
  115. package/src/managers/balances.ts +93 -0
  116. package/src/managers/base.ts +10 -2
  117. package/src/managers/cluster.test.ts +39 -0
  118. package/src/managers/cluster.ts +53 -0
  119. package/src/managers/construction-types.ts +80 -0
  120. package/src/managers/construction.ts +453 -0
  121. package/src/managers/context.ts +58 -1
  122. package/src/managers/coordinates.ts +14 -0
  123. package/src/managers/entities.ts +35 -63
  124. package/src/managers/epochs.ts +72 -1
  125. package/src/managers/index.ts +38 -1
  126. package/src/managers/influence.test.ts +56 -0
  127. package/src/managers/influence.ts +425 -0
  128. package/src/managers/jobs.test.ts +96 -0
  129. package/src/managers/jobs.ts +58 -0
  130. package/src/managers/locations.ts +25 -29
  131. package/src/managers/nft.test.ts +21 -0
  132. package/src/managers/nft.ts +110 -0
  133. package/src/managers/players.ts +25 -0
  134. package/src/managers/plot.ts +122 -0
  135. package/src/nft/atomicassets.abi.json +1342 -0
  136. package/src/nft/atomicassets.ts +237 -0
  137. package/src/nft/atomicdata.ts +130 -0
  138. package/src/nft/buildImmutableData.ts +388 -0
  139. package/src/nft/description.ts +222 -100
  140. package/src/nft/index.ts +3 -0
  141. package/src/planner/index.ts +296 -0
  142. package/src/planner/planner.test.ts +459 -0
  143. package/src/receipts.ts +22 -0
  144. package/src/resolution/describe-module.ts +43 -25
  145. package/src/resolution/display-name.ts +39 -11
  146. package/src/resolution/resolve-item.test.ts +48 -0
  147. package/src/resolution/resolve-item.ts +112 -58
  148. package/src/scan/index.ts +244 -0
  149. package/src/scan/scan-wasm.base64.ts +2 -0
  150. package/src/scheduling/accessor.ts +65 -23
  151. package/src/scheduling/availability.test.ts +320 -0
  152. package/src/scheduling/availability.ts +251 -0
  153. package/src/scheduling/cancel.test.ts +550 -0
  154. package/src/scheduling/cancel.ts +254 -0
  155. package/src/scheduling/cluster-stock.test.ts +31 -0
  156. package/src/scheduling/cluster-stock.ts +15 -0
  157. package/src/scheduling/energy.ts +47 -0
  158. package/src/scheduling/epoch.ts +1 -0
  159. package/src/scheduling/idle-resolve.ts +45 -0
  160. package/src/scheduling/jobs.test.ts +448 -0
  161. package/src/scheduling/jobs.ts +343 -0
  162. package/src/scheduling/lane-core.ts +128 -0
  163. package/src/scheduling/lanes.test.ts +250 -0
  164. package/src/scheduling/lanes.ts +279 -0
  165. package/src/scheduling/projection.ts +265 -149
  166. package/src/scheduling/schedule.test.ts +163 -0
  167. package/src/scheduling/schedule.ts +282 -116
  168. package/src/scheduling/task-cargo.test.ts +110 -0
  169. package/src/scheduling/task-cargo.ts +62 -0
  170. package/src/scheduling/task-effects.test.ts +35 -0
  171. package/src/scheduling/task-effects.ts +34 -0
  172. package/src/scheduling/unwrap.test.ts +86 -0
  173. package/src/scheduling/unwrap.ts +190 -0
  174. package/src/shipload.ts +63 -4
  175. package/src/subscriptions/manager.cluster.test.ts +51 -0
  176. package/src/subscriptions/manager.ts +245 -172
  177. package/src/subscriptions/mappers.ts +5 -8
  178. package/src/subscriptions/types.ts +28 -3
  179. package/src/testing/catalog-hash.ts +19 -0
  180. package/src/testing/index.ts +2 -0
  181. package/src/testing/projection-parity.ts +167 -0
  182. package/src/travel/reach.test.ts +86 -0
  183. package/src/travel/reach.ts +34 -0
  184. package/src/travel/route-build.test.ts +63 -0
  185. package/src/travel/route-build.ts +60 -0
  186. package/src/travel/route-planner.ts +288 -0
  187. package/src/travel/route-simulator.ts +170 -0
  188. package/src/travel/travel.ts +190 -123
  189. package/src/types/capabilities.ts +37 -8
  190. package/src/types/entity.ts +3 -3
  191. package/src/types/index.ts +0 -1
  192. package/src/types.ts +81 -23
  193. package/src/utils/cargo.test.ts +14 -0
  194. package/src/utils/cargo.ts +2 -0
  195. package/src/utils/display-name.ts +70 -0
  196. package/src/utils/hash.ts +22 -2
  197. package/src/utils/system.ts +24 -5
  198. package/src/capabilities/loading.ts +0 -8
  199. package/src/entities/container.ts +0 -123
  200. package/src/entities/ship-deploy.ts +0 -295
  201. package/src/entities/ship.ts +0 -221
  202. package/src/entities/warehouse.ts +0 -127
  203. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,320 @@
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
+ hasIncomingCoupling,
8
+ hasSourceCoupling,
9
+ incomingSources,
10
+ type IncomingSource,
11
+ projectedCargoAvailableAt,
12
+ taskCargoEffect,
13
+ } from './availability'
14
+
15
+ const T0 = '2026-06-19T00:00:00'
16
+ const EPOCH = new Date(0)
17
+
18
+ function cargoItem(itemId: number, stats: number, quantity: number) {
19
+ return ServerContract.Types.cargo_item.from({item_id: itemId, stats, modules: [], quantity})
20
+ }
21
+
22
+ function task(over: {type?: number; duration?: number; cargo?: ReturnType<typeof cargoItem>[]}) {
23
+ return ServerContract.Types.task.from({
24
+ type: over.type ?? TaskType.LOAD,
25
+ duration: over.duration ?? 60,
26
+ cancelable: 0,
27
+ cargo: over.cargo ?? [],
28
+ couplings: [],
29
+ })
30
+ }
31
+
32
+ function coupling(kind: number) {
33
+ return ServerContract.Types.coupling.from({
34
+ counterpart: {entity_type: 'ship', entity_id: 2},
35
+ hold: 1,
36
+ kind,
37
+ })
38
+ }
39
+
40
+ function craftTask(
41
+ cargo: ReturnType<typeof cargoItem>[],
42
+ couplings: ReturnType<typeof coupling>[]
43
+ ) {
44
+ return ServerContract.Types.task.from({
45
+ type: TaskType.CRAFT,
46
+ duration: 60,
47
+ cancelable: 0,
48
+ cargo,
49
+ couplings,
50
+ })
51
+ }
52
+
53
+ function entity(
54
+ cargo: ReturnType<typeof cargoItem>[],
55
+ tasks: ReturnType<typeof task>[] = [],
56
+ startedISO = T0
57
+ ) {
58
+ return ServerContract.Types.entity_info.from({
59
+ projected_at: 0,
60
+ type: 'ship',
61
+ id: 1,
62
+ owner: 'player.gm',
63
+ entity_name: 'Ship 1',
64
+ coordinates: {x: 0, y: 0, z: 0},
65
+ item_id: 1,
66
+ cargomass: 0,
67
+ cargo: cargo.map((c) => ({
68
+ item_id: c.item_id,
69
+ stats: c.stats,
70
+ modules: c.modules,
71
+ quantity: c.quantity,
72
+ id: 0,
73
+ })),
74
+ modules: [],
75
+ lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
76
+ gatherer_lanes: [],
77
+ crafter_lanes: [],
78
+ builder_lanes: [],
79
+ loader_lanes: [],
80
+ holds: [],
81
+ })
82
+ }
83
+
84
+ describe('cargoReadyAt', () => {
85
+ test('on-hand covers demand → epoch', () => {
86
+ const e = entity([cargoItem(7, 0, 10)])
87
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
88
+ expect(cargoReadyAt(e, demand)).toEqual(EPOCH)
89
+ })
90
+
91
+ test('own-lane LOAD covers demand → its completion', () => {
92
+ const loadCompletesAt = new Date('2026-06-19T00:01:00.000Z')
93
+ const e = entity(
94
+ [],
95
+ [task({type: TaskType.LOAD, duration: 60, cargo: [cargoItem(7, 0, 10)]})]
96
+ )
97
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
98
+ expect(cargoReadyAt(e, demand)).toEqual(loadCompletesAt)
99
+ })
100
+
101
+ test('incoming source covers demand → its until', () => {
102
+ const until = new Date('2026-06-19T00:00:30.000Z')
103
+ const e = entity([])
104
+ const incoming: IncomingSource[] = [{holdId: '1', until, items: [cargoItem(7, 0, 10)]}]
105
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
106
+ expect(cargoReadyAt(e, demand, incoming)).toEqual(until)
107
+ })
108
+
109
+ test('on-hand covers demand; unrelated same-item different-stats inflow does not delay readiness', () => {
110
+ const laterCompletesAt = new Date('2026-06-19T00:01:00.000Z')
111
+ const e = entity(
112
+ [cargoItem(7, 0, 10)],
113
+ [task({type: TaskType.LOAD, duration: 60, cargo: [cargoItem(7, 99, 10)]})]
114
+ )
115
+ const demand: CargoInput[] = [{itemId: 7, stats: 0n, quantity: 5}]
116
+ const result = cargoReadyAt(e, demand)
117
+ expect(result).toEqual(EPOCH)
118
+ expect(result).not.toEqual(laterCompletesAt)
119
+ })
120
+ })
121
+
122
+ describe('calcCounterpartDelivery', () => {
123
+ test('coupled CRAFT task returns output cargo only', () => {
124
+ const output = cargoItem(9, 0, 1)
125
+ const t = task({
126
+ type: TaskType.CRAFT,
127
+ cargo: [cargoItem(7, 0, 2), cargoItem(8, 0, 1), output],
128
+ })
129
+ const result = calcCounterpartDelivery(t, coupling(HoldKind.PUSH))
130
+ expect(result.length).toBe(1)
131
+ expect(result[0].item_id.toNumber()).toBe(9)
132
+ })
133
+
134
+ test('CRAFT task with empty cargo returns []', () => {
135
+ const t = task({type: TaskType.CRAFT, cargo: []})
136
+ expect(calcCounterpartDelivery(t, coupling(HoldKind.PUSH))).toEqual([])
137
+ })
138
+
139
+ test('coupled UNLOAD task returns whole cargo', () => {
140
+ const t = task({type: TaskType.UNLOAD, cargo: [cargoItem(7, 0, 2), cargoItem(8, 0, 1)]})
141
+ const result = calcCounterpartDelivery(t, coupling(HoldKind.GATHER))
142
+ expect(result.length).toBe(2)
143
+ })
144
+
145
+ test('non-incoming coupling kind returns []', () => {
146
+ const t = task({type: TaskType.UNLOAD, cargo: [cargoItem(7, 0, 2)]})
147
+ expect(calcCounterpartDelivery(t, coupling(HoldKind.PULL))).toEqual([])
148
+ })
149
+ })
150
+
151
+ describe('projectedCargoAvailableAt — incoming', () => {
152
+ const until = new Date('2026-06-19T00:00:30.000Z')
153
+
154
+ function incomingFor(qty: number): IncomingSource[] {
155
+ return [{holdId: '1', until, items: [cargoItem(7, 0, qty)]}]
156
+ }
157
+
158
+ test('credits an incoming source strictly before its until', () => {
159
+ const e = entity([])
160
+ const avail = projectedCargoAvailableAt(e, new Date(until.getTime() + 1), incomingFor(10))
161
+ expect(avail.get('7:0')).toBe(10n)
162
+ })
163
+
164
+ test('does not credit an incoming source at exactly its until', () => {
165
+ const e = entity([])
166
+ const avail = projectedCargoAvailableAt(e, until, incomingFor(10))
167
+ expect(avail.get('7:0') ?? 0n).toBe(0n)
168
+ })
169
+
170
+ test('does not credit an incoming source before its until', () => {
171
+ const e = entity([])
172
+ const avail = projectedCargoAvailableAt(e, new Date(until.getTime() - 1), incomingFor(10))
173
+ expect(avail.get('7:0') ?? 0n).toBe(0n)
174
+ })
175
+ })
176
+
177
+ describe('clustercraft projection parity', () => {
178
+ const inputs = [cargoItem(1001, 0, 10), cargoItem(1002, 0, 5)]
179
+ const output = cargoItem(2001, 0, 1)
180
+
181
+ test('own-hold craft: inputs removed, output added', () => {
182
+ const eff = taskCargoEffect(craftTask([...inputs, output], []))
183
+ expect(eff.removed.length).toBe(2)
184
+ expect(eff.added).toEqual([output])
185
+ })
186
+
187
+ test('clustercraft to a target: no crafter debit, no crafter credit', () => {
188
+ const t = craftTask(
189
+ [...inputs, output],
190
+ [coupling(HoldKind.SOURCE), coupling(HoldKind.GATHER)]
191
+ )
192
+ expect(hasSourceCoupling(t)).toBe(true)
193
+ expect(hasIncomingCoupling(t)).toBe(true)
194
+ const eff = taskCargoEffect(t)
195
+ expect(eff.removed).toEqual([])
196
+ expect(eff.added).toEqual([])
197
+ })
198
+
199
+ test('clustercraft with target omitted: output falls back to crafter, still no input debit', () => {
200
+ const t = craftTask([...inputs, output], [coupling(HoldKind.SOURCE)])
201
+ expect(hasIncomingCoupling(t)).toBe(false)
202
+ const eff = taskCargoEffect(t)
203
+ expect(eff.removed).toEqual([])
204
+ expect(eff.added).toEqual([output])
205
+ })
206
+ })
207
+
208
+ function simpleTask(type: number) {
209
+ return ServerContract.Types.task.from({
210
+ type,
211
+ duration: 60,
212
+ cancelable: 0,
213
+ cargo: [cargoItem(401, 0, 100)],
214
+ couplings: [],
215
+ })
216
+ }
217
+
218
+ describe('taskCargoEffect task coverage', () => {
219
+ test('a contribute removes its cargo', () => {
220
+ const effect = taskCargoEffect(simpleTask(TaskType.CONTRIBUTE))
221
+ expect(effect.removed).toHaveLength(1)
222
+ expect(effect.added).toHaveLength(0)
223
+ })
224
+
225
+ test('a depot store removes its cargo', () => {
226
+ const effect = taskCargoEffect(simpleTask(TaskType.CIVIC_DEPOSIT))
227
+ expect(effect.removed).toHaveLength(1)
228
+ expect(effect.added).toHaveLength(0)
229
+ })
230
+
231
+ test('a depot take adds its cargo', () => {
232
+ const effect = taskCargoEffect(simpleTask(TaskType.CIVIC_WITHDRAW))
233
+ expect(effect.added).toHaveLength(1)
234
+ expect(effect.removed).toHaveLength(0)
235
+ })
236
+ })
237
+
238
+ describe('incomingSources', () => {
239
+ const RECEIVER = 1
240
+ const SENDER = 2
241
+
242
+ function receiver(holds: {id: number; kind: number; from: number; until: string}[]) {
243
+ const e = entity([])
244
+ return ServerContract.Types.entity_info.from({
245
+ projected_at: 0,
246
+ ...JSON.parse(JSON.stringify(e)),
247
+ id: RECEIVER,
248
+ holds: holds.map((h) => ({
249
+ id: h.id,
250
+ kind: h.kind,
251
+ counterpart: {entity_type: 'ship', entity_id: h.from},
252
+ until: h.until,
253
+ incoming_mass: 100,
254
+ })),
255
+ })
256
+ }
257
+
258
+ function sender(tasks: ReturnType<typeof craftTask>[]) {
259
+ const e = entity([], [])
260
+ return ServerContract.Types.entity_info.from({
261
+ projected_at: 0,
262
+ ...JSON.parse(JSON.stringify(e)),
263
+ id: SENDER,
264
+ lanes: [{lane_key: 0, schedule: {started: T0, tasks}}],
265
+ })
266
+ }
267
+
268
+ function coupledTo(hold: number, entityId: number, kind: number) {
269
+ return ServerContract.Types.coupling.from({
270
+ counterpart: {entity_type: 'ship', entity_id: entityId},
271
+ hold,
272
+ kind,
273
+ })
274
+ }
275
+
276
+ test('a coupled craft output resolves to the receiving hold', () => {
277
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
278
+ const from = sender([
279
+ craftTask(
280
+ [cargoItem(7, 0, 4), cargoItem(9, 0, 1)],
281
+ [coupledTo(1, RECEIVER, HoldKind.GATHER)]
282
+ ),
283
+ ])
284
+ const sources = incomingSources(to, () => from)
285
+ expect(sources).toHaveLength(1)
286
+ // A craft delivers its output slot alone, never its inputs.
287
+ expect(sources[0].items).toHaveLength(1)
288
+ expect(sources[0].items[0].item_id.toNumber()).toBe(9)
289
+ expect(sources[0].holdId).toBe('1')
290
+ })
291
+
292
+ test('a hold whose counterpart is unknown yields nothing', () => {
293
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
294
+ expect(incomingSources(to, () => undefined)).toHaveLength(0)
295
+ })
296
+
297
+ test('an excluded hold is skipped', () => {
298
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
299
+ const from = sender([
300
+ craftTask([cargoItem(9, 0, 1)], [coupledTo(1, RECEIVER, HoldKind.GATHER)]),
301
+ ])
302
+ expect(incomingSources(to, () => from, ['1'])).toHaveLength(0)
303
+ })
304
+
305
+ test('a coupling naming another hold does not fill this one', () => {
306
+ const to = receiver([{id: 1, kind: HoldKind.GATHER, from: SENDER, until: T0}])
307
+ const from = sender([
308
+ craftTask([cargoItem(9, 0, 1)], [coupledTo(2, RECEIVER, HoldKind.GATHER)]),
309
+ ])
310
+ expect(incomingSources(to, () => from)).toHaveLength(0)
311
+ })
312
+
313
+ test('an outgoing hold kind is not an incoming source', () => {
314
+ const to = receiver([{id: 1, kind: HoldKind.PULL, from: SENDER, until: T0}])
315
+ const from = sender([
316
+ craftTask([cargoItem(9, 0, 1)], [coupledTo(1, RECEIVER, HoldKind.GATHER)]),
317
+ ])
318
+ expect(incomingSources(to, () => from)).toHaveLength(0)
319
+ })
320
+ })
@@ -0,0 +1,251 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {HoldKind, TaskType} from '../types'
3
+ import * as schedule from './schedule'
4
+ import {taskCargoRule} from './task-effects'
5
+
6
+ type Task = ServerContract.Types.task
7
+ type Coupling = ServerContract.Types.coupling
8
+ type CargoItem = ServerContract.Types.cargo_item
9
+ type ModuleEntry = ServerContract.Types.module_entry
10
+
11
+ export interface CargoEffect {
12
+ added: CargoItem[]
13
+ removed: CargoItem[]
14
+ }
15
+
16
+ export interface AvailabilityInput extends schedule.ScheduleData {
17
+ cargo: CargoItem[]
18
+ }
19
+
20
+ export interface CargoInput {
21
+ itemId: number
22
+ stats: bigint
23
+ modules?: ModuleEntry[]
24
+ quantity: number
25
+ }
26
+
27
+ export interface IncomingSource {
28
+ holdId: string
29
+ until: Date
30
+ items: CargoItem[]
31
+ }
32
+
33
+ const INCOMING_COUPLING_KINDS = new Set<number>([HoldKind.PUSH, HoldKind.GATHER, HoldKind.FLIGHT])
34
+
35
+ // Mirrors is_incoming_hold_kind: PUSH/GATHER/FLIGHT couplings deliver to the counterpart.
36
+ export function isIncomingCouplingKind(kind: number): boolean {
37
+ return INCOMING_COUPLING_KINDS.has(kind)
38
+ }
39
+
40
+ // Mirrors has_source_hold: a HOLD_SOURCE coupling marks an escrowed clustercraft.
41
+ export function hasSourceCoupling(task: Task): boolean {
42
+ return task.couplings.some((c) => c.kind.toNumber() === HoldKind.SOURCE)
43
+ }
44
+
45
+ // Mirrors has_incoming_coupling: any PUSH/GATHER/FLIGHT coupling delivers output to a counterpart.
46
+ export function hasIncomingCoupling(task: Task): boolean {
47
+ return task.couplings.some((c) => isIncomingCouplingKind(c.kind.toNumber()))
48
+ }
49
+
50
+ // A CRAFT task's output ownership: clustered escrows inputs off other members; own-output lands on the crafter.
51
+ export function craftCargoOwnership(task: Task): {clustered: boolean; ownOutput: boolean} {
52
+ const clustered = hasSourceCoupling(task)
53
+ return {
54
+ clustered,
55
+ ownOutput: clustered ? !hasIncomingCoupling(task) : task.couplings.length === 0,
56
+ }
57
+ }
58
+
59
+ // Mirrors calc_counterpart_delivery: incoming-kind couplings only; a coupled CRAFT yields its output slot.
60
+ export function calcCounterpartDelivery(task: Task, coupling: Coupling): CargoItem[] {
61
+ if (!isIncomingCouplingKind(coupling.kind.toNumber())) return []
62
+ if (task.type.toNumber() === TaskType.CRAFT) {
63
+ return task.cargo.length === 0 ? [] : [task.cargo[task.cargo.length - 1]]
64
+ }
65
+ return task.cargo
66
+ }
67
+
68
+ export interface IncomingHoldEntity extends schedule.ScheduleData {
69
+ id: {toString(): string}
70
+ holds?: ServerContract.Types.hold[]
71
+ }
72
+
73
+ // Mirrors incoming_sources: each incoming hold resolved through the counterpart task that will fill it.
74
+ export function incomingSources(
75
+ entity: IncomingHoldEntity,
76
+ findCounterpart: (entityId: string) => schedule.ScheduleData | undefined,
77
+ excludedHoldIds: readonly string[] = []
78
+ ): IncomingSource[] {
79
+ const out: IncomingSource[] = []
80
+ const excluded = new Set(excludedHoldIds.map((id) => String(id)))
81
+ const selfId = entity.id.toString()
82
+ for (const hold of entity.holds ?? []) {
83
+ if (!isIncomingCouplingKind(hold.kind.toNumber())) continue
84
+ const holdId = hold.id.toString()
85
+ if (excluded.has(holdId)) continue
86
+ const counterpart = findCounterpart(hold.counterpart.entity_id.toString())
87
+ if (!counterpart) continue
88
+ let matched = false
89
+ for (const lane of counterpart.lanes ?? []) {
90
+ if (matched) break
91
+ for (const task of lane.schedule.tasks) {
92
+ if (matched) break
93
+ for (const coupling of task.couplings) {
94
+ if (coupling.hold.toString() !== holdId) continue
95
+ if (coupling.counterpart.entity_id.toString() !== selfId) continue
96
+ const items = calcCounterpartDelivery(task, coupling)
97
+ if (items.length > 0) {
98
+ out.push({holdId, until: hold.until.toDate(), items})
99
+ }
100
+ matched = true
101
+ break
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return out
107
+ }
108
+
109
+ export function taskCargoEffect(task: Task): CargoEffect {
110
+ switch (taskCargoRule(task.type.toNumber())) {
111
+ case 'all-in':
112
+ case 'undeploy':
113
+ return {added: task.cargo, removed: []}
114
+ case 'all-out':
115
+ return {added: [], removed: task.cargo}
116
+ case 'gather':
117
+ return task.couplings.length > 0
118
+ ? {added: [], removed: []}
119
+ : {added: task.cargo, removed: []}
120
+ case 'craft': {
121
+ if (task.cargo.length === 0) return {added: [], removed: []}
122
+ const {clustered, ownOutput} = craftCargoOwnership(task)
123
+ return {
124
+ added: ownOutput ? [task.cargo[task.cargo.length - 1]] : [],
125
+ removed: clustered ? [] : task.cargo.slice(0, -1),
126
+ }
127
+ }
128
+ default:
129
+ return {added: [], removed: []}
130
+ }
131
+ }
132
+
133
+ function refKey(itemId: number, stats: bigint, modules: ModuleEntry[], entityId: string): string {
134
+ const base = `${itemId}:${stats.toString()}`
135
+ const normalizedEntityId = entityId !== '0' ? entityId : ''
136
+ if (modules.length === 0 && normalizedEntityId === '') return base
137
+ return `${base}:modules=${JSON.stringify(modules)}:entity=${normalizedEntityId}`
138
+ }
139
+
140
+ export function cargoKey(item: CargoItem): string {
141
+ return refKey(
142
+ item.item_id.toNumber(),
143
+ BigInt(item.stats.toString()),
144
+ item.modules ?? [],
145
+ item.entity_id?.toString() ?? ''
146
+ )
147
+ }
148
+
149
+ export function cargoInputKey(input: CargoInput): string {
150
+ return refKey(input.itemId, input.stats, input.modules ?? [], '')
151
+ }
152
+
153
+ function cargoQuantity(item: CargoItem): bigint {
154
+ return BigInt(item.quantity.toString())
155
+ }
156
+
157
+ export function projectedCargoAvailableAt(
158
+ entity: AvailabilityInput,
159
+ at: Date,
160
+ incoming: readonly IncomingSource[] = []
161
+ ): Map<string, bigint> {
162
+ const avail = new Map<string, bigint>()
163
+
164
+ for (const item of entity.cargo) {
165
+ const key = cargoKey(item)
166
+ avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
167
+ }
168
+
169
+ // Every scheduled task reserves inputs against the unsettled cargo base, even already-elapsed ones.
170
+ const tasks = schedule.orderedTasks(entity)
171
+
172
+ for (const ordered of tasks) {
173
+ if (ordered.completesAt.getTime() >= at.getTime()) continue
174
+
175
+ for (const item of taskCargoEffect(ordered.task).added) {
176
+ const key = cargoKey(item)
177
+ avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
178
+ }
179
+ }
180
+
181
+ for (const src of incoming) {
182
+ if (src.until.getTime() >= at.getTime()) continue
183
+ for (const item of src.items) {
184
+ const key = cargoKey(item)
185
+ avail.set(key, (avail.get(key) ?? 0n) + cargoQuantity(item))
186
+ }
187
+ }
188
+
189
+ for (const ordered of tasks) {
190
+ for (const item of taskCargoEffect(ordered.task).removed) {
191
+ const key = cargoKey(item)
192
+ const current = avail.get(key) ?? 0n
193
+ const quantity = cargoQuantity(item)
194
+ avail.set(key, current > quantity ? current - quantity : 0n)
195
+ }
196
+ }
197
+
198
+ return avail
199
+ }
200
+
201
+ // Earliest-sufficient candidate (epoch, own-lane producer completions, incoming untils); else the latest candidate.
202
+ export function cargoReadyAt(
203
+ entity: AvailabilityInput,
204
+ inputs: readonly CargoInput[],
205
+ incoming: readonly IncomingSource[] = []
206
+ ): Date {
207
+ const demand = new Map<string, bigint>()
208
+ for (const input of inputs) {
209
+ const key = cargoInputKey(input)
210
+ demand.set(key, (demand.get(key) ?? 0n) + BigInt(input.quantity))
211
+ }
212
+
213
+ const candidates: number[] = [0]
214
+ for (const ordered of schedule.orderedTasks(entity)) {
215
+ for (const item of taskCargoEffect(ordered.task).added) {
216
+ if (demand.has(cargoKey(item))) {
217
+ candidates.push(ordered.completesAt.getTime())
218
+ break
219
+ }
220
+ }
221
+ }
222
+ for (const src of incoming) {
223
+ if (src.items.some((item) => demand.has(cargoKey(item)))) {
224
+ candidates.push(src.until.getTime())
225
+ }
226
+ }
227
+ candidates.sort((a, b) => a - b)
228
+
229
+ for (const candidateMs of candidates) {
230
+ // +1ms mirrors the contract's candidate+1µs probe: inclusive at the candidate instant.
231
+ const available = projectedCargoAvailableAt(entity, new Date(candidateMs + 1), incoming)
232
+ let sufficient = true
233
+ for (const [key, quantity] of demand) {
234
+ if ((available.get(key) ?? 0n) < quantity) {
235
+ sufficient = false
236
+ break
237
+ }
238
+ }
239
+ if (sufficient) return new Date(candidateMs)
240
+ }
241
+ return new Date(candidates[candidates.length - 1])
242
+ }
243
+
244
+ export function availableForItem(avail: Map<string, bigint>, itemId: number): bigint {
245
+ const prefix = `${itemId}:`
246
+ let total = 0n
247
+ for (const [key, quantity] of avail) {
248
+ if (key.startsWith(prefix)) total += quantity
249
+ }
250
+ return total
251
+ }