@shipload/sdk 1.0.0-next.73 → 1.0.0-next.74

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 (38) hide show
  1. package/lib/shipload.d.ts +502 -127
  2. package/lib/shipload.js +5871 -4906
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +5858 -4900
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +174 -47
  7. package/lib/testing.js +524 -142
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +524 -142
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/contracts/server.ts +457 -134
  13. package/src/data/entities.json +2 -2
  14. package/src/index-module.ts +22 -1
  15. package/src/influence/civic-shuttle.test.ts +58 -0
  16. package/src/influence/civic-shuttle.ts +52 -6
  17. package/src/influence/duration.ts +0 -65
  18. package/src/influence/index.ts +4 -5
  19. package/src/influence/influence.test.ts +51 -136
  20. package/src/influence/valuation.ts +75 -0
  21. package/src/managers/actions.ts +37 -10
  22. package/src/managers/balances.test.ts +33 -0
  23. package/src/managers/balances.ts +15 -0
  24. package/src/managers/context.ts +50 -11
  25. package/src/managers/index.ts +14 -0
  26. package/src/managers/jobs.test.ts +82 -1
  27. package/src/managers/jobs.ts +41 -2
  28. package/src/managers/nft.test.ts +173 -1
  29. package/src/managers/nft.ts +35 -2
  30. package/src/managers/shuttle.test.ts +562 -0
  31. package/src/managers/shuttle.ts +397 -0
  32. package/src/scheduling/availability.ts +10 -0
  33. package/src/scheduling/energy.ts +55 -1
  34. package/src/scheduling/jobs.test.ts +171 -1
  35. package/src/scheduling/jobs.ts +110 -13
  36. package/src/scheduling/projection.ts +2 -1
  37. package/src/scheduling/task-effects.ts +38 -0
  38. package/src/shipload.ts +23 -4
