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

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 +266 -101
  2. package/lib/shipload.js +898 -404
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +880 -398
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +51 -13
  7. package/lib/testing.js +209 -64
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +209 -64
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/contracts/server.ts +140 -34
  13. package/src/data/capability-formulas.ts +4 -4
  14. package/src/data/entities.json +84 -68
  15. package/src/data/item-ids.ts +5 -4
  16. package/src/data/items.json +30 -24
  17. package/src/data/kind-registry.json +75 -39
  18. package/src/data/kind-registry.ts +13 -0
  19. package/src/data/metadata.ts +35 -0
  20. package/src/data/recipes.json +194 -155
  21. package/src/derivation/build-methods.test.ts +4 -4
  22. package/src/derivation/capabilities.test.ts +14 -10
  23. package/src/derivation/capabilities.ts +8 -10
  24. package/src/derivation/capability-mappings.ts +4 -4
  25. package/src/derivation/capacity-fn-registry.test.ts +9 -0
  26. package/src/derivation/capacity-fn-registry.ts +18 -0
  27. package/src/derivation/recipe-usage.test.ts +7 -11
  28. package/src/index-module.ts +28 -8
  29. package/src/influence/influence.test.ts +1 -1
  30. package/src/managers/actions.ts +19 -4
  31. package/src/managers/context.ts +9 -0
  32. package/src/managers/fund.ts +49 -0
  33. package/src/managers/index.ts +3 -0
  34. package/src/managers/jobs.test.ts +25 -9
  35. package/src/managers/jobs.ts +8 -4
  36. package/src/nft/description.ts +5 -10
  37. package/src/receipts.ts +9 -9
  38. package/src/resolution/resolve-item.test.ts +15 -10
  39. package/src/scheduling/jobs.test.ts +417 -18
  40. package/src/scheduling/jobs.ts +222 -16
  41. package/src/scheduling/lanes.ts +53 -1
  42. package/src/shipload.ts +5 -0
@@ -1,4 +1,6 @@
1
1
  import type {ServerContract} from '../contracts'
2
+ import {TaskType} from '../types'
3
+ import type {OrderedTask} from './schedule'
2
4
 
3
5
  type CargoItem = ServerContract.Types.cargo_item
4
6
 
@@ -8,12 +10,18 @@ export interface JobWindow {
8
10
  owner: string
9
11
  startsAt: Date
10
12
  completesAt: Date
13
+ /** Contract-recorded completion of the input Drop-off. */
14
+ arrivesAt?: Date
11
15
  recipeId: number
12
16
  quantity: number
13
- /** False while the job is In Line: booked, with its inputs still in transit to the building. */
17
+ /** False while the job's inputs are still in transit to the building. */
14
18
  deposited: boolean
15
19
  /** Packed stat roll of the job's output, when the source carried the job's cargo. */
16
20
  outputStats?: bigint
21
+ /** Ship that booked the job; lets a caller find its Drop-off on that ship's schedule. */
22
+ shipId?: number
23
+ /** Inputs the Drop-off carries; matched against the task cargo to find the right Drop-off. */
24
+ inputs?: CargoItem[]
17
25
  }
18
26
 
19
27
  export interface JobLaneEntry {
@@ -59,18 +67,26 @@ export function socketTail(jobs: JobWindow[], socket: number, now: Date): Date {
59
67
  return last && last.completesAt > now ? last.completesAt : now
60
68
  }
61
69
 
70
+ // Display-only estimate of the window a booking would receive; the contract picks the socket.
62
71
  export function pickFabricator(
63
72
  jobs: JobWindow[],
64
73
  sockets: Array<{open: boolean}>,
65
74
  durationBySocketMinutes: number[],
66
- now: Date
75
+ arrival: Date,
76
+ now: Date = arrival
67
77
  ): {slot: number; startsAt: Date; completesAt: Date} | null {
68
78
  let best: {slot: number; startsAt: Date; completesAt: Date} | null = null
69
79
  for (let slot = 0; slot < sockets.length; slot++) {
70
80
  if (!sockets[slot].open) continue
71
81
  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)
82
+ const durationMs = durationBySocketMinutes[slot] * 60_000
83
+ let startsAt = arrival
84
+ for (const job of activeOn(jobs, slot, now)) {
85
+ if (job.completesAt <= startsAt) continue
86
+ if (job.startsAt.getTime() - startsAt.getTime() >= durationMs) break
87
+ startsAt = job.completesAt
88
+ }
89
+ const completesAt = new Date(startsAt.getTime() + durationMs)
74
90
  if (!best || completesAt < best.completesAt) {
75
91
  best = {slot, startsAt, completesAt}
76
92
  }
@@ -78,24 +94,212 @@ export function pickFabricator(
78
94
  return best
79
95
  }
80
96
 
81
- export type JobStatus = 'inline' | 'waiting' | 'crafting' | 'ready'
97
+ export function workshopAvailability(
98
+ jobs: JobWindow[],
99
+ socketCount: number,
100
+ now: Date
101
+ ): {open: number; nextOpeningAt: Date | null} {
102
+ let open = 0
103
+ let nextOpeningAt: Date | null = null
104
+ for (let socket = 0; socket < socketCount; socket++) {
105
+ const tail = socketTail(jobs, socket, now)
106
+ if (tail <= now) {
107
+ open++
108
+ } else if (!nextOpeningAt || tail < nextOpeningAt) {
109
+ nextOpeningAt = tail
110
+ }
111
+ }
112
+ return {open, nextOpeningAt: open > 0 ? null : nextOpeningAt}
113
+ }
114
+
115
+ export type JobStatus = 'booked' | 'dropping' | 'queued' | 'crafting' | 'ready' | 'pickingup'
116
+
117
+ export interface JobStatusInput {
118
+ startsAt: Date
119
+ completesAt: Date
120
+ /** Contract-recorded completion of the input Drop-off. */
121
+ arrivesAt?: Date
122
+ deposited?: boolean
123
+ quantity?: number
124
+ building?: number
125
+ inputs?: readonly CargoItem[]
126
+ }
82
127
 
83
- // A job In Line has no window yet, so the window alone would read it as long since ready.
128
+ function modulesEqual(a: CargoItem['modules'], b: CargoItem['modules']): boolean {
129
+ const length = Math.max(a?.length ?? 0, b?.length ?? 0)
130
+ for (let i = 0; i < length; i++) {
131
+ const installed = a?.[i]?.installed
132
+ const other = b?.[i]?.installed
133
+ if (Boolean(installed) !== Boolean(other)) return false
134
+ if (
135
+ installed &&
136
+ other &&
137
+ (String(installed.item_id) !== String(other.item_id) ||
138
+ String(installed.stats) !== String(other.stats))
139
+ ) {
140
+ return false
141
+ }
142
+ }
143
+ return true
144
+ }
145
+
146
+ function cargoEquals(a: readonly CargoItem[], b: readonly CargoItem[]): boolean {
147
+ if (a.length !== b.length) return false
148
+ return a.every((x, i) => {
149
+ const y = b[i]
150
+ if (
151
+ String(x.item_id) !== String(y.item_id) ||
152
+ String(x.stats) !== String(y.stats) ||
153
+ String(x.quantity) !== String(y.quantity) ||
154
+ String(x.entity_id ?? 0) !== String(y.entity_id ?? 0) ||
155
+ !modulesEqual(x.modules, y.modules)
156
+ ) {
157
+ return false
158
+ }
159
+ return true
160
+ })
161
+ }
162
+
163
+ function matchesJob(t: OrderedTask, job: JobStatusInput): boolean {
164
+ const subject = t.task.subject
165
+ if (job.building !== undefined && Number(subject?.entity_id) !== job.building) return false
166
+ if (job.inputs !== undefined && !cargoEquals(t.task.cargo, job.inputs)) return false
167
+ return true
168
+ }
169
+
170
+ export function jobDropoffTask(
171
+ job: JobStatusInput,
172
+ tasks: readonly OrderedTask[] | undefined
173
+ ): OrderedTask | undefined {
174
+ const matches = tasks?.filter(
175
+ (t) =>
176
+ Number(t.task.type) === TaskType.CIVIC_DEPOSIT &&
177
+ matchesJob(t, job) &&
178
+ (job.arrivesAt === undefined || t.completesAt.getTime() === job.arrivesAt.getTime())
179
+ )
180
+ return matches?.length === 1 ? matches[0] : undefined
181
+ }
182
+
183
+ // Without the ship's schedule an undeposited row reads as Dropping off: the transfer usually starts at once.
84
184
  export function jobStatus(
85
- job: {startsAt: Date; completesAt: Date; deposited?: boolean},
86
- now: Date
185
+ job: JobStatusInput,
186
+ now: Date,
187
+ tasks?: readonly OrderedTask[]
87
188
  ): JobStatus {
88
- if (job.deposited === false) return 'inline'
89
- if (now < job.startsAt) return 'waiting'
189
+ if (job.deposited === false) {
190
+ const dropoff = jobDropoffTask(job, tasks)
191
+ return dropoff && now < dropoff.startsAt ? 'booked' : 'dropping'
192
+ }
193
+ if (job.quantity === 0) return 'ready'
194
+ if (now < job.startsAt) return 'queued'
90
195
  if (now < job.completesAt) return 'crafting'
91
196
  return 'ready'
92
197
  }
93
198
 
199
+ export function jobCancellable(status: JobStatus): boolean {
200
+ return status === 'booked' || status === 'dropping' || status === 'queued'
201
+ }
202
+
203
+ export type JobCancellationBlockReason = 'legacy-inputs-unavailable' | 'ambiguous-dropoff'
204
+
205
+ /** A migrated output-only job has no retained inputs that modern cancellation could return. */
206
+ export function jobCancellationBlockReason(
207
+ job: JobStatusInput,
208
+ tasks?: readonly OrderedTask[]
209
+ ): JobCancellationBlockReason | null {
210
+ if (
211
+ job.deposited === true &&
212
+ (job.quantity ?? 0) > 0 &&
213
+ job.inputs !== undefined &&
214
+ job.inputs.length === 0
215
+ ) {
216
+ return 'legacy-inputs-unavailable'
217
+ }
218
+ if (job.deposited === false && job.arrivesAt !== undefined && tasks) {
219
+ const matches = tasks.filter(
220
+ (task) =>
221
+ Number(task.task.type) === TaskType.CIVIC_DEPOSIT &&
222
+ matchesJob(task, job) &&
223
+ task.completesAt.getTime() === job.arrivesAt?.getTime()
224
+ )
225
+ if (matches.length > 1) return 'ambiguous-dropoff'
226
+ }
227
+ return null
228
+ }
229
+
230
+ export type JobCancelRoute =
231
+ | {kind: 'dropoff'; shipId: number; laneKey: number; count: number}
232
+ | {kind: 'craft'; jobId: number}
233
+ | {kind: 'build'; jobId: number}
234
+
235
+ // The ship's cancel pops from the lane tail, so the count reaches from the Drop-off through every task queued behind it.
236
+ function cancelRoute(
237
+ job: JobStatusInput & {id: number; shipId?: number},
238
+ queued: 'craft' | 'build',
239
+ now: Date,
240
+ tasks?: readonly OrderedTask[]
241
+ ): JobCancelRoute | null {
242
+ if (!jobCancellable(jobStatus(job, now, tasks))) return null
243
+ if (queued === 'craft' && jobCancellationBlockReason(job, tasks)) return null
244
+ if (job.deposited !== false) return {kind: queued, jobId: job.id}
245
+ if (job.shipId === undefined || !tasks) return null
246
+ const dropoff = jobDropoffTask(job, tasks)
247
+ if (!dropoff || dropoff.completesAt <= now) return null
248
+ const laneLength = tasks.filter((t) => t.laneKey === dropoff.laneKey).length
249
+ return {
250
+ kind: 'dropoff',
251
+ shipId: job.shipId,
252
+ laneKey: dropoff.laneKey,
253
+ count: laneLength - dropoff.taskIndex,
254
+ }
255
+ }
256
+
257
+ export function jobCancelRoute(
258
+ job: JobStatusInput & {id: number; shipId?: number},
259
+ now: Date,
260
+ tasks?: readonly OrderedTask[]
261
+ ): JobCancelRoute | null {
262
+ return cancelRoute(job, 'craft', now, tasks)
263
+ }
264
+
265
+ // A Build Job's Drop-off rides on the upgrade target itself, so the target plays the ship's part.
266
+ export function buildJobCancelRoute(
267
+ job: JobStatusInput & {id: number; targetId?: number},
268
+ now: Date,
269
+ tasks?: readonly OrderedTask[]
270
+ ): JobCancelRoute | null {
271
+ return cancelRoute({...job, shipId: job.targetId}, 'build', now, tasks)
272
+ }
273
+
274
+ export interface PickupInFlight {
275
+ building: number
276
+ cargo: CargoItem[]
277
+ startsAt: Date
278
+ completesAt: Date
279
+ }
280
+
281
+ export function pickupsInFlight(tasks: OrderedTask[], building?: number): PickupInFlight[] {
282
+ return tasks
283
+ .filter(
284
+ (t) =>
285
+ Number(t.task.type) === TaskType.CIVIC_WITHDRAW &&
286
+ (building === undefined || Number(t.task.subject?.entity_id) === building)
287
+ )
288
+ .map((t) => ({
289
+ building: Number(t.task.subject?.entity_id),
290
+ cargo: [...t.task.cargo],
291
+ startsAt: t.startsAt,
292
+ completesAt: t.completesAt,
293
+ }))
294
+ }
295
+
94
296
  const JOB_STATUS_LABELS: Record<JobStatus, string> = {
95
- inline: 'In Line',
96
- waiting: 'Waiting',
297
+ booked: 'Booked',
298
+ dropping: 'Dropping off',
299
+ queued: 'Queued',
97
300
  crafting: 'Crafting',
98
301
  ready: 'Ready for Pickup',
302
+ pickingup: 'Picking up',
99
303
  }
100
304
 
101
305
  export function jobStatusLabel(status: JobStatus): string {
@@ -108,14 +312,15 @@ export function jobDeposited(value: unknown): boolean {
108
312
  return Boolean(value)
109
313
  }
110
314
 
111
- // A job row holds the inputs while In Line and the output once its drop-off has landed.
315
+ // A deposited row's output is the cargo tail; a cancelled row (quantity 0) holds only its inputs.
112
316
  export function splitJobCargo<T>(
113
317
  cargo: readonly T[],
114
- deposited: boolean
318
+ deposited: boolean,
319
+ quantity: number
115
320
  ): {output: T | null; inputs: T[]} {
116
321
  if (cargo.length === 0) return {output: null, inputs: []}
117
- if (!deposited) return {output: null, inputs: [...cargo]}
118
- return {output: cargo[0], inputs: []}
322
+ if (!deposited || quantity === 0) return {output: null, inputs: [...cargo]}
323
+ return {output: cargo[cargo.length - 1], inputs: cargo.slice(0, -1)}
119
324
  }
120
325
 
121
326
  export interface OwnedJob {
@@ -126,6 +331,7 @@ export interface OwnedJob {
126
331
  coords: {x: number; y: number}
127
332
  startsAt: Date
128
333
  completesAt: Date
334
+ arrivesAt: Date
129
335
  recipeId: number
130
336
  quantity: number
131
337
  status: JobStatus
@@ -13,7 +13,7 @@ import {
13
13
  computeBuilderSpeed,
14
14
  computeBuilderDrain,
15
15
  } from '../nft/description'
16
- import type {ModuleType} from '../types'
16
+ import {TaskType, type ModuleType} from '../types'
17
17
  import {getLane, LANE_MOBILITY, type ScheduleData} from './schedule'
18
18
 
19
19
  type ModuleEntry = ServerContract.Types.module_entry
@@ -225,3 +225,55 @@ export function candidateLaneCompletesAt(
225
225
 
226
226
  return new Date(startMs + durationSec * 1000)
227
227
  }
228
+
229
+ /** Mirrors append.cpp for the position-bound civic Drop-off queued by craftjob. */
230
+ export function candidateCivicDepositWindow(
231
+ entity: ScheduleData & {modules: ModuleEntry[]},
232
+ durationSec: number,
233
+ now: Date,
234
+ minStartsAt: Date = now
235
+ ): {laneKey: number; startsAt: Date; completesAt: Date} {
236
+ const lanes = entity.lanes ?? []
237
+ let lowestLoader: number | undefined
238
+ let laneKey: number | undefined
239
+ for (let slotIndex = 0; slotIndex < entity.modules.length; slotIndex++) {
240
+ const installed = entity.modules[slotIndex].installed
241
+ if (!installed || getItem(installed.item_id).moduleType !== 'loader') continue
242
+ const key = laneKeyForModule(slotIndex)
243
+ if (lowestLoader === undefined) lowestLoader = key
244
+ if (laneIsFree(lanes, key)) {
245
+ laneKey = key
246
+ break
247
+ }
248
+ }
249
+ laneKey ??= lowestLoader ?? LANE_MOBILITY
250
+ const floorNowMs = Math.floor(now.getTime() / 1000) * 1000
251
+ const lane = getLane(entity, laneKey)
252
+ const laneEndMs = lane ? rawScheduleEnd(lane.schedule).getTime() : floorNowMs
253
+ let barrierMs = minStartsAt.getTime()
254
+
255
+ for (const entry of lanes) {
256
+ let completesMs = entry.schedule.started.toDate().getTime()
257
+ for (const task of entry.schedule.tasks) {
258
+ completesMs += task.duration.toNumber() * 1000
259
+ const type = task.type.toNumber()
260
+ if (
261
+ type === TaskType.TRAVEL ||
262
+ type === TaskType.WARP ||
263
+ type === TaskType.TRANSIT ||
264
+ type === TaskType.RECHARGE
265
+ ) {
266
+ barrierMs = Math.max(barrierMs, completesMs)
267
+ }
268
+ }
269
+ }
270
+ let startMs = Math.max(laneEndMs, barrierMs)
271
+ if (startMs > laneEndMs) {
272
+ startMs = laneEndMs + Math.ceil((startMs - laneEndMs) / 1000) * 1000
273
+ }
274
+ return {
275
+ laneKey,
276
+ startsAt: new Date(startMs),
277
+ completesAt: new Date(startMs + durationSec * 1000),
278
+ }
279
+ }
package/src/shipload.ts CHANGED
@@ -13,6 +13,7 @@ import type {ActionsManager} from './managers/actions'
13
13
  import type {ClusterManager} from './managers/cluster'
14
14
  import type {NftManager} from './managers/nft'
15
15
  import type {BalancesManager} from './managers/balances'
16
+ import type {FundManager} from './managers/fund'
16
17
  import type {JobsManager} from './managers/jobs'
17
18
  import type {InfluenceManager} from './managers/influence'
18
19
  import type {SubscriptionsManager} from './subscriptions/manager'
@@ -154,6 +155,10 @@ export class Shipload {
154
155
  return this._context.nft
155
156
  }
156
157
 
158
+ get funds(): FundManager {
159
+ return this._context.funds
160
+ }
161
+
157
162
  get balances(): BalancesManager {
158
163
  return this._context.balances
159
164
  }