@shipload/sdk 1.0.0-next.39 → 1.0.0-next.40

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipload/sdk",
3
3
  "description": "SDKs for Shipload",
4
- "version": "1.0.0-next.39",
4
+ "version": "1.0.0-next.40",
5
5
  "homepage": "https://github.com/shipload/toolkit/tree/master/packages/sdk",
6
6
  "repository": {
7
7
  "type": "git",
@@ -28,7 +28,15 @@ export {
28
28
  PLANET_SUBTYPE_INDUSTRIAL,
29
29
  } from './resources'
30
30
 
31
- export {RESERVE_TIERS, TIER_ROLL_MAX, tierOfReserve, rollTier, rollWithinTier} from './tiers'
31
+ export {
32
+ RESERVE_TIERS,
33
+ TIER_ROLL_MAX,
34
+ tierOfReserve,
35
+ rollTier,
36
+ rollWithinTier,
37
+ RESOURCE_TIER_MULT_TENTHS,
38
+ applyResourceTierMultiplier,
39
+ } from './tiers'
32
40
  export type {ReserveTier, TierRange} from './tiers'
33
41
 
34
42
  export {getEffectiveReserve} from './reserve-regen'
@@ -0,0 +1,78 @@
1
+ import {expect, test} from 'bun:test'
2
+ import {
3
+ getAllRecipes,
4
+ getRecipeConsumers,
5
+ getComponentDemand,
6
+ getResourceDemand,
7
+ } from './recipe-usage'
8
+ import {
9
+ ITEM_SENSOR,
10
+ ITEM_RESIN,
11
+ ITEM_PLATE,
12
+ ITEM_BEAM,
13
+ ITEM_GATHERER_T1,
14
+ ITEM_CRAFTER_T1,
15
+ ITEM_EXTRACTOR_T1_PACKED,
16
+ ITEM_SHIP_T1_PACKED,
17
+ } from '../data/item-ids'
18
+
19
+ test('getAllRecipes returns the full catalog including the gatherer', () => {
20
+ const all = getAllRecipes()
21
+ expect(all.length).toBeGreaterThan(20)
22
+ expect(all.some((r) => r.outputItemId === ITEM_GATHERER_T1)).toBe(true)
23
+ })
24
+
25
+ test('getRecipeConsumers lists every recipe that consumes Sensor', () => {
26
+ const consumers = getRecipeConsumers(ITEM_SENSOR)
27
+ const ids = consumers.map((c) => c.outputItemId).sort((a, b) => a - b)
28
+ expect(ids).toEqual(
29
+ [ITEM_GATHERER_T1, ITEM_CRAFTER_T1, ITEM_SHIP_T1_PACKED, ITEM_EXTRACTOR_T1_PACKED].sort(
30
+ (a, b) => a - b
31
+ )
32
+ )
33
+ })
34
+
35
+ test('Sensor feeds the gatherer drain stat', () => {
36
+ const consumers = getRecipeConsumers(ITEM_SENSOR)
37
+ const gatherer = consumers.find((c) => c.outputItemId === ITEM_GATHERER_T1)
38
+ expect(gatherer).toBeDefined()
39
+ const drain = gatherer?.statFlows.find(
40
+ (f) => f.capability === 'Gathering' && f.attribute === 'drain'
41
+ )
42
+ expect(drain).toBeDefined()
43
+ })
44
+
45
+ test('Sensor is a mass-only sink in the Extractor recipe', () => {
46
+ const consumers = getRecipeConsumers(ITEM_SENSOR)
47
+ const extractor = consumers.find((c) => c.outputItemId === ITEM_EXTRACTOR_T1_PACKED)
48
+ expect(extractor).toBeDefined()
49
+ expect(extractor?.statFlows).toHaveLength(0)
50
+ })
51
+
52
+ test('getResourceDemand returns the resource tonnage for a single-resource component', () => {
53
+ expect(getResourceDemand(ITEM_PLATE)).toEqual({ore: 10})
54
+ })
55
+
56
+ test('getResourceDemand traces a dual-resource component to both resources', () => {
57
+ expect(getResourceDemand(ITEM_BEAM)).toEqual({ore: 5, gas: 5})
58
+ })
59
+
60
+ test('getResourceDemand recurses through a module to raw resources', () => {
61
+ // Gatherer = 300 Beam (5 ore + 5 gas each) + 300 Sensor (10 crystal each)
62
+ expect(getResourceDemand(ITEM_GATHERER_T1)).toEqual({
63
+ ore: 1500,
64
+ gas: 1500,
65
+ crystal: 3000,
66
+ })
67
+ })
68
+
69
+ test('getResourceDemand scales by quantity', () => {
70
+ expect(getResourceDemand(ITEM_PLATE, 3)).toEqual({ore: 30})
71
+ })
72
+
73
+ test('getComponentDemand reports Resin as consumed by exactly one recipe', () => {
74
+ const demand = getComponentDemand()
75
+ const resin = demand.find((d) => d.itemId === ITEM_RESIN)
76
+ expect(resin).toBeDefined()
77
+ expect(resin?.consumerCount).toBe(1)
78
+ })
@@ -0,0 +1,141 @@
1
+ import recipes from '../data/recipes.json'
2
+ import {getRecipe, type Recipe} from '../data/recipes-runtime'
3
+ import {getItem} from '../data/catalog'
4
+ import {getStatDefinitions} from './stats'
5
+ import {SLOT_FORMULAS, type SlotConsumerKind} from '../data/capability-formulas'
6
+ import {KIND_TO_ITEM_ID} from './capability-mappings'
7
+ import type {ResourceCategory} from '../types'
8
+
9
+ export function getAllRecipes(): Recipe[] {
10
+ return recipes as unknown as Recipe[]
11
+ }
12
+
13
+ export type ResourceDemand = Partial<Record<ResourceCategory, number>>
14
+
15
+ function accumulateResourceDemand(itemId: number, quantity: number, out: ResourceDemand): void {
16
+ const item = getItem(itemId)
17
+ if (item.type === 'resource' && item.category) {
18
+ out[item.category] = (out[item.category] ?? 0) + quantity
19
+ return
20
+ }
21
+ const recipe = getRecipe(itemId)
22
+ if (!recipe) return
23
+ for (const input of recipe.inputs) {
24
+ accumulateResourceDemand(input.itemId, input.quantity * quantity, out)
25
+ }
26
+ }
27
+
28
+ // Raw-resource tonnage to craft `quantity` of an item, tracing recipes to the resource leaves.
29
+ export function getResourceDemand(itemId: number, quantity = 1): ResourceDemand {
30
+ const out: ResourceDemand = {}
31
+ accumulateResourceDemand(itemId, quantity, out)
32
+ return out
33
+ }
34
+
35
+ const ITEM_ID_TO_KIND = new Map<number, SlotConsumerKind>()
36
+ for (const [kind, itemId] of Object.entries(KIND_TO_ITEM_ID) as [SlotConsumerKind, number][]) {
37
+ ITEM_ID_TO_KIND.set(itemId, kind)
38
+ }
39
+
40
+ // Traces a stat index down to the raw category stat label it carries (Sensor stat 0 → "Conductivity").
41
+ function resolveComponentStatLabel(itemId: number, statIndex: number): string | undefined {
42
+ let item: ReturnType<typeof getItem>
43
+ try {
44
+ item = getItem(itemId)
45
+ } catch {
46
+ return undefined
47
+ }
48
+ if (item.type === 'resource' && item.category) {
49
+ return getStatDefinitions(item.category)[statIndex]?.label
50
+ }
51
+ const recipe = getRecipe(itemId)
52
+ const slot = recipe?.statSlots[statIndex]
53
+ const source = slot?.sources[0]
54
+ if (!recipe || !source) return undefined
55
+ const input = recipe.inputs[source.inputIndex]
56
+ if (!input) return undefined
57
+ return resolveComponentStatLabel(input.itemId, source.statIndex)
58
+ }
59
+
60
+ export interface StatFlow {
61
+ slotIndex: number
62
+ capability?: string
63
+ attribute?: string
64
+ sourceStatIndex: number
65
+ sourceStatLabel?: string
66
+ }
67
+
68
+ export interface RecipeConsumer {
69
+ outputItemId: number
70
+ quantity: number
71
+ statFlows: StatFlow[]
72
+ }
73
+
74
+ /** Every recipe that consumes `componentItemId`, with how its stats flow through. */
75
+ export function getRecipeConsumers(componentItemId: number): RecipeConsumer[] {
76
+ const out: RecipeConsumer[] = []
77
+ for (const recipe of getAllRecipes()) {
78
+ for (let inputIndex = 0; inputIndex < recipe.inputs.length; inputIndex++) {
79
+ if (recipe.inputs[inputIndex].itemId !== componentItemId) continue
80
+ const kind = ITEM_ID_TO_KIND.get(recipe.outputItemId)
81
+ const formulas = kind ? SLOT_FORMULAS[kind] : undefined
82
+ const statFlows: StatFlow[] = []
83
+ for (let slotIndex = 0; slotIndex < recipe.statSlots.length; slotIndex++) {
84
+ for (const source of recipe.statSlots[slotIndex].sources) {
85
+ if (source.inputIndex !== inputIndex) continue
86
+ const consumer = formulas?.[slotIndex]
87
+ statFlows.push({
88
+ slotIndex,
89
+ capability: consumer?.capability,
90
+ attribute: consumer?.attribute,
91
+ sourceStatIndex: source.statIndex,
92
+ sourceStatLabel: resolveComponentStatLabel(
93
+ componentItemId,
94
+ source.statIndex
95
+ ),
96
+ })
97
+ }
98
+ }
99
+ out.push({
100
+ outputItemId: recipe.outputItemId,
101
+ quantity: recipe.inputs[inputIndex].quantity,
102
+ statFlows,
103
+ })
104
+ }
105
+ }
106
+ return out
107
+ }
108
+
109
+ export interface DemandRow {
110
+ itemId: number
111
+ consumerCount: number
112
+ statSourceCount: number
113
+ sinkOnlyCount: number
114
+ consumers: number[]
115
+ }
116
+
117
+ /** Demand tally for every item consumed as a recipe input, ascending by usage. */
118
+ export function getComponentDemand(): DemandRow[] {
119
+ const inputIds = new Set<number>()
120
+ for (const recipe of getAllRecipes()) {
121
+ for (const input of recipe.inputs) inputIds.add(input.itemId)
122
+ }
123
+ const rows: DemandRow[] = []
124
+ for (const itemId of inputIds) {
125
+ const consumers = getRecipeConsumers(itemId)
126
+ let statSourceCount = 0
127
+ let sinkOnlyCount = 0
128
+ for (const c of consumers) {
129
+ if (c.statFlows.length > 0) statSourceCount++
130
+ else sinkOnlyCount++
131
+ }
132
+ rows.push({
133
+ itemId,
134
+ consumerCount: consumers.length,
135
+ statSourceCount,
136
+ sinkOnlyCount,
137
+ consumers: consumers.map((c) => c.outputItemId),
138
+ })
139
+ }
140
+ return rows.sort((a, b) => a.consumerCount - b.consumerCount || a.itemId - b.itemId)
141
+ }
@@ -15,8 +15,8 @@ export const DEPTH_THRESHOLD_T10 = 63000
15
15
  export const LOCATION_MIN_DEPTH = 500
16
16
  export const LOCATION_MAX_DEPTH = 65535
17
17
 
18
- export const YIELD_FRACTION_SHALLOW = 0.0025
19
- export const YIELD_FRACTION_DEEP = 0.0005
18
+ export const YIELD_FRACTION_SHALLOW = 0.005
19
+ export const YIELD_FRACTION_DEEP = 0.001
20
20
 
21
21
  export function yieldThresholdAt(stratum: number): number {
22
22
  const clamped = stratum > 65535 ? 65535 : stratum
@@ -3,7 +3,7 @@ import {hash512} from '../utils/hash'
3
3
  import {Coordinates, type CoordinatesType} from '../types'
4
4
  import {getItem} from '../data/catalog'
5
5
  import {getEligibleResources, getResourceWeight, yieldThresholdAt} from './resources'
6
- import {RESERVE_TIERS, rollTier, rollWithinTier} from './tiers'
6
+ import {RESERVE_TIERS, rollTier, rollWithinTier, applyResourceTierMultiplier} from './tiers'
7
7
 
8
8
  export interface StratumInfo {
9
9
  itemId: number
@@ -63,8 +63,9 @@ export function deriveStratum(
63
63
  const tierRoll = ((bytes[18] << 8) | bytes[19]) >>> 0
64
64
  const withinRoll = ((bytes[20] << 8) | bytes[21]) >>> 0
65
65
  const tier = rollTier(tierRoll, stratum)
66
- const unitMass = getItem(selectedItemId).mass
67
- const reserve = rollWithinTier(withinRoll, RESERVE_TIERS[tier], unitMass)
66
+ const selected = getItem(selectedItemId)
67
+ const baseReserve = rollWithinTier(withinRoll, RESERVE_TIERS[tier], selected.mass)
68
+ const reserve = applyResourceTierMultiplier(baseReserve, selected.tier)
68
69
 
69
70
  const seedBigInt =
70
71
  (BigInt(bytes[8]) << 56n) |
@@ -60,14 +60,26 @@ export function rollWithinTier(
60
60
  return Math.max(1, Math.floor(depositMass / resourceUnitMass))
61
61
  }
62
62
 
63
+ // Must mirror the contract tier-multiplier table in tiers.hpp byte-for-byte; values are in tenths (T1..T10).
64
+ export const RESOURCE_TIER_MULT_TENTHS = [200, 154, 118, 91, 70, 54, 41, 32, 24, 19] as const
65
+
66
+ export function applyResourceTierMultiplier(units: number, resourceTier: number): number {
67
+ const idx = resourceTier < 1 ? 0 : resourceTier > 10 ? 9 : resourceTier - 1
68
+ const scaled = Math.floor((units * RESOURCE_TIER_MULT_TENTHS[idx]) / 10)
69
+ return scaled > 0 ? scaled : 1
70
+ }
71
+
63
72
  const RESERVE_TIER_ENTRIES = Object.entries(RESERVE_TIERS) as Array<[ReserveTier, TierRange]>
64
73
 
65
74
  export function tierOfReserve(reserve: number, itemId: number): ReserveTier | null {
66
75
  if (reserve <= 0) return null
67
- const unitMass = getItem(itemId).mass
68
- if (unitMass <= 0) return null
69
- const impliedMassLow = reserve * unitMass
70
- const impliedMassHigh = impliedMassLow + unitMass
76
+ const item = getItem(itemId)
77
+ if (item.mass <= 0) return null
78
+ // Reverse the resource-tier multiplier so bands read relative to the resource tier.
79
+ const idx = item.tier < 1 ? 0 : item.tier > 10 ? 9 : item.tier - 1
80
+ const baseReserve = (reserve * 10) / RESOURCE_TIER_MULT_TENTHS[idx]
81
+ const impliedMassLow = baseReserve * item.mass
82
+ const impliedMassHigh = impliedMassLow + item.mass
71
83
  for (const [tier, range] of RESERVE_TIER_ENTRIES) {
72
84
  if (impliedMassHigh > range.min && impliedMassLow <= range.max) return tier
73
85
  }
@@ -103,6 +103,27 @@ export function wormholeAt(
103
103
  if (!w || w.A.x !== x || w.A.y !== y) return null
104
104
  return w.B
105
105
  }
106
+
107
+ // Wormhole mouths (the local A endpoint) within reachTiles of (x,y); regions are RSIZE-wide so only a few overlap.
108
+ export function nearbyWormholes(
109
+ seed: Checksum256Type,
110
+ x: number,
111
+ y: number,
112
+ reachTiles: number
113
+ ): {x: number; y: number}[] {
114
+ const min = regionOf(x - reachTiles, y - reachTiles)
115
+ const max = regionOf(x + reachTiles, y + reachTiles)
116
+ const out: {x: number; y: number}[] = []
117
+ for (let rx = min.rx; rx <= max.rx; rx++) {
118
+ for (let ry = min.ry; ry <= max.ry; ry++) {
119
+ const w = wormholeOfRegion(seed, {rx, ry})
120
+ if (!w) continue
121
+ if (w.A.x === x && w.A.y === y) continue
122
+ if (dist({x, y}, w.A) <= reachTiles) out.push(w.A)
123
+ }
124
+ }
125
+ return out
126
+ }
106
127
  export function isValidWormholePair(
107
128
  seed: Checksum256Type,
108
129
  ax: number,
@@ -132,7 +132,15 @@ export {
132
132
 
133
133
  export type {StratumInfo, ResourceStats, DerivedStratum} from './derivation'
134
134
 
135
- export {RESERVE_TIERS, TIER_ROLL_MAX, tierOfReserve, rollTier, rollWithinTier} from './derivation'
135
+ export {
136
+ RESERVE_TIERS,
137
+ TIER_ROLL_MAX,
138
+ tierOfReserve,
139
+ rollTier,
140
+ rollWithinTier,
141
+ RESOURCE_TIER_MULT_TENTHS,
142
+ applyResourceTierMultiplier,
143
+ } from './derivation'
136
144
  export type {ReserveTier, TierRange} from './derivation'
137
145
 
138
146
  export {getEffectiveReserve} from './derivation'
@@ -348,6 +356,13 @@ export {
348
356
  } from './derivation/capability-mappings'
349
357
  export {SLOT_FORMULAS} from './data/capability-formulas'
350
358
  export type {SlotConsumer, SlotConsumerKind} from './data/capability-formulas'
359
+ export {
360
+ getAllRecipes,
361
+ getRecipeConsumers,
362
+ getComponentDemand,
363
+ getResourceDemand,
364
+ } from './derivation/recipe-usage'
365
+ export type {StatFlow, RecipeConsumer, DemandRow, ResourceDemand} from './derivation/recipe-usage'
351
366
 
352
367
  export {
353
368
  encodeStats,
@@ -1,5 +1,6 @@
1
1
  import {distanceBetweenPoints, findNearbyPlanets} from './travel'
2
2
  import {hasSystem} from '../utils/system'
3
+ import {nearbyWormholes, wormholeAt} from '../derivation/wormhole'
3
4
  import {PRECISION} from '../types'
4
5
  import {Checksum256, type Checksum256Type} from '@wharfkit/antelope'
5
6
 
@@ -170,14 +171,26 @@ function reconstruct(cameFrom: Map<string, Coord>, origin: Coord, dest: Coord):
170
171
 
171
172
  export function sdkSystemGraph(seed: Checksum256Type): SystemGraph {
172
173
  const s = Checksum256.from(seed)
174
+ // Travelable nodes mirror the contract's is_travelable: systems plus wormhole mouths.
173
175
  return {
174
- hasSystem: (c) => hasSystem(s, {x: c.x, y: c.y}),
175
- nearby: (c, reachTiles) =>
176
- findNearbyPlanets(s, {x: c.x, y: c.y}, reachTiles * PRECISION)
177
- .map((d) => ({
178
- coord: {x: Number(d.destination.x), y: Number(d.destination.y)},
179
- dist: Number(d.distance) / PRECISION,
180
- }))
181
- .filter((n) => !(n.coord.x === c.x && n.coord.y === c.y)),
176
+ hasSystem: (c) => hasSystem(s, {x: c.x, y: c.y}) || wormholeAt(s, c.x, c.y) !== null,
177
+ nearby: (c, reachTiles) => {
178
+ const seen = new Set<string>([`${c.x},${c.y}`])
179
+ const out: Neighbor[] = []
180
+ for (const d of findNearbyPlanets(s, {x: c.x, y: c.y}, reachTiles * PRECISION)) {
181
+ const coord = {x: Number(d.destination.x), y: Number(d.destination.y)}
182
+ const k = `${coord.x},${coord.y}`
183
+ if (seen.has(k)) continue
184
+ seen.add(k)
185
+ out.push({coord, dist: Number(d.distance) / PRECISION})
186
+ }
187
+ for (const coord of nearbyWormholes(s, c.x, c.y, reachTiles)) {
188
+ const k = `${coord.x},${coord.y}`
189
+ if (seen.has(k)) continue
190
+ seen.add(k)
191
+ out.push({coord, dist: Math.hypot(coord.x - c.x, coord.y - c.y)})
192
+ }
193
+ return out
194
+ },
182
195
  }
183
196
  }