@@ -332,25 +332,35 @@ export class ActionsManager extends BaseManager {
332
332
  depotstore(
333
333
  shipId: UInt64Type,
334
334
  depotId: UInt64Type,
335
- items: ServerContract.ActionParams.Type.cargo_item[]
335
+ items: ServerContract.ActionParams.Type.cargo_item[],
336
+ shuttledBy?: UInt64Type
336
337
  ): Action {
337
- return this.server.action('depotstore', {
338
+ const params: ServerContract.ActionParams.depotstore = {
338
339
  ship_id: UInt64.from(shipId),
339
340
  depot_id: UInt64.from(depotId),
340
341
  items,
341
- })
342
+ }
343
+ if (shuttledBy !== undefined) {
344
+ params.shuttled_by = UInt64.from(shuttledBy)
345
+ }
346
+ return this.server.action('depotstore', params)
342
347
  }
343
348
 
344
349
  depottake(
345
350
  shipId: UInt64Type,
346
351
  depotId: UInt64Type,
347
- items: ServerContract.ActionParams.Type.cargo_item[]
352
+ items: ServerContract.ActionParams.Type.cargo_item[],
353
+ shuttledBy?: UInt64Type
348
354
  ): Action {
349
- return this.server.action('depottake', {
355
+ const params: ServerContract.ActionParams.depottake = {
350
356
  ship_id: UInt64.from(shipId),
351
357
  depot_id: UInt64.from(depotId),
352
358
  items,
353
- })
359
+ }
360
+ if (shuttledBy !== undefined) {
361
+ params.shuttled_by = UInt64.from(shuttledBy)
362
+ }
363
+ return this.server.action('depottake', params)
354
364
  }
355
365
 
356
366
  shuttle(
@@ -518,7 +528,8 @@ export class ActionsManager extends BaseManager {
518
528
  workshopId: UInt64Type,
519
529
  recipeId: UInt16Type,
520
530
  quantity: UInt32Type,
521
- inputs: ServerContract.ActionParams.Type.cargo_item[]
531
+ inputs: ServerContract.ActionParams.Type.cargo_item[],
532
+ shuttledBy?: UInt64Type
522
533
  ): Action {
523
534
  const params: ServerContract.ActionParams.craftjob = {
524
535
  ship_id: UInt64.from(shipId),
@@ -527,6 +538,9 @@ export class ActionsManager extends BaseManager {
527
538
  quantity: UInt32.from(quantity),
528
539
  inputs,
529
540
  }
541
+ if (shuttledBy !== undefined) {
542
+ params.shuttled_by = UInt64.from(shuttledBy)
543
+ }
530
544
  return this.server.action('craftjob', params)
531
545
  }
532
546
 
@@ -540,6 +554,9 @@ export class ActionsManager extends BaseManager {
540
554
  canceljob(route: JobCancelRoute): Action {
541
555
  if (route.kind === 'dropoff') return this.cancel(route.shipId, route.laneKey, route.count)
542
556
  if (route.kind === 'build') return this.cancelbuild(route.jobId)
557
+ if (route.kind === 'civic') {
558
+ return this.cancelcivic(route.buildingId, route.laneKey, route.fromId)
559
+ }
543
560
  return this.cancelcraft(route.jobId)
544
561
  }
545
562
 
@@ -566,11 +583,14 @@ export class ActionsManager extends BaseManager {
566
583
  return this.server.action('clustercraft', params)
567
584
  }
568
585
 
569
- claimcraft(jobId: UInt64Type, shipId: UInt64Type): Action {
586
+ claimcraft(jobId: UInt64Type, shipId: UInt64Type, shuttledBy?: UInt64Type): Action {
570
587
  const params: ServerContract.ActionParams.claimcraft = {
571
588
  job_id: UInt64.from(jobId),
572
589
  ship_id: UInt64.from(shipId),
573
590
  }
591
+ if (shuttledBy !== undefined) {
592
+ params.shuttled_by = UInt64.from(shuttledBy)
593
+ }
574
594
  return this.server.action('claimcraft', params)
575
595
  }
576
596
 
@@ -578,7 +598,8 @@ export class ActionsManager extends BaseManager {
578
598
  targetId: UInt64Type,
579
599
  dockId: UInt64Type,
580
600
  targetItemId: UInt16Type,
581
- inputs: ServerContract.ActionParams.Type.cargo_item[]
601
+ inputs: ServerContract.ActionParams.Type.cargo_item[],
602
+ shuttledBy?: UInt64Type
582
603
  ): Action {
583
604
  const params: ServerContract.ActionParams.buildjob = {
584
605
  target_id: UInt64.from(targetId),
@@ -586,6 +607,9 @@ export class ActionsManager extends BaseManager {
586
607
  target_item_id: UInt16.from(targetItemId),
587
608
  inputs,
588
609
  }
610
+ if (shuttledBy !== undefined) {
611
+ params.shuttled_by = UInt64.from(shuttledBy)
612
+ }
589
613
  return this.server.action('buildjob', params)
590
614
  }
591
615
 
@@ -596,10 +620,13 @@ export class ActionsManager extends BaseManager {
596
620
  return this.server.action('claimbuild', params)
597
621
  }
598
622
 
599
- cancelbuild(jobId: UInt64Type): Action {
623
+ cancelbuild(jobId: UInt64Type, shuttledBy?: UInt64Type): Action {
600
624
  const params: ServerContract.ActionParams.cancelbuild = {
601
625
  job_id: UInt64.from(jobId),
602
626
  }
627
+ if (shuttledBy !== undefined) {
628
+ params.shuttled_by = UInt64.from(shuttledBy)
629
+ }
603
630
  return this.server.action('cancelbuild', params)
604
631
  }
605
632
 
@@ -78,6 +78,39 @@ describe('getDepositConfig', () => {
78
78
  })
79
79
  })
80
80
 
81
+ describe('setDepositConfig', () => {
82
+ test('a seeded config keeps every balance read off the chain', async () => {
83
+ const {manager, counts} = stubContext({
84
+ depositConfig: SCRAP,
85
+ balances: [{token_contract: 'scrap.gm', balance: '5 SCRAP'}],
86
+ wallet: ['7 SCRAP'],
87
+ })
88
+ manager.setDepositConfig({tokenContract: 'scrap.gm', symbol: '0,SCRAP'})
89
+
90
+ const config = await manager.getDepositConfig()
91
+ expect(String(config?.tokenContract)).toBe('scrap.gm')
92
+ expect(String(config?.symbol)).toBe('0,SCRAP')
93
+ await manager.getPlatformBalance('alice')
94
+ await manager.getWalletBalance('alice')
95
+
96
+ expect(counts.depositcfg).toBe(0)
97
+ })
98
+
99
+ test('a reload still reaches the chain', async () => {
100
+ const {manager, counts} = stubContext({depositConfig: SCRAP})
101
+ manager.setDepositConfig({tokenContract: 'scrap.gm', symbol: '0,SCRAP'})
102
+ await manager.getDepositConfig(true)
103
+ expect(counts.depositcfg).toBe(1)
104
+ })
105
+
106
+ test('seeding null records a chain with no deposit token', async () => {
107
+ const {manager, counts} = stubContext({depositConfig: SCRAP})
108
+ manager.setDepositConfig(null)
109
+ expect(await manager.getDepositConfig()).toBeNull()
110
+ expect(counts.depositcfg).toBe(0)
111
+ })
112
+ })
113
+
81
114
  describe('getPlatformBalance', () => {
82
115
  test('returns null when the owner has never opened a row', async () => {
83
116
  const {manager} = stubContext({depositConfig: SCRAP, balances: []})
@@ -7,6 +7,11 @@ export interface DepositConfig {
7
7
  symbol: Asset.Symbol
8
8
  }
9
9
 
10
+ export interface DepositConfigInput {
11
+ tokenContract: NameType
12
+ symbol: Asset.SymbolType
13
+ }
14
+
10
15
  export interface PlatformBalance {
11
16
  owner: Name
12
17
  tokenContract: Name
@@ -22,6 +27,16 @@ export interface TokenBalance {
22
27
  export class BalancesManager extends BaseManager {
23
28
  private depositConfig?: DepositConfig | null
24
29
 
30
+ // A caller holding the config already, from the indexer or its own cache, seeds it here so no balance read reaches the chain for it.
31
+ setDepositConfig(config: DepositConfigInput | null): void {
32
+ this.depositConfig = config
33
+ ? {
34
+ tokenContract: Name.from(config.tokenContract),
35
+ symbol: Asset.Symbol.from(config.symbol),
36
+ }
37
+ : null
38
+ }
39
+
25
40
  async getDepositConfig(reload = false): Promise<DepositConfig | null> {
26
41
  if (!reload && this.depositConfig !== undefined) {
27
42
  return this.depositConfig
@@ -15,6 +15,7 @@ import {NftManager} from './nft'
15
15
  import {BalancesManager} from './balances'
16
16
  import {FundManager} from './fund'
17
17
  import {JobsManager} from './jobs'
18
+ import {ShuttleManager} from './shuttle'
18
19
  import {InfluenceManager} from './influence'
19
20
  import {SubscriptionsManager} from '../subscriptions/manager'
20
21
 
@@ -30,12 +31,15 @@ export class GameContext {
30
31
  private _balances?: BalancesManager
31
32
  private _funds?: FundManager
32
33
  private _jobs?: JobsManager
34
+ private _shuttle?: ShuttleManager
33
35
  private _influence?: InfluenceManager
34
36
  private _subscriptions?: SubscriptionsManager
35
37
  private _subscriptionsUrl?: string
36
38
 
37
39
  private _gameCache?: PlatformContract.Types.game_row
40
+ private _gameInflight?: Promise<PlatformContract.Types.game_row>
38
41
  private _stateCache?: GameState
42
+ private _stateInflight?: Promise<GameState>
39
43
 
40
44
  constructor(
41
45
  public readonly client: APIClient,
@@ -122,6 +126,13 @@ export class GameContext {
122
126
  return this._jobs
123
127
  }
124
128
 
129
+ get shuttle(): ShuttleManager {
130
+ if (!this._shuttle) {
131
+ this._shuttle = new ShuttleManager(this)
132
+ }
133
+ return this._shuttle
134
+ }
135
+
125
136
  get influence(): InfluenceManager {
126
137
  if (!this._influence) {
127
138
  this._influence = new InfluenceManager(this)
@@ -149,25 +160,53 @@ export class GameContext {
149
160
  if (!reload && this._gameCache) {
150
161
  return this._gameCache
151
162
  }
152
- const game = await this.platform.table('games').get()
153
- if (!game) {
154
- throw new Error('Game not initialized')
163
+ if (!reload && this._gameInflight) {
164
+ return this._gameInflight
165
+ }
166
+ const inflight = (async () => {
167
+ const game = await this.platform.table('games').get()
168
+ if (!game) {
169
+ throw new Error('Game not initialized')
170
+ }
171
+ this._gameCache = game
172
+ return game
173
+ })()
174
+ this._gameInflight = inflight
175
+ try {
176
+ return await inflight
177
+ } finally {
178
+ if (this._gameInflight === inflight) {
179
+ this._gameInflight = undefined
180
+ }
155
181
  }
156
- this._gameCache = game
157
- return game
158
182
  }
159
183
 
160
184
  async getState(reload = false): Promise<GameState> {
161
185
  if (!reload && this._stateCache) {
162
186
  return this._stateCache
163
187
  }
164
- const state = await this.server.table('state').get()
165
- if (!state) {
166
- throw new Error('Game state not initialized')
188
+ if (!reload && this._stateInflight) {
189
+ return this._stateInflight
190
+ }
191
+ const inflight = (async () => {
192
+ const [state, game] = await Promise.all([
193
+ this.server.table('state').get(),
194
+ this.getGame(),
195
+ ])
196
+ if (!state) {
197
+ throw new Error('Game state not initialized')
198
+ }
199
+ this._stateCache = GameState.from(state, game)
200
+ return this._stateCache
201
+ })()
202
+ this._stateInflight = inflight
203
+ try {
204
+ return await inflight
205
+ } finally {
206
+ if (this._stateInflight === inflight) {
207
+ this._stateInflight = undefined
208
+ }
167
209
  }
168
- const game = this._gameCache
169
- this._stateCache = GameState.from(state, game)
170
- return this._stateCache
171
210
  }
172
211
 
173
212
  get cachedGame(): PlatformContract.Types.game_row | undefined {
@@ -15,6 +15,20 @@ export type {NftConfigForItem, WrapConfig, WrapGate} from './nft'
15
15
  export {BalancesManager} from './balances'
16
16
  export type {DepositConfig, PlatformBalance, TokenBalance} from './balances'
17
17
  export {JobsManager} from './jobs'
18
+ export {
19
+ ShuttleManager,
20
+ rankShuttleOptions,
21
+ shuttleReasonCode,
22
+ BOOKING_LEVEL_CODES,
23
+ OMITTED_REASONS,
24
+ } from './shuttle'
25
+ export type {
26
+ ShuttleMode,
27
+ ShuttleOption,
28
+ ShuttleOptions,
29
+ ShuttleReason,
30
+ ShuttleReasonCode,
31
+ } from './shuttle'
18
32
  export {InfluenceManager} from './influence'
19
33
  export type {
20
34
  CharterProgress,
@@ -41,7 +41,7 @@ describe('JobsManager.getOwnedJobs', () => {
41
41
  expect(jobs[0].inputs).toEqual([{item: 'in'}] as never)
42
42
  })
43
43
 
44
- it('reports an undeposited row as dropping off, whatever its window says', async () => {
44
+ it('reports a legacy undeposited row (inputs only) as dropping off, whatever its window says', async () => {
45
45
  const inFlight = row({
46
46
  deposited: false,
47
47
  cargo: [{item: 'in'}],
@@ -57,6 +57,27 @@ describe('JobsManager.getOwnedJobs', () => {
57
57
  expect(jobs[0].inputs).toEqual([{item: 'in'}] as never)
58
58
  })
59
59
 
60
+ it('reads a booking-time undeposited row whose input is booked as more than one stack', async () => {
61
+ const inFlight = row({
62
+ deposited: false,
63
+ cargo: [
64
+ {item_id: 101, quantity: 20},
65
+ {item_id: 101, quantity: 30},
66
+ ],
67
+ })
68
+ const m = managerWith(
69
+ async () => [inFlight],
70
+ async () => []
71
+ )
72
+ const jobs = await m.getOwnedJobs(OWNER, {now: new Date('2026-07-26T11:30:00Z')})
73
+ expect(jobs[0].status).toBe('dropping')
74
+ expect(jobs[0].inputs).toEqual([
75
+ {item_id: 101, quantity: 20},
76
+ {item_id: 101, quantity: 30},
77
+ ] as never)
78
+ expect(jobs[0].output).toBeNull()
79
+ })
80
+
60
81
  it('reports a cancelled row as ready with its inputs held', async () => {
61
82
  const cancelled = row({
62
83
  quantity: {toNumber: () => 0},
@@ -94,3 +115,63 @@ describe('JobsManager.getOwnedJobs', () => {
94
115
  expect(jobs).toHaveLength(1)
95
116
  })
96
117
  })
118
+
119
+ const buildRow = (over: Record<string, unknown> = {}) => ({
120
+ id: {toNumber: () => 9},
121
+ building: {toNumber: () => 2},
122
+ socket: {toNumber: () => 1},
123
+ target: {toNumber: () => 5},
124
+ owner: Name.from(OWNER),
125
+ coords: {x: {toNumber: () => 0}, y: {toNumber: () => 0}},
126
+ starts_at: {toDate: () => new Date('2026-07-26T10:00:00Z')},
127
+ completes_at: {toDate: () => new Date('2026-07-26T11:00:00Z')},
128
+ arrives_at: {toDate: () => new Date('2026-07-26T09:45:00Z')},
129
+ target_item_id: {toNumber: () => 10212},
130
+ cargo: [{item: 'in'}],
131
+ deposited: true,
132
+ ...over,
133
+ })
134
+
135
+ function managerWithReadonly(impl: (name: string, data: unknown) => Promise<unknown>) {
136
+ const ctx = {server: {readonly: impl}} as never
137
+ return new JobsManager(ctx)
138
+ }
139
+
140
+ describe('JobsManager.getBuildJobs', () => {
141
+ it('reads the dock queue through getbuildjobs and parses every row', async () => {
142
+ let called: unknown = null
143
+ const m = managerWithReadonly(async (name, data) => {
144
+ called = {name, data}
145
+ return {jobs: [buildRow(), buildRow({id: {toNumber: () => 10}, deposited: false})]}
146
+ })
147
+ const jobs = await m.getBuildJobs(2, {now: new Date('2026-07-26T11:30:00Z')})
148
+ expect(called).toMatchObject({name: 'getbuildjobs'})
149
+ expect(String((called as {data: {building_id: unknown}}).data.building_id)).toBe('2')
150
+ expect(jobs).toHaveLength(2)
151
+ expect(jobs[0]).toMatchObject({
152
+ id: 9,
153
+ building: 2,
154
+ socket: 1,
155
+ targetId: 5,
156
+ owner: OWNER,
157
+ targetItemId: 10212,
158
+ deposited: true,
159
+ status: 'ready',
160
+ })
161
+ expect(jobs[0].coords).toEqual({x: 0, y: 0})
162
+ expect(jobs[0].arrivesAt).toEqual(new Date('2026-07-26T09:45:00Z'))
163
+ expect(jobs[0].inputs).toEqual([{item: 'in'}] as never)
164
+ expect(jobs[1].status).toBe('dropping')
165
+ })
166
+
167
+ it('reports a booked row before its window as queued', async () => {
168
+ const m = managerWithReadonly(async () => ({jobs: [buildRow()]}))
169
+ const jobs = await m.getBuildJobs(2, {now: new Date('2026-07-26T09:50:00Z')})
170
+ expect(jobs[0].status).toBe('queued')
171
+ })
172
+
173
+ it('returns an empty list when the result carries no jobs', async () => {
174
+ const m = managerWithReadonly(async () => ({}))
175
+ expect(await m.getBuildJobs(2)).toEqual([])
176
+ })
177
+ })
@@ -1,9 +1,16 @@
1
- import {Name, type NameType} from '@wharfkit/antelope'
1
+ import {Name, UInt64, type NameType, type UInt64Type} from '@wharfkit/antelope'
2
2
  import {BaseManager} from './base'
3
- import {jobDeposited, jobStatus, splitJobCargo, type OwnedJob} from '../scheduling/jobs'
3
+ import {
4
+ jobDeposited,
5
+ jobStatus,
6
+ splitJobCargo,
7
+ type BuildJob,
8
+ type OwnedJob,
9
+ } from '../scheduling/jobs'
4
10
  import type {ServerContract} from '../contracts'
5
11
 
6
12
  type JobRow = ServerContract.Types.craftjob_row
13
+ type BuildJobRow = ServerContract.Types.buildjob_row
7
14
 
8
15
  export class JobsManager extends BaseManager {
9
16
  async getOwnedJobs(owner: NameType, opts?: {now?: Date}): Promise<OwnedJob[]> {
@@ -29,6 +36,38 @@ export class JobsManager extends BaseManager {
29
36
  return rows.filter((r) => ownerName.equals(r.owner)).map((r) => this.parseOwnedJob(r, now))
30
37
  }
31
38
 
39
+ async getBuildJobs(dockId: UInt64Type, opts?: {now?: Date}): Promise<BuildJob[]> {
40
+ const now = opts?.now ?? new Date()
41
+ const result = (await this.server.readonly('getbuildjobs', {
42
+ building_id: UInt64.from(dockId),
43
+ })) as ServerContract.Types.buildjobs_result | undefined
44
+ return (result?.jobs ?? []).map((r) => this.parseBuildJob(r, now))
45
+ }
46
+
47
+ private parseBuildJob(r: BuildJobRow, now: Date): BuildJob {
48
+ const startsAt = r.starts_at.toDate()
49
+ const completesAt = r.completes_at.toDate()
50
+ const arrivesAt = r.arrives_at.toDate()
51
+ const deposited = jobDeposited(r.deposited)
52
+ const building = r.building.toNumber()
53
+ const inputs = [...r.cargo]
54
+ return {
55
+ id: r.id.toNumber(),
56
+ building,
57
+ socket: r.socket.toNumber(),
58
+ targetId: r.target.toNumber(),
59
+ owner: r.owner.toString(),
60
+ coords: {x: r.coords.x.toNumber(), y: r.coords.y.toNumber()},
61
+ startsAt,
62
+ completesAt,
63
+ arrivesAt,
64
+ targetItemId: r.target_item_id.toNumber(),
65
+ status: jobStatus({startsAt, completesAt, arrivesAt, deposited, building, inputs}, now),
66
+ deposited,
67
+ inputs,
68
+ }
69
+ }
70
+
32
71
  private parseOwnedJob(r: JobRow, now: Date): OwnedJob {
33
72
  const startsAt = r.starts_at.toDate()
34
73
  const completesAt = r.completes_at.toDate()
@@ -1,5 +1,8 @@
1
1
  import {describe, expect, test} from 'bun:test'
2
- import {resolveLockedAmount, wrapCostKey} from './nft'
2
+ import {Asset, Name} from '@wharfkit/antelope'
3
+ import {NftManager, resolveLockedAmount, wrapCostKey} from './nft'
4
+ import type {GameContext} from './context'
5
+ import {ServerContract} from '../contracts'
3
6
 
4
7
  describe('resolveLockedAmount', () => {
5
8
  test('no fee refunds the full cost', () => {
@@ -19,3 +22,172 @@ describe('wrapCostKey', () => {
19
22
  expect(wrapCostKey(3, 2).toString()).toBe('770')
20
23
  })
21
24
  })
25
+
26
+ interface StubOptions {
27
+ wrapConfig?: {fee_pct: number; fee_account: string; min_asset_id?: number} | null
28
+ wrapCosts?: {item_type: number; tier: number; amount: number}[]
29
+ depositConfig?: {tokenContract: string; symbol: string} | null
30
+ }
31
+
32
+ function stubContext(options: StubOptions = {}) {
33
+ const counts = {wrapconfig: 0, wrapcost: 0, depositcfg: 0}
34
+ const costRows = (options.wrapCosts ?? []).map((row) =>
35
+ ServerContract.Types.wrapcost_row.from(row)
36
+ )
37
+ const server = {
38
+ table(name: string) {
39
+ if (name === 'wrapconfig') {
40
+ return {
41
+ get: async () => {
42
+ counts.wrapconfig++
43
+ return options.wrapConfig
44
+ ? ServerContract.Types.wrapconfig_row.from({
45
+ ...options.wrapConfig,
46
+ min_asset_id: options.wrapConfig.min_asset_id ?? 0,
47
+ })
48
+ : undefined
49
+ },
50
+ }
51
+ }
52
+ if (name === 'wrapcost') {
53
+ return {
54
+ get: async (key: {toString(): string}) => {
55
+ counts.wrapcost++
56
+ return costRows.find(
57
+ (row) =>
58
+ wrapCostKey(Number(row.item_type), Number(row.tier)).toString() ===
59
+ key.toString()
60
+ )
61
+ },
62
+ }
63
+ }
64
+ throw new Error(`unexpected table: ${name}`)
65
+ },
66
+ }
67
+ const balances = {
68
+ getDepositConfig: async () => {
69
+ counts.depositcfg++
70
+ return options.depositConfig
71
+ ? {
72
+ tokenContract: Name.from(options.depositConfig.tokenContract),
73
+ symbol: Asset.Symbol.from(options.depositConfig.symbol),
74
+ }
75
+ : null
76
+ },
77
+ }
78
+ const context = {server, balances} as unknown as GameContext
79
+ return {manager: new NftManager(context), counts}
80
+ }
81
+
82
+ const DEPOSIT = {tokenContract: 'scrap.gm', symbol: '0,SCRAP'}
83
+
84
+ describe('setWrapConfig', () => {
85
+ test('a seeded config keeps getWrapConfig off the chain', async () => {
86
+ const {manager, counts} = stubContext({wrapConfig: {fee_pct: 200, fee_account: 'fee.gm'}})
87
+ manager.setWrapConfig({feePctBasisPoints: 200, feeAccount: 'fee.gm'})
88
+
89
+ const config = await manager.getWrapConfig()
90
+ expect(config?.feePctBasisPoints).toBe(200)
91
+ expect(String(config?.feeAccount)).toBe('fee.gm')
92
+ expect(counts.wrapconfig).toBe(0)
93
+ })
94
+
95
+ test('a reload still reaches the chain', async () => {
96
+ const {manager, counts} = stubContext({wrapConfig: {fee_pct: 200, fee_account: 'fee.gm'}})
97
+ manager.setWrapConfig({feePctBasisPoints: 200, feeAccount: 'fee.gm'})
98
+ await manager.getWrapConfig(true)
99
+ expect(counts.wrapconfig).toBe(1)
100
+ })
101
+
102
+ test('seeding null records a chain with no wrap fee configured', async () => {
103
+ const {manager, counts} = stubContext({wrapConfig: {fee_pct: 200, fee_account: 'fee.gm'}})
104
+ manager.setWrapConfig(null)
105
+ expect(await manager.getWrapConfig()).toBeNull()
106
+ expect(counts.wrapconfig).toBe(0)
107
+ })
108
+ })
109
+
110
+ describe('setWrapCosts', () => {
111
+ test('a seeded cost keeps getWrapCost off the chain', async () => {
112
+ const {manager, counts} = stubContext({
113
+ wrapCosts: [{item_type: 3, tier: 2, amount: 500}],
114
+ })
115
+ manager.setWrapCosts([{itemType: 3, tier: 2, units: 500n}])
116
+
117
+ expect(await manager.getWrapCost(3, 2)).toBe(500n)
118
+ expect(counts.wrapcost).toBe(0)
119
+ })
120
+
121
+ test('an item missing from the seed falls back to chain', async () => {
122
+ const {manager, counts} = stubContext({
123
+ wrapCosts: [{item_type: 3, tier: 2, amount: 500}],
124
+ })
125
+ manager.setWrapCosts([{itemType: 3, tier: 2, units: 500n}])
126
+
127
+ expect(await manager.getWrapCost(9, 1)).toBe(0n)
128
+ expect(counts.wrapcost).toBe(1)
129
+ })
130
+
131
+ test('a reload still reaches the chain', async () => {
132
+ const {manager, counts} = stubContext({
133
+ wrapCosts: [{item_type: 3, tier: 2, amount: 500}],
134
+ })
135
+ manager.setWrapCosts([{itemType: 3, tier: 2, units: 500n}])
136
+ await manager.getWrapCost(3, 2, true)
137
+ expect(counts.wrapcost).toBe(1)
138
+ })
139
+ })
140
+
141
+ describe('getWrapDeposit', () => {
142
+ test('with every seed present, makes no chain reads', async () => {
143
+ const {manager, counts} = stubContext({
144
+ wrapCosts: [{item_type: 3, tier: 2, amount: 500}],
145
+ wrapConfig: {fee_pct: 200, fee_account: 'fee.gm'},
146
+ depositConfig: DEPOSIT,
147
+ })
148
+ manager.setWrapCosts([{itemType: 3, tier: 2, units: 500n}])
149
+ manager.setWrapConfig({feePctBasisPoints: 200, feeAccount: 'fee.gm'})
150
+
151
+ const deposit = await manager.getWrapDeposit(3, 2)
152
+ expect(deposit).toEqual({
153
+ cost: 500n,
154
+ refund: 490n,
155
+ feePct: 2,
156
+ symbol: 'SCRAP',
157
+ precision: 0,
158
+ tokenContract: 'scrap.gm',
159
+ })
160
+ expect(counts.wrapcost).toBe(0)
161
+ expect(counts.wrapconfig).toBe(0)
162
+ // getDepositConfig is a BalancesManager concern seeded separately; this stub always calls through.
163
+ expect(counts.depositcfg).toBe(1)
164
+ })
165
+
166
+ test('with nothing seeded, falls back to chain for config and cost', async () => {
167
+ const {manager, counts} = stubContext({
168
+ wrapCosts: [{item_type: 3, tier: 2, amount: 500}],
169
+ wrapConfig: {fee_pct: 200, fee_account: 'fee.gm'},
170
+ depositConfig: DEPOSIT,
171
+ })
172
+
173
+ const deposit = await manager.getWrapDeposit(3, 2)
174
+ expect(deposit?.cost).toBe(500n)
175
+ expect(counts.wrapcost).toBe(1)
176
+ expect(counts.wrapconfig).toBe(1)
177
+ })
178
+
179
+ test('reload true bypasses seeds and reaches chain', async () => {
180
+ const {manager, counts} = stubContext({
181
+ wrapCosts: [{item_type: 3, tier: 2, amount: 500}],
182
+ wrapConfig: {fee_pct: 200, fee_account: 'fee.gm'},
183
+ depositConfig: DEPOSIT,
184
+ })
185
+ manager.setWrapCosts([{itemType: 3, tier: 2, units: 500n}])
186
+ manager.setWrapConfig({feePctBasisPoints: 200, feeAccount: 'fee.gm'})
187
+
188
+ await manager.getWrapDeposit(3, 2, {reload: true})
189
+ expect(counts.wrapcost).toBe(1)
190
+ expect(counts.wrapconfig).toBe(1)
191
+ expect(counts.depositcfg).toBe(1)
192
+ })
193
+ })