@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.
- package/lib/shipload.d.ts +266 -101
- package/lib/shipload.js +898 -404
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +880 -398
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.d.ts +51 -13
- package/lib/testing.js +209 -64
- package/lib/testing.js.map +1 -1
- package/lib/testing.m.js +209 -64
- package/lib/testing.m.js.map +1 -1
- package/package.json +1 -1
- package/src/contracts/server.ts +140 -34
- package/src/data/capability-formulas.ts +4 -4
- package/src/data/entities.json +84 -68
- package/src/data/item-ids.ts +5 -4
- package/src/data/items.json +30 -24
- package/src/data/kind-registry.json +75 -39
- package/src/data/kind-registry.ts +13 -0
- package/src/data/metadata.ts +35 -0
- package/src/data/recipes.json +194 -155
- package/src/derivation/build-methods.test.ts +4 -4
- package/src/derivation/capabilities.test.ts +14 -10
- package/src/derivation/capabilities.ts +8 -10
- package/src/derivation/capability-mappings.ts +4 -4
- package/src/derivation/capacity-fn-registry.test.ts +9 -0
- package/src/derivation/capacity-fn-registry.ts +18 -0
- package/src/derivation/recipe-usage.test.ts +7 -11
- package/src/index-module.ts +28 -8
- package/src/influence/influence.test.ts +1 -1
- package/src/managers/actions.ts +19 -4
- package/src/managers/context.ts +9 -0
- package/src/managers/fund.ts +49 -0
- package/src/managers/index.ts +3 -0
- package/src/managers/jobs.test.ts +25 -9
- package/src/managers/jobs.ts +8 -4
- package/src/nft/description.ts +5 -10
- package/src/receipts.ts +9 -9
- package/src/resolution/resolve-item.test.ts +15 -10
- package/src/scheduling/jobs.test.ts +417 -18
- package/src/scheduling/jobs.ts +222 -16
- package/src/scheduling/lanes.ts +53 -1
- package/src/shipload.ts +5 -0
package/src/scheduling/jobs.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
86
|
-
now: Date
|
|
185
|
+
job: JobStatusInput,
|
|
186
|
+
now: Date,
|
|
187
|
+
tasks?: readonly OrderedTask[]
|
|
87
188
|
): JobStatus {
|
|
88
|
-
if (job.deposited === false)
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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[
|
|
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
|
package/src/scheduling/lanes.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
computeBuilderSpeed,
|
|
14
14
|
computeBuilderDrain,
|
|
15
15
|
} from '../nft/description'
|
|
16
|
-
import type
|
|
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
|
}
|