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

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 +5696 -1503
  7. package/lib/shipload.js +20294 -5200
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +20371 -5683
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1648 -0
  12. package/lib/testing.js +6980 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +6974 -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 +2661 -466
  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 +411 -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 +940 -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 +80 -0
  129. package/src/managers/jobs.ts +54 -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 +49 -0
  161. package/src/scheduling/jobs.ts +137 -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 +227 -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,254 @@
1
+ import type {ServerContract} from '../contracts'
2
+ import {HoldKind, TaskCancelable, TaskType} from '../types'
3
+ import {calcCargoItemMass} from '../capabilities/storage'
4
+ import {
5
+ taskCargoEffect,
6
+ cargoKey,
7
+ isIncomingCouplingKind,
8
+ type IncomingSource,
9
+ } from './availability'
10
+ import * as schedule from './schedule'
11
+ import {validateSchedule, type Projectable} from './projection'
12
+
13
+ export enum CancelBlockReason {
14
+ TASK_NEVER = 'TASK_NEVER',
15
+ BEFORE_START_RUNNING = 'BEFORE_START_RUNNING',
16
+ DONE = 'DONE',
17
+ CONTAINS_LINKED_TASK = 'CONTAINS_LINKED_TASK',
18
+ WOULD_STRAND = 'WOULD_STRAND',
19
+ WOULD_STRAND_COUNTERPART = 'WOULD_STRAND_COUNTERPART',
20
+ WOULD_OVERFILL = 'WOULD_OVERFILL',
21
+ NOT_OWNER = 'NOT_OWNER',
22
+ }
23
+
24
+ type Task = InstanceType<typeof ServerContract.Types.task>
25
+ type EntityInfo = InstanceType<typeof ServerContract.Types.entity_info>
26
+ type EntityRef = InstanceType<typeof ServerContract.Types.entity_ref>
27
+ type CargoItem = InstanceType<typeof ServerContract.Types.cargo_item>
28
+
29
+ export interface CancelRefund {
30
+ giver: EntityRef
31
+ cargo: CargoItem[]
32
+ }
33
+ export interface CancelReleasedHold {
34
+ counterpart: EntityRef
35
+ kind: number
36
+ }
37
+ export interface CancelEffects {
38
+ refunds: CancelRefund[]
39
+ releasedHolds: CancelReleasedHold[]
40
+ abandonsRunning: boolean
41
+ keepsPlotDeposits?: {plot: EntityRef}
42
+ energyForfeited?: number
43
+ }
44
+ export interface CancelPlan {
45
+ ok: boolean
46
+ blockedReason?: CancelBlockReason
47
+ blockedByCounterpart?: EntityRef
48
+ range: {count: number; taskIndices: number[]}
49
+ effects: CancelEffects
50
+ }
51
+ export interface CancelEligibilityInput {
52
+ now: Date
53
+ counterparts?: Map<string, EntityInfo>
54
+ counterpartIncoming?: Map<string, readonly IncomingSource[]>
55
+ }
56
+
57
+ const EMPTY_EFFECTS: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
58
+
59
+ function postCancelEntity(entity: EntityInfo, laneKey: number, fromTaskIndex: number): EntityInfo {
60
+ const clone = (entity.constructor as typeof ServerContract.Types.entity_info).from(
61
+ JSON.parse(JSON.stringify(entity.toJSON()))
62
+ ) as EntityInfo
63
+ const lane = clone.lanes.find((l) => l.lane_key.toNumber() === laneKey)!
64
+ lane.schedule.tasks = lane.schedule.tasks.slice(0, fromTaskIndex)
65
+ return clone
66
+ }
67
+
68
+ // C1: every pending consumer keeps its inputs covered by on-hand + own-lane producers strictly-before + incoming strictly-before.
69
+ function queueFeasible(entity: EntityInfo, incoming: readonly IncomingSource[] = []): boolean {
70
+ const ordered = schedule.orderedTasks(entity as unknown as Projectable)
71
+ const base = new Map<string, number>()
72
+ for (const c of entity.cargo ?? []) {
73
+ const k = cargoKey(c)
74
+ base.set(k, (base.get(k) ?? 0) + c.quantity.toNumber())
75
+ }
76
+ const isConsumer = (t: Task) =>
77
+ t.type.toNumber() === TaskType.CRAFT ||
78
+ t.type.toNumber() === TaskType.UNLOAD ||
79
+ t.type.toNumber() === TaskType.UPGRADE
80
+ for (const self of ordered) {
81
+ if (!isConsumer(self.task)) continue
82
+ const map = new Map(base)
83
+ for (const other of ordered) {
84
+ if (other.completesAt.getTime() >= self.completesAt.getTime()) continue
85
+ for (const out of taskCargoEffect(other.task).added) {
86
+ map.set(cargoKey(out), (map.get(cargoKey(out)) ?? 0) + out.quantity.toNumber())
87
+ }
88
+ }
89
+ for (const src of incoming) {
90
+ if (src.until.getTime() >= self.completesAt.getTime()) continue
91
+ for (const item of src.items) {
92
+ map.set(cargoKey(item), (map.get(cargoKey(item)) ?? 0) + item.quantity.toNumber())
93
+ }
94
+ }
95
+ for (const other of ordered) {
96
+ if (other === self) continue
97
+ for (const inp of taskCargoEffect(other.task).removed) {
98
+ const cur = map.get(cargoKey(inp)) ?? 0
99
+ map.set(cargoKey(inp), Math.max(0, cur - inp.quantity.toNumber()))
100
+ }
101
+ }
102
+ for (const inp of taskCargoEffect(self.task).removed) {
103
+ if ((map.get(cargoKey(inp)) ?? 0) < inp.quantity.toNumber()) return false
104
+ }
105
+ }
106
+ return true
107
+ }
108
+
109
+ function feasibleAfterCancel(post: EntityInfo): boolean {
110
+ if (!queueFeasible(post)) return false
111
+ try {
112
+ validateSchedule(post as unknown as Projectable)
113
+ } catch {
114
+ return false
115
+ }
116
+ return true
117
+ }
118
+
119
+ interface Timing {
120
+ startsAt: number
121
+ completesAt: number
122
+ running: boolean
123
+ done: boolean
124
+ }
125
+
126
+ function laneTiming(
127
+ lane: {schedule: {started: {toDate(): Date}; tasks: Task[]}},
128
+ nowMs: number
129
+ ): Timing[] {
130
+ const startedMs = lane.schedule.started.toDate().getTime()
131
+ let endSec = 0
132
+ return lane.schedule.tasks.map((t) => {
133
+ const startsAt = startedMs + endSec * 1000
134
+ endSec += t.duration.toNumber()
135
+ const completesAt = startedMs + endSec * 1000
136
+ return {
137
+ startsAt,
138
+ completesAt,
139
+ running: nowMs >= startsAt && nowMs < completesAt,
140
+ done: nowMs >= completesAt,
141
+ }
142
+ })
143
+ }
144
+
145
+ export function cancelEligibility(
146
+ entity: EntityInfo,
147
+ laneKey: number,
148
+ fromTaskIndex: number,
149
+ input: CancelEligibilityInput
150
+ ): CancelPlan {
151
+ const lane = (entity.lanes ?? []).find((l) => l.lane_key.equals(laneKey))
152
+ if (!lane || fromTaskIndex < 0 || fromTaskIndex >= lane.schedule.tasks.length) {
153
+ return {ok: false, range: {count: 0, taskIndices: []}, effects: {...EMPTY_EFFECTS}}
154
+ }
155
+
156
+ const tasks = lane.schedule.tasks
157
+ const timing = laneTiming(lane, input.now.getTime())
158
+ const taskIndices: number[] = []
159
+ for (let i = fromTaskIndex; i < tasks.length; i++) taskIndices.push(i)
160
+ const range = {count: taskIndices.length, taskIndices}
161
+
162
+ const block = (blockedReason: CancelBlockReason): CancelPlan => ({
163
+ ok: false,
164
+ blockedReason,
165
+ range,
166
+ effects: {...EMPTY_EFFECTS},
167
+ })
168
+
169
+ for (const i of taskIndices) {
170
+ const t = tasks[i]
171
+ if (t.entitygroup && !t.entitygroup.equals(0))
172
+ return block(CancelBlockReason.CONTAINS_LINKED_TASK)
173
+ }
174
+
175
+ for (const i of taskIndices) {
176
+ const t = tasks[i]
177
+ if (timing[i].done) return block(CancelBlockReason.DONE)
178
+ if (t.cancelable.equals(TaskCancelable.NEVER) && !t.type.equals(TaskType.IDLE))
179
+ return block(CancelBlockReason.TASK_NEVER)
180
+ if (t.cancelable.equals(TaskCancelable.BEFORE_START) && timing[i].running)
181
+ return block(CancelBlockReason.BEFORE_START_RUNNING)
182
+ }
183
+
184
+ const post = postCancelEntity(entity, laneKey, fromTaskIndex)
185
+ if (!feasibleAfterCancel(post)) return block(CancelBlockReason.WOULD_STRAND)
186
+
187
+ // cross-entity C1: a counterpart losing this delivery must still feed its own queued consumers.
188
+ const releasedByCounterpart = new Map<string, {counterpart: EntityRef; holdIds: Set<string>}>()
189
+ for (const i of taskIndices) {
190
+ for (const c of tasks[i].couplings ?? []) {
191
+ if (!isIncomingCouplingKind(c.kind.toNumber())) continue
192
+ const id = c.counterpart.entity_id.toString()
193
+ const entry = releasedByCounterpart.get(id) ?? {
194
+ counterpart: c.counterpart,
195
+ holdIds: new Set<string>(),
196
+ }
197
+ entry.holdIds.add(c.hold.toString())
198
+ releasedByCounterpart.set(id, entry)
199
+ }
200
+ }
201
+ for (const [counterpartId, released] of releasedByCounterpart) {
202
+ const counterpart = input.counterparts?.get(counterpartId)
203
+ if (!counterpart) continue
204
+ const incoming = (input.counterpartIncoming?.get(counterpartId) ?? []).filter(
205
+ (src) => !released.holdIds.has(src.holdId)
206
+ )
207
+ if (!queueFeasible(counterpart, incoming)) {
208
+ return {
209
+ ok: false,
210
+ blockedReason: CancelBlockReason.WOULD_STRAND_COUNTERPART,
211
+ blockedByCounterpart: released.counterpart,
212
+ range,
213
+ effects: {...EMPTY_EFFECTS},
214
+ }
215
+ }
216
+ }
217
+
218
+ const effects: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
219
+ let energyForfeited = 0
220
+ for (const i of taskIndices) {
221
+ const t = tasks[i]
222
+ if (timing[i].running && t.cancelable.equals(TaskCancelable.ALWAYS))
223
+ effects.abandonsRunning = true
224
+ if (t.energy_cost) energyForfeited += t.energy_cost.toNumber()
225
+ if (t.type.equals(TaskType.BUILDPLOT) && t.couplings.length > 0)
226
+ effects.keepsPlotDeposits = {plot: t.couplings[0].counterpart}
227
+ for (const c of t.couplings) {
228
+ const kind = c.kind.toNumber()
229
+ effects.releasedHolds.push({counterpart: c.counterpart, kind})
230
+ if (kind === HoldKind.PULL) {
231
+ effects.refunds.push({giver: c.counterpart, cargo: t.cargo})
232
+ const giver = input.counterparts?.get(c.counterpart.entity_id.toString())
233
+ if (giver) {
234
+ const returned = t.cargo.reduce(
235
+ (s, c) => s + calcCargoItemMass(c).toNumber(),
236
+ 0
237
+ )
238
+ const cap = giver.capacity ? giver.capacity.toNumber() : Number.MAX_SAFE_INTEGER
239
+ if (giver.cargomass.toNumber() + returned > cap) {
240
+ return {
241
+ ok: false,
242
+ blockedReason: CancelBlockReason.WOULD_OVERFILL,
243
+ range,
244
+ effects: {...EMPTY_EFFECTS},
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+ if (energyForfeited > 0) effects.energyForfeited = energyForfeited
252
+
253
+ return {ok: true, range, effects}
254
+ }
@@ -0,0 +1,31 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {ServerContract, TaskType} from '../index-module'
3
+ import {availableForItem} from './availability'
4
+ import {clusterStockAvailable} from './cluster-stock'
5
+
6
+ const AT = new Date('2026-06-19T00:00:00')
7
+
8
+ function cargoItem(itemId: number, stats: number, quantity: number) {
9
+ return ServerContract.Types.cargo_item.from({item_id: itemId, stats, modules: [], quantity})
10
+ }
11
+
12
+ function member(cargo: ReturnType<typeof cargoItem>[]) {
13
+ return {cargo, tasks: [], lanes: []} as unknown as Parameters<
14
+ typeof clusterStockAvailable
15
+ >[0][number]
16
+ }
17
+
18
+ describe('clusterStockAvailable', () => {
19
+ test('sums the same item split across two members', () => {
20
+ const stock = clusterStockAvailable(
21
+ [member([cargoItem(1001, 0, 30)]), member([cargoItem(1001, 0, 20)])],
22
+ AT
23
+ )
24
+ expect(availableForItem(stock, 1001)).toBe(50n)
25
+ })
26
+
27
+ test('an empty cluster yields nothing', () => {
28
+ const stock = clusterStockAvailable([], AT)
29
+ expect(availableForItem(stock, 1001)).toBe(0n)
30
+ })
31
+ })
@@ -0,0 +1,15 @@
1
+ import {type AvailabilityInput, projectedCargoAvailableAt} from './availability'
2
+
3
+ // Union of every cluster member's present available cargo (present-only; incoming/future not credited).
4
+ export function clusterStockAvailable(
5
+ members: readonly AvailabilityInput[],
6
+ at: Date
7
+ ): Map<string, bigint> {
8
+ const total = new Map<string, bigint>()
9
+ for (const member of members) {
10
+ for (const [key, qty] of projectedCargoAvailableAt(member, at)) {
11
+ total.set(key, (total.get(key) ?? 0n) + qty)
12
+ }
13
+ }
14
+ return total
15
+ }
@@ -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
+ }
@@ -10,6 +10,7 @@ export interface EpochInfo {
10
10
  export function getCurrentEpoch(game: PlatformContract.Types.game_row): UInt64 {
11
11
  const current = new Date().getTime()
12
12
  const difference = (current - game.config.start.toMilliseconds()) / 1000
13
+ if (difference < 0) return UInt64.from(0)
13
14
  const epoch = Math.floor(difference / Number(game.config.epochtime)) + 1
14
15
  return UInt64.from(epoch)
15
16
  }
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {jobStatus, jobStatusLabel, splitJobCargo} from './jobs'
3
+
4
+ const at = (s: string) => new Date(s)
5
+
6
+ describe('jobStatus', () => {
7
+ const job = {startsAt: at('2026-07-26T10:00:00Z'), completesAt: at('2026-07-26T11:00:00Z')}
8
+ it('is waiting before startsAt', () => {
9
+ expect(jobStatus(job, at('2026-07-26T09:59:59Z'))).toBe('waiting')
10
+ })
11
+ it('is crafting between startsAt and completesAt', () => {
12
+ expect(jobStatus(job, at('2026-07-26T10:30:00Z'))).toBe('crafting')
13
+ })
14
+ it('is ready at or after completesAt', () => {
15
+ expect(jobStatus(job, at('2026-07-26T11:00:00Z'))).toBe('ready')
16
+ expect(jobStatus(job, at('2026-07-26T12:00:00Z'))).toBe('ready')
17
+ })
18
+ it('is inline while the drop-off is still in the air', () => {
19
+ const booked = {
20
+ startsAt: at('1970-01-01T00:00:00Z'),
21
+ completesAt: at('1970-01-01T00:00:00Z'),
22
+ }
23
+ expect(jobStatus({...booked, deposited: false}, at('2026-07-26T10:00:00Z'))).toBe('inline')
24
+ expect(jobStatus({...job, deposited: true}, at('2026-07-26T10:30:00Z'))).toBe('crafting')
25
+ })
26
+ it('labels every state', () => {
27
+ expect(jobStatusLabel('inline')).toBe('In Line')
28
+ expect(jobStatusLabel('ready')).toBe('Ready for Pickup')
29
+ })
30
+ })
31
+
32
+ describe('splitJobCargo', () => {
33
+ it('reads an In Line row as inputs with no output', () => {
34
+ const cargo = [{n: 'a'}, {n: 'b'}] as unknown as Parameters<typeof splitJobCargo>[0]
35
+ const {output, inputs} = splitJobCargo(cargo, false)
36
+ expect(output).toBeNull()
37
+ expect(inputs).toEqual([{n: 'a'}, {n: 'b'}] as never)
38
+ })
39
+ it('reads a landed row as the output with no inputs', () => {
40
+ const cargo = [{n: 'out'}] as unknown as Parameters<typeof splitJobCargo>[0]
41
+ const {output, inputs} = splitJobCargo(cargo, true)
42
+ expect(output).toEqual({n: 'out'} as never)
43
+ expect(inputs).toEqual([] as never)
44
+ })
45
+ it('returns null output for empty cargo', () => {
46
+ expect(splitJobCargo([], true)).toEqual({output: null, inputs: []})
47
+ expect(splitJobCargo([], false)).toEqual({output: null, inputs: []})
48
+ })
49
+ })
@@ -0,0 +1,137 @@
1
+ import type {ServerContract} from '../contracts'
2
+
3
+ type CargoItem = ServerContract.Types.cargo_item
4
+
5
+ export interface JobWindow {
6
+ id: number
7
+ socket: number
8
+ owner: string
9
+ startsAt: Date
10
+ completesAt: Date
11
+ recipeId: number
12
+ quantity: number
13
+ /** False while the job is In Line: booked, with its inputs still in transit to the building. */
14
+ deposited: boolean
15
+ /** Packed stat roll of the job's output, when the source carried the job's cargo. */
16
+ outputStats?: bigint
17
+ }
18
+
19
+ export interface JobLaneEntry {
20
+ kind: 'idle' | 'job'
21
+ startsAt: Date
22
+ completesAt: Date
23
+ job?: JobWindow
24
+ }
25
+
26
+ export interface JobLane {
27
+ socket: number
28
+ entries: JobLaneEntry[]
29
+ }
30
+
31
+ export const JOB_QUEUE_CAP = 25
32
+
33
+ function activeOn(jobs: JobWindow[], socket: number, now: Date): JobWindow[] {
34
+ return jobs
35
+ .filter((j) => j.socket === socket && j.completesAt > now)
36
+ .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
37
+ }
38
+
39
+ export function jobsToLanes(jobs: JobWindow[], socketCount: number, now: Date): JobLane[] {
40
+ const lanes: JobLane[] = []
41
+ for (let socket = 0; socket < socketCount; socket++) {
42
+ const entries: JobLaneEntry[] = []
43
+ let cursor: Date | null = null
44
+ for (const j of activeOn(jobs, socket, now)) {
45
+ if (cursor && j.startsAt > cursor) {
46
+ entries.push({kind: 'idle', startsAt: cursor, completesAt: j.startsAt})
47
+ }
48
+ entries.push({kind: 'job', startsAt: j.startsAt, completesAt: j.completesAt, job: j})
49
+ cursor = j.completesAt
50
+ }
51
+ lanes.push({socket, entries})
52
+ }
53
+ return lanes
54
+ }
55
+
56
+ export function socketTail(jobs: JobWindow[], socket: number, now: Date): Date {
57
+ const active = activeOn(jobs, socket, now)
58
+ const last = active[active.length - 1]
59
+ return last && last.completesAt > now ? last.completesAt : now
60
+ }
61
+
62
+ export function pickFabricator(
63
+ jobs: JobWindow[],
64
+ sockets: Array<{open: boolean}>,
65
+ durationBySocketMinutes: number[],
66
+ now: Date
67
+ ): {slot: number; startsAt: Date; completesAt: Date} | null {
68
+ let best: {slot: number; startsAt: Date; completesAt: Date} | null = null
69
+ for (let slot = 0; slot < sockets.length; slot++) {
70
+ if (!sockets[slot].open) continue
71
+ if (activeOn(jobs, slot, now).length >= JOB_QUEUE_CAP) continue
72
+ const startsAt = socketTail(jobs, slot, now)
73
+ const completesAt = new Date(startsAt.getTime() + durationBySocketMinutes[slot] * 60_000)
74
+ if (!best || completesAt < best.completesAt) {
75
+ best = {slot, startsAt, completesAt}
76
+ }
77
+ }
78
+ return best
79
+ }
80
+
81
+ export type JobStatus = 'inline' | 'waiting' | 'crafting' | 'ready'
82
+
83
+ // A job In Line has no window yet, so the window alone would read it as long since ready.
84
+ export function jobStatus(
85
+ job: {startsAt: Date; completesAt: Date; deposited?: boolean},
86
+ now: Date
87
+ ): JobStatus {
88
+ if (job.deposited === false) return 'inline'
89
+ if (now < job.startsAt) return 'waiting'
90
+ if (now < job.completesAt) return 'crafting'
91
+ return 'ready'
92
+ }
93
+
94
+ const JOB_STATUS_LABELS: Record<JobStatus, string> = {
95
+ inline: 'In Line',
96
+ waiting: 'Waiting',
97
+ crafting: 'Crafting',
98
+ ready: 'Ready for Pickup',
99
+ }
100
+
101
+ export function jobStatusLabel(status: JobStatus): string {
102
+ return JOB_STATUS_LABELS[status]
103
+ }
104
+
105
+ // A row written before the deposited flag existed reads as undefined; those jobs were all deposited.
106
+ export function jobDeposited(value: unknown): boolean {
107
+ if (value === undefined || value === null) return true
108
+ return Boolean(value)
109
+ }
110
+
111
+ // A job row holds the inputs while In Line and the output once its drop-off has landed.
112
+ export function splitJobCargo<T>(
113
+ cargo: readonly T[],
114
+ deposited: boolean
115
+ ): {output: T | null; inputs: T[]} {
116
+ if (cargo.length === 0) return {output: null, inputs: []}
117
+ if (!deposited) return {output: null, inputs: [...cargo]}
118
+ return {output: cargo[0], inputs: []}
119
+ }
120
+
121
+ export interface OwnedJob {
122
+ id: number
123
+ building: number
124
+ socket: number
125
+ shipId: number
126
+ coords: {x: number; y: number}
127
+ startsAt: Date
128
+ completesAt: Date
129
+ recipeId: number
130
+ quantity: number
131
+ status: JobStatus
132
+ deposited: boolean
133
+ output: CargoItem | null
134
+ inputs: CargoItem[]
135
+ /** Packed stat roll of `output`, so every job shape answers this the same way. */
136
+ outputStats?: bigint
137
+ }