@shipload/sdk 1.0.0-next.61 → 1.0.0-next.63

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 (55) hide show
  1. package/lib/shipload.d.ts +1243 -59
  2. package/lib/shipload.js +3911 -1261
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +3812 -1265
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +391 -13
  7. package/lib/testing.js +1554 -114
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +1555 -115
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +2 -2
  12. package/src/contracts/fund.ts +201 -0
  13. package/src/contracts/platform.ts +73 -1
  14. package/src/contracts/server.ts +946 -26
  15. package/src/contracts.ts +1 -0
  16. package/src/data/charters.json +198 -0
  17. package/src/data/entities.json +36 -0
  18. package/src/data/item-ids.ts +2 -0
  19. package/src/data/items.json +12 -0
  20. package/src/data/kind-registry.json +20 -3
  21. package/src/data/kind-registry.ts +5 -0
  22. package/src/data/metadata.ts +14 -0
  23. package/src/data/recipes.json +0 -48
  24. package/src/derivation/capabilities.ts +14 -0
  25. package/src/derivation/recipe-usage.test.ts +0 -2
  26. package/src/format.ts +9 -0
  27. package/src/index-module.ts +34 -3
  28. package/src/influence/categories.ts +32 -0
  29. package/src/influence/charters.test.ts +126 -0
  30. package/src/influence/charters.ts +120 -0
  31. package/src/influence/citizenry.ts +31 -0
  32. package/src/influence/constants.ts +63 -0
  33. package/src/influence/decay.ts +20 -0
  34. package/src/influence/decomp.ts +95 -0
  35. package/src/influence/demand.ts +106 -0
  36. package/src/influence/duration.ts +36 -0
  37. package/src/influence/index.ts +63 -0
  38. package/src/influence/influence.test.ts +291 -0
  39. package/src/influence/quality.ts +25 -0
  40. package/src/influence/valuation.ts +154 -0
  41. package/src/managers/actions.ts +135 -0
  42. package/src/managers/construction.ts +11 -1
  43. package/src/managers/context.ts +18 -0
  44. package/src/managers/entities.ts +11 -0
  45. package/src/managers/index.ts +13 -0
  46. package/src/managers/influence.test.ts +58 -0
  47. package/src/managers/influence.ts +347 -0
  48. package/src/managers/jobs.test.ts +62 -0
  49. package/src/managers/jobs.ts +52 -0
  50. package/src/nft/description.ts +7 -0
  51. package/src/scheduling/jobs.test.ts +32 -0
  52. package/src/scheduling/jobs.ts +37 -0
  53. package/src/scheduling/task-cargo.ts +1 -0
  54. package/src/shipload.ts +10 -0
  55. package/src/types.ts +23 -4
@@ -55,6 +55,17 @@ export class EntitiesManager extends BaseManager {
55
55
  return result as ServerContract.Types.entity_summary[]
56
56
  }
57
57
 
58
+ async getDepot(
59
+ depotId: UInt64Type,
60
+ owner: NameType | ServerContract.Types.player_row
61
+ ): Promise<ServerContract.Types.depot_view> {
62
+ const result = await this.server.readonly('getdepot', {
63
+ depot_id: depotId,
64
+ owner: this.resolveOwner(owner),
65
+ })
66
+ return result as ServerContract.Types.depot_view
67
+ }
68
+
58
69
  private resolveOwner(owner: NameType | ServerContract.Types.player_row): Name {
59
70
  if (typeof owner === 'object' && owner !== null && 'owner' in owner) {
60
71
  return owner.owner
@@ -12,6 +12,19 @@ export {ClusterManager, computeFreeCells} from './cluster'
12
12
  export type {GridCell, ClusterCell, Cluster} from './cluster'
13
13
  export {NftManager} from './nft'
14
14
  export type {NftConfigForItem} from './nft'
15
+ export {JobsManager} from './jobs'
16
+ export {InfluenceManager} from './influence'
17
+ export type {
18
+ CharterProgress,
19
+ ContributePreview,
20
+ ContributePreviewRow,
21
+ FoundedWorld,
22
+ InfluenceStanding,
23
+ PendingBallot,
24
+ VoteCast,
25
+ VoteOption,
26
+ VoteStandings,
27
+ } from './influence'
15
28
  export {PlotManager} from './plot'
16
29
  export type {PlotProgress, PlotProgressInputRow} from './plot'
17
30
  export {ConstructionManager} from './construction'
@@ -0,0 +1,58 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {Name, UInt64} from '@wharfkit/antelope'
3
+ import {InfluenceManager} from './influence'
4
+ import {coordsToLocationId} from '../types'
5
+
6
+ const LOCATION = {x: 12, y: 34}
7
+
8
+ function managerWith(rowsByTable: Record<string, unknown[]>) {
9
+ const calls: {table: string; scope: unknown}[] = []
10
+ const table = (name: string, scope: unknown) => {
11
+ calls.push({table: name, scope})
12
+ return {all: async () => rowsByTable[name] ?? []}
13
+ }
14
+ const ctx = {server: {table}} as never
15
+ return {manager: new InfluenceManager(ctx), calls}
16
+ }
17
+
18
+ describe('InfluenceManager scoped table reads', () => {
19
+ it('getVoteCasts scopes infvote by location and maps fields', async () => {
20
+ const {manager, calls} = managerWith({
21
+ infvote: [{account: Name.from('eggmaple.gm'), epoch: 5, node_id: 2, weight: '100'}],
22
+ })
23
+ const rows = await manager.getVoteCasts(LOCATION)
24
+ expect(calls).toHaveLength(1)
25
+ expect(calls[0].table).toBe('infvote')
26
+ expect(UInt64.from(calls[0].scope as never).equals(coordsToLocationId(LOCATION))).toBe(true)
27
+ expect(rows).toEqual([
28
+ {account: Name.from('eggmaple.gm'), epoch: 5, nodeId: 2, weight: 100n},
29
+ ])
30
+ })
31
+
32
+ it('getVoteTallies scopes inftally by location and maps fields', async () => {
33
+ const {manager, calls} = managerWith({
34
+ inftally: [{node_id: 3, total: '250'}],
35
+ })
36
+ const rows = await manager.getVoteTallies(LOCATION)
37
+ expect(calls[0].table).toBe('inftally')
38
+ expect(UInt64.from(calls[0].scope as never).equals(coordsToLocationId(LOCATION))).toBe(true)
39
+ expect(rows).toEqual([{nodeId: 3, total: 250n}])
40
+ })
41
+
42
+ it('getBuiltCharters scopes charters by location and maps fields', async () => {
43
+ const {manager, calls} = managerWith({
44
+ charters: [{node_id: 1, entity_id: '9001'}],
45
+ })
46
+ const rows = await manager.getBuiltCharters(LOCATION)
47
+ expect(calls[0].table).toBe('charters')
48
+ expect(UInt64.from(calls[0].scope as never).equals(coordsToLocationId(LOCATION))).toBe(true)
49
+ expect(rows).toEqual([{nodeId: 1, entityId: 9001n}])
50
+ })
51
+
52
+ it('returns an empty array when the scope has no rows', async () => {
53
+ const {manager} = managerWith({})
54
+ expect(await manager.getVoteCasts(LOCATION)).toEqual([])
55
+ expect(await manager.getVoteTallies(LOCATION)).toEqual([])
56
+ expect(await manager.getBuiltCharters(LOCATION)).toEqual([])
57
+ })
58
+ })
@@ -0,0 +1,347 @@
1
+ import {Int64, Name, type NameType, UInt64} from '@wharfkit/antelope'
2
+ import {BaseManager} from './base'
3
+ import type {ServerContract} from '../contracts'
4
+ import {coordsToLocationId, locationIdToCoords, type CoordinatesType} from '../types'
5
+ import {
6
+ type BuiltCharter,
7
+ citizenryName,
8
+ contributeDuration,
9
+ decayActive,
10
+ deriveDemand,
11
+ pricingFromWeights,
12
+ valueCargoItem,
13
+ type DemandTriple,
14
+ type DemandView,
15
+ type ValuedItem,
16
+ } from '../influence'
17
+ import {getItem} from '../data/catalog'
18
+
19
+ export interface InfluenceStanding {
20
+ epoch: number
21
+ playerLifetime: bigint
22
+ playerActive: bigint
23
+ standingLifetime: bigint
24
+ standingActive: bigint
25
+ locationLifetime: bigint
26
+ locationActive: bigint
27
+ watermark: bigint
28
+ mandate: number
29
+ founder: Name
30
+ founded: number
31
+ }
32
+
33
+ export interface CharterProgress {
34
+ locationId: bigint
35
+ lifetime: bigint
36
+ watermark: bigint
37
+ surplus: bigint
38
+ mandate: number
39
+ nextCost: bigint
40
+ prereqsMet: boolean
41
+ buildable: boolean
42
+ epoch: number
43
+ built: {nodeId: number; completedEpoch: number; entityId: bigint}[]
44
+ }
45
+
46
+ export interface ContributePreviewRow {
47
+ itemId: number
48
+ quantity: number
49
+ stats: bigint
50
+ valueAtomic: bigint
51
+ massKg: number
52
+ }
53
+
54
+ export interface ContributePreview {
55
+ rows: ContributePreviewRow[]
56
+ totalAtomic: bigint
57
+ totalMassKg: number
58
+ durationSeconds: number
59
+ demand: DemandView
60
+ }
61
+
62
+ export interface FoundedWorld {
63
+ locationId: bigint
64
+ x: number
65
+ y: number
66
+ lifetime: bigint
67
+ active: bigint
68
+ watermark: bigint
69
+ chosen: number
70
+ chosenEpoch: number
71
+ mandate: number | undefined
72
+ founder: Name
73
+ founded: number
74
+ }
75
+
76
+ export interface VoteOption {
77
+ nodeId: number
78
+ cost: bigint
79
+ eligible: boolean
80
+ total: bigint | undefined
81
+ }
82
+
83
+ export interface VoteStandings {
84
+ locationId: bigint
85
+ epoch: number
86
+ chosen: number
87
+ chosenEpoch: number
88
+ mandate: number
89
+ ballotEpoch: number | undefined
90
+ talliesSuppressed: boolean
91
+ options: VoteOption[]
92
+ }
93
+
94
+ export interface VoteCast {
95
+ account: Name
96
+ epoch: number
97
+ nodeId: number
98
+ weight: bigint
99
+ }
100
+
101
+ export interface PendingBallot {
102
+ locationId: bigint
103
+ x: number
104
+ y: number
105
+ epoch: number
106
+ }
107
+
108
+ function big(value: unknown): bigint {
109
+ return BigInt(String(value))
110
+ }
111
+
112
+ export class InfluenceManager extends BaseManager {
113
+ async getStanding(owner: NameType, location: CoordinatesType): Promise<InfluenceStanding> {
114
+ const result = (await this.server.readonly('getinfluence', {
115
+ owner: Name.from(owner),
116
+ x: Int64.from(location.x),
117
+ y: Int64.from(location.y),
118
+ })) as ServerContract.Types.influence_totals
119
+
120
+ return {
121
+ epoch: Number(result.epoch),
122
+ playerLifetime: big(result.player_lifetime),
123
+ playerActive: big(result.player_active),
124
+ standingLifetime: big(result.standing_lifetime),
125
+ standingActive: big(result.standing_active),
126
+ locationLifetime: big(result.location_lifetime),
127
+ locationActive: big(result.location_active),
128
+ watermark: big(result.watermark),
129
+ mandate: Number(result.mandate),
130
+ founder: Name.from(result.founder),
131
+ founded: Number(result.founded),
132
+ }
133
+ }
134
+
135
+ async getDemandConfig(): Promise<DemandTriple> {
136
+ const row = (await this.server.table('infdemand').get()) as
137
+ | ServerContract.Types.infdemand_row
138
+ | undefined
139
+ if (!row) throw new Error('influence demand config is unset')
140
+ return {peak: big(row.peak), base: big(row.base), floor: big(row.floor)}
141
+ }
142
+
143
+ async getQualityDivisor(): Promise<number> {
144
+ const row = (await this.server.table('infquality').get()) as
145
+ | ServerContract.Types.infquality_row
146
+ | undefined
147
+ if (!row) throw new Error('influence quality config is unset')
148
+ return Number(row.d1)
149
+ }
150
+
151
+ async getWeights(): Promise<ServerContract.Types.infweight_row[]> {
152
+ return (await this.server.table('infweight').all()) as ServerContract.Types.infweight_row[]
153
+ }
154
+
155
+ async getDemand(location: CoordinatesType): Promise<DemandView> {
156
+ const game = await this.getGame()
157
+ const state = await this.getState()
158
+ const triple = await this.getDemandConfig()
159
+ return deriveDemand({
160
+ gameSeed: game.config.seed,
161
+ epochSeed: state.epochSeed,
162
+ coordinates: location,
163
+ triple,
164
+ })
165
+ }
166
+
167
+ async getCitizenryName(location: CoordinatesType): Promise<string | undefined> {
168
+ const game = await this.getGame()
169
+ return citizenryName(game.config.seed, location)
170
+ }
171
+
172
+ async previewContribution(
173
+ location: CoordinatesType,
174
+ bundle: ValuedItem[],
175
+ opts: {altitudeZ?: number} = {}
176
+ ): Promise<ContributePreview> {
177
+ const [demand, d1, weights] = await Promise.all([
178
+ this.getDemand(location),
179
+ this.getQualityDivisor(),
180
+ this.getWeights(),
181
+ ])
182
+ const pricing = pricingFromWeights(
183
+ d1,
184
+ weights.map((w) => ({
185
+ category: Number(w.category),
186
+ tier: Number(w.tier),
187
+ weightFp: big(w.weight_fp),
188
+ }))
189
+ )
190
+
191
+ let totalAtomic = 0n
192
+ let totalMassKg = 0
193
+ const rows = bundle.map((item) => {
194
+ const valueAtomic = valueCargoItem(item, demand, pricing)
195
+ const massKg = getItem(item.itemId).mass * item.quantity
196
+ totalAtomic += valueAtomic
197
+ totalMassKg += massKg
198
+ return {...item, valueAtomic, massKg}
199
+ })
200
+
201
+ return {
202
+ rows,
203
+ totalAtomic,
204
+ totalMassKg,
205
+ durationSeconds: contributeDuration(totalMassKg, opts.altitudeZ ?? 0),
206
+ demand,
207
+ }
208
+ }
209
+
210
+ async getCharter(location: CoordinatesType): Promise<CharterProgress> {
211
+ const result = (await this.server.readonly('getcharter', {
212
+ x: Int64.from(location.x),
213
+ y: Int64.from(location.y),
214
+ })) as ServerContract.Types.charter_result
215
+
216
+ return {
217
+ locationId: big(result.location_id),
218
+ lifetime: big(result.lifetime),
219
+ watermark: big(result.watermark),
220
+ surplus: big(result.surplus),
221
+ mandate: Number(result.mandate),
222
+ nextCost: big(result.next_cost),
223
+ prereqsMet: Boolean(result.prereqs_met),
224
+ buildable: Boolean(result.buildable),
225
+ epoch: Number(result.epoch),
226
+ built: result.built.map((b) => ({
227
+ nodeId: Number(b.node_id),
228
+ completedEpoch: Number(b.completed_epoch),
229
+ entityId: big(b.entity_id),
230
+ })),
231
+ }
232
+ }
233
+
234
+ async getPools(): Promise<ServerContract.Types.pool_view[]> {
235
+ const result = (await this.server.readonly('getpools')) as ServerContract.Types.pools_result
236
+ return result.pools
237
+ }
238
+
239
+ async getPool(category: number, tier: number): Promise<ServerContract.Types.pool_pair_result> {
240
+ return (await this.server.readonly('getpool', {
241
+ category,
242
+ tier,
243
+ })) as ServerContract.Types.pool_pair_result
244
+ }
245
+
246
+ async getMintConfig(): Promise<ServerContract.Types.mintcfg_result> {
247
+ return (await this.server.readonly('getmintcfg')) as ServerContract.Types.mintcfg_result
248
+ }
249
+
250
+ async getVotes(location: CoordinatesType): Promise<VoteStandings> {
251
+ const result = (await this.server.readonly('getvotes', {
252
+ x: Int64.from(location.x),
253
+ y: Int64.from(location.y),
254
+ })) as ServerContract.Types.vote_result
255
+
256
+ const epoch = Number(result.epoch)
257
+ const rawBallotEpoch = Number(result.ballot_epoch)
258
+ const ballotEpoch = rawBallotEpoch === 0 ? undefined : rawBallotEpoch
259
+ const talliesSuppressed = ballotEpoch !== undefined && ballotEpoch < epoch
260
+
261
+ return {
262
+ locationId: big(result.location_id),
263
+ epoch,
264
+ chosen: Number(result.chosen),
265
+ chosenEpoch: Number(result.chosen_epoch),
266
+ mandate: Number(result.effective),
267
+ ballotEpoch,
268
+ talliesSuppressed,
269
+ options: result.options.map((option) => ({
270
+ nodeId: Number(option.node_id),
271
+ cost: big(option.cost),
272
+ eligible: Boolean(option.eligible),
273
+ total: talliesSuppressed ? undefined : big(option.total),
274
+ })),
275
+ }
276
+ }
277
+
278
+ async getVoteCasts(location: CoordinatesType): Promise<VoteCast[]> {
279
+ const rows = (await this.server
280
+ .table('infvote', coordsToLocationId(location))
281
+ .all()) as ServerContract.Types.infvote_row[]
282
+ return rows.map((row) => ({
283
+ account: Name.from(row.account),
284
+ epoch: Number(row.epoch),
285
+ nodeId: Number(row.node_id),
286
+ weight: big(row.weight),
287
+ }))
288
+ }
289
+
290
+ async getVoteTallies(location: CoordinatesType): Promise<{nodeId: number; total: bigint}[]> {
291
+ const rows = (await this.server
292
+ .table('inftally', coordsToLocationId(location))
293
+ .all()) as ServerContract.Types.inftally_row[]
294
+ return rows.map((row) => ({nodeId: Number(row.node_id), total: big(row.total)}))
295
+ }
296
+
297
+ async getBuiltCharters(location: CoordinatesType): Promise<BuiltCharter[]> {
298
+ const rows = (await this.server
299
+ .table('charters', coordsToLocationId(location))
300
+ .all()) as ServerContract.Types.charters_row[]
301
+ return rows.map((row) => ({nodeId: Number(row.node_id), entityId: big(row.entity_id)}))
302
+ }
303
+
304
+ async getBallotQueue(): Promise<PendingBallot[]> {
305
+ const rows = (await this.server
306
+ .table('infballot')
307
+ .all()) as ServerContract.Types.infballot_row[]
308
+ return rows.map((row) => ({
309
+ locationId: big(row.location_id),
310
+ ...locationIdToCoords(row.location_id),
311
+ epoch: Number(row.epoch),
312
+ }))
313
+ }
314
+
315
+ async getFoundedWorlds(opts: {withMandate?: boolean} = {}): Promise<FoundedWorld[]> {
316
+ const rows = (await this.server.table('infloc').all()) as ServerContract.Types.infloc_row[]
317
+ const epoch = (await this.getState()).epoch
318
+ const worlds = rows.map((row) => ({
319
+ locationId: big(row.location_id),
320
+ ...locationIdToCoords(row.location_id),
321
+ lifetime: big(row.lifetime),
322
+ active: decayActive(
323
+ big(row.active),
324
+ Math.max(0, Number(epoch) - Number(row.last_update_epoch))
325
+ ),
326
+ watermark: big(row.watermark),
327
+ chosen: Number(row.chosen),
328
+ chosenEpoch: Number(row.chosen_epoch),
329
+ mandate: undefined as number | undefined,
330
+ founder: Name.from(row.founder),
331
+ founded: Number(row.founded),
332
+ }))
333
+
334
+ if (!opts.withMandate) return worlds
335
+
336
+ for (const world of worlds) {
337
+ world.mandate = (await this.getCharter({x: world.x, y: world.y})).mandate
338
+ }
339
+ return worlds
340
+ }
341
+
342
+ async isFounded(location: CoordinatesType): Promise<boolean> {
343
+ const id = coordsToLocationId(location)
344
+ const row = await this.server.table('infloc').get(UInt64.from(id))
345
+ return row !== undefined
346
+ }
347
+ }
@@ -0,0 +1,62 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {Name} from '@wharfkit/antelope'
3
+ import {JobsManager} from './jobs'
4
+
5
+ const OWNER = 'eggmaple.gm'
6
+ const row = (over: Record<string, unknown> = {}) => ({
7
+ id: {toNumber: () => 7},
8
+ workshop: {toNumber: () => 42},
9
+ socket: {toNumber: () => 0},
10
+ owner: Name.from(OWNER),
11
+ ship_id: {toNumber: () => 100},
12
+ coords: {x: {toNumber: () => 12}, y: {toNumber: () => 34}},
13
+ starts_at: {toDate: () => new Date('2026-07-26T10:00:00Z')},
14
+ completes_at: {toDate: () => new Date('2026-07-26T11:00:00Z')},
15
+ recipe_id: {toNumber: () => 10001},
16
+ quantity: {toNumber: () => 5},
17
+ energy_paid: {toNumber: () => 0},
18
+ cargo: [{item: 'in'}, {item: 'out'}],
19
+ ...over,
20
+ })
21
+
22
+ function managerWith(queryImpl: () => Promise<unknown[]>, allImpl: () => Promise<unknown[]>) {
23
+ const table = () => ({query: () => ({all: queryImpl}), all: allImpl})
24
+ const ctx = {server: {table}} as never
25
+ return new JobsManager(ctx)
26
+ }
27
+
28
+ describe('JobsManager.getOwnedJobs', () => {
29
+ it('parses owner rows: output = last cargo, inputs = rest, status derived', async () => {
30
+ const m = managerWith(
31
+ async () => [row()],
32
+ async () => []
33
+ )
34
+ const jobs = await m.getOwnedJobs(OWNER, {now: new Date('2026-07-26T11:30:00Z')})
35
+ expect(jobs).toHaveLength(1)
36
+ expect(jobs[0]).toMatchObject({id: 7, workshop: 42, quantity: 5, status: 'ready'})
37
+ expect(jobs[0].coords).toEqual({x: 12, y: 34})
38
+ expect(jobs[0].output).toEqual({item: 'out'} as never)
39
+ expect(jobs[0].inputs).toEqual([{item: 'in'}] as never)
40
+ })
41
+
42
+ it('drops rows whose owner does not match (positional-index safety re-filter)', async () => {
43
+ const other = row({owner: Name.from('someoneelse.gm')})
44
+ const m = managerWith(
45
+ async () => [row(), other],
46
+ async () => []
47
+ )
48
+ const jobs = await m.getOwnedJobs(OWNER)
49
+ expect(jobs).toHaveLength(1)
50
+ })
51
+
52
+ it('falls back to a full-scan .all() when the positional query throws', async () => {
53
+ const m = managerWith(
54
+ async () => {
55
+ throw new Error('bad index_position')
56
+ },
57
+ async () => [row()]
58
+ )
59
+ const jobs = await m.getOwnedJobs(OWNER)
60
+ expect(jobs).toHaveLength(1)
61
+ })
62
+ })
@@ -0,0 +1,52 @@
1
+ import {Name, type NameType} from '@wharfkit/antelope'
2
+ import {BaseManager} from './base'
3
+ import {jobStatus, splitJobCargo, type OwnedJob} from '../scheduling/jobs'
4
+ import type {ServerContract} from '../contracts'
5
+
6
+ type JobRow = ServerContract.Types.job_row
7
+
8
+ export class JobsManager extends BaseManager {
9
+ async getOwnedJobs(owner: NameType, opts?: {now?: Date}): Promise<OwnedJob[]> {
10
+ const ownerName = Name.from(owner)
11
+ const now = opts?.now ?? new Date()
12
+
13
+ let rows: JobRow[]
14
+ try {
15
+ // index_position 'tertiary' = nodeos slot 3 = owner's secondary index; no ABI metadata for it.
16
+ rows = (await this.server
17
+ .table('jobs')
18
+ .query({
19
+ index_position: 'tertiary',
20
+ key_type: 'i64',
21
+ from: ownerName.value,
22
+ to: ownerName.value,
23
+ })
24
+ .all()) as JobRow[]
25
+ } catch {
26
+ rows = (await this.server.table('jobs').all()) as JobRow[]
27
+ }
28
+
29
+ return rows.filter((r) => ownerName.equals(r.owner)).map((r) => this.parseOwnedJob(r, now))
30
+ }
31
+
32
+ private parseOwnedJob(r: JobRow, now: Date): OwnedJob {
33
+ const startsAt = r.starts_at.toDate()
34
+ const completesAt = r.completes_at.toDate()
35
+ const {output, inputs} = splitJobCargo(r.cargo)
36
+ return {
37
+ id: r.id.toNumber(),
38
+ workshop: r.workshop.toNumber(),
39
+ socket: r.socket.toNumber(),
40
+ shipId: r.ship_id.toNumber(),
41
+ coords: {x: r.coords.x.toNumber(), y: r.coords.y.toNumber()},
42
+ startsAt,
43
+ completesAt,
44
+ recipeId: r.recipe_id.toNumber(),
45
+ quantity: r.quantity.toNumber(),
46
+ status: jobStatus({startsAt, completesAt}, now),
47
+ output,
48
+ inputs,
49
+ outputStats: output?.stats === undefined ? undefined : BigInt(output.stats.toString()),
50
+ }
51
+ }
52
+ }
@@ -85,6 +85,11 @@ export function computeBaseCapacityWorkshop(stats: bigint): number {
85
85
  return Math.floor(5_000_000 * 6 ** (s / 1998))
86
86
  }
87
87
 
88
+ export function computeBaseCapacityDepot(stats: bigint): number {
89
+ const s = decodeStat(stats, 0) + decodeStat(stats, 2)
90
+ return Math.floor(50_000_000 * 6 ** (s / 1998))
91
+ }
92
+
88
93
  const CAPACITY_FN_BY_KIND: Record<string, (stats: bigint) => number> = {
89
94
  ship: computeBaseCapacityShip,
90
95
  warehouse: computeBaseCapacityWarehouse,
@@ -95,6 +100,8 @@ const CAPACITY_FN_BY_KIND: Record<string, (stats: bigint) => number> = {
95
100
  mdriver: computeBaseCapacityContainer,
96
101
  mcatcher: computeBaseCapacityContainer,
97
102
  container: computeBaseCapacityContainer,
103
+ nexus: computeBaseCapacityContainer,
104
+ depot: computeBaseCapacityDepot,
98
105
  }
99
106
 
100
107
  export function computeBaseCapacityForEntity(itemId: number, stats: bigint): number {
@@ -0,0 +1,32 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+ import {jobStatus, 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
+ })
19
+
20
+ describe('splitJobCargo', () => {
21
+ it('takes the last element as output, the rest as inputs', () => {
22
+ const cargo = [{n: 'a'}, {n: 'b'}, {n: 'out'}] as unknown as Parameters<
23
+ typeof splitJobCargo
24
+ >[0]
25
+ const {output, inputs} = splitJobCargo(cargo)
26
+ expect(output).toEqual({n: 'out'} as never)
27
+ expect(inputs).toEqual([{n: 'a'}, {n: 'b'}] as never)
28
+ })
29
+ it('returns null output for empty cargo', () => {
30
+ expect(splitJobCargo([])).toEqual({output: null, inputs: []})
31
+ })
32
+ })
@@ -1,3 +1,7 @@
1
+ import type {ServerContract} from '../contracts'
2
+
3
+ type CargoItem = ServerContract.Types.cargo_item
4
+
1
5
  export interface JobWindow {
2
6
  id: number
3
7
  socket: number
@@ -6,6 +10,8 @@ export interface JobWindow {
6
10
  completesAt: Date
7
11
  recipeId: number
8
12
  quantity: number
13
+ /** Packed stat roll of the job's output, when the source carried the job's cargo. */
14
+ outputStats?: bigint
9
15
  }
10
16
 
11
17
  export interface JobLaneEntry {
@@ -69,3 +75,34 @@ export function pickFabricator(
69
75
  }
70
76
  return best
71
77
  }
78
+
79
+ export type JobStatus = 'waiting' | 'crafting' | 'ready'
80
+
81
+ export function jobStatus(job: {startsAt: Date; completesAt: Date}, now: Date): JobStatus {
82
+ if (now < job.startsAt) return 'waiting'
83
+ if (now < job.completesAt) return 'crafting'
84
+ return 'ready'
85
+ }
86
+
87
+ // Generic in the element so raw chain JSON (readonly actions) splits by the same rule as decoded rows.
88
+ export function splitJobCargo<T>(cargo: readonly T[]): {output: T | null; inputs: T[]} {
89
+ if (cargo.length === 0) return {output: null, inputs: []}
90
+ return {output: cargo[cargo.length - 1], inputs: cargo.slice(0, -1)}
91
+ }
92
+
93
+ export interface OwnedJob {
94
+ id: number
95
+ workshop: number
96
+ socket: number
97
+ shipId: number
98
+ coords: {x: number; y: number}
99
+ startsAt: Date
100
+ completesAt: Date
101
+ recipeId: number
102
+ quantity: number
103
+ status: JobStatus
104
+ output: CargoItem | null
105
+ inputs: CargoItem[]
106
+ /** Packed stat roll of `output`, so every job shape answers this the same way. */
107
+ outputStats?: bigint
108
+ }
@@ -34,6 +34,7 @@ export function taskCargoChanges(task: ServerContract.Types.task): TaskCargoChan
34
34
  case TaskType.GATHER:
35
35
  return task.couplings.length > 0 ? [] : items.map((i) => toChange(i, 'in'))
36
36
  case TaskType.UNLOAD:
37
+ case TaskType.CONTRIBUTE:
37
38
  return items.map((i) => toChange(i, 'out'))
38
39
  case TaskType.CRAFT:
39
40
  return [