@shipload/sdk 1.0.0-next.62 → 1.0.0-next.64

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 (62) hide show
  1. package/lib/shipload.d.ts +1829 -501
  2. package/lib/shipload.js +4325 -1324
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +4286 -1389
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +391 -13
  7. package/lib/testing.js +1692 -107
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +1693 -108
  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/token.ts +170 -0
  16. package/src/contracts.ts +2 -0
  17. package/src/data/catalog.test.ts +19 -0
  18. package/src/data/catalog.ts +6 -0
  19. package/src/data/charters.json +198 -0
  20. package/src/data/citizenry-adjectives.json +42 -0
  21. package/src/data/citizenry-nouns.json +50 -0
  22. package/src/data/entities.json +36 -0
  23. package/src/data/item-ids.ts +2 -0
  24. package/src/data/items.json +12 -0
  25. package/src/data/kind-registry.json +20 -3
  26. package/src/data/kind-registry.ts +5 -0
  27. package/src/data/metadata.ts +14 -0
  28. package/src/data/recipes.json +0 -48
  29. package/src/derivation/capabilities.ts +14 -0
  30. package/src/derivation/recipe-usage.test.ts +0 -2
  31. package/src/format.ts +9 -0
  32. package/src/index-module.ts +32 -2
  33. package/src/influence/categories.ts +32 -0
  34. package/src/influence/charters.test.ts +126 -0
  35. package/src/influence/charters.ts +120 -0
  36. package/src/influence/citizenry.ts +38 -0
  37. package/src/influence/constants.ts +63 -0
  38. package/src/influence/decay.ts +20 -0
  39. package/src/influence/decomp.ts +95 -0
  40. package/src/influence/demand.ts +106 -0
  41. package/src/influence/duration.ts +36 -0
  42. package/src/influence/index.ts +63 -0
  43. package/src/influence/influence.test.ts +317 -0
  44. package/src/influence/quality.ts +25 -0
  45. package/src/influence/valuation.ts +154 -0
  46. package/src/managers/actions.ts +227 -5
  47. package/src/managers/balances.test.ts +161 -0
  48. package/src/managers/balances.ts +93 -0
  49. package/src/managers/construction.ts +11 -1
  50. package/src/managers/context.ts +18 -0
  51. package/src/managers/entities.ts +11 -0
  52. package/src/managers/index.ts +15 -1
  53. package/src/managers/influence.test.ts +58 -0
  54. package/src/managers/influence.ts +347 -0
  55. package/src/managers/nft.test.ts +8 -1
  56. package/src/managers/nft.ts +56 -16
  57. package/src/nft/description.ts +7 -0
  58. package/src/scheduling/task-cargo.ts +1 -0
  59. package/src/shipload.ts +10 -0
  60. package/src/types.ts +23 -4
  61. package/src/utils/hash.ts +4 -0
  62. package/src/utils/system.ts +1 -5
@@ -0,0 +1,95 @@
1
+ import {tryGetItem} from '../data/catalog'
2
+ import {getRecipe} from '../data/recipes-runtime'
3
+ import recipes from '../data/recipes.json'
4
+ import {
5
+ DECOMP_MAX_BUCKETS,
6
+ DECOMP_MAX_DEPTH,
7
+ DECOMP_PROCESSED_KG_MAX,
8
+ DECOMP_RAW_KG_MAX,
9
+ } from './constants'
10
+ import {categoryIndex} from './categories'
11
+
12
+ export interface DecompBucket {
13
+ category: number
14
+ tier: number
15
+ rawKg: number
16
+ }
17
+
18
+ export interface DecompEntry {
19
+ itemId: number
20
+ processedKg: number
21
+ buckets: DecompBucket[]
22
+ }
23
+
24
+ interface Accumulator {
25
+ buckets: DecompBucket[]
26
+ processedKg: number
27
+ overflow: boolean
28
+ }
29
+
30
+ function add(acc: Accumulator, category: number, tier: number, kg: number): void {
31
+ const existing = acc.buckets.find((b) => b.category === category && b.tier === tier)
32
+ if (existing) {
33
+ existing.rawKg += kg
34
+ return
35
+ }
36
+ if (acc.buckets.length >= DECOMP_MAX_BUCKETS) {
37
+ acc.overflow = true
38
+ return
39
+ }
40
+ acc.buckets.push({category, tier, rawKg: kg})
41
+ }
42
+
43
+ function walk(itemId: number, multiplier: number, acc: Accumulator, depth: number): void {
44
+ if (depth > DECOMP_MAX_DEPTH) {
45
+ acc.overflow = true
46
+ return
47
+ }
48
+ const recipe = getRecipe(itemId)
49
+ if (!recipe) return
50
+
51
+ for (const input of recipe.inputs) {
52
+ const def = tryGetItem(input.itemId)
53
+ const units = multiplier * input.quantity
54
+ const kg = units * (def ? def.mass : 0)
55
+ acc.processedKg += kg
56
+ if (getRecipe(input.itemId)) {
57
+ walk(input.itemId, units, acc, depth + 1)
58
+ } else if (def?.type !== 'resource' || def.category === undefined) {
59
+ acc.overflow = true
60
+ } else {
61
+ add(acc, categoryIndex(def.category), def.tier, kg)
62
+ }
63
+ }
64
+ }
65
+
66
+ function buildRegistry(): DecompEntry[] {
67
+ const out: DecompEntry[] = []
68
+ for (const recipe of recipes as {outputItemId: number}[]) {
69
+ const acc: Accumulator = {buckets: [], processedKg: 0, overflow: false}
70
+ walk(recipe.outputItemId, 1, acc, 0)
71
+ acc.buckets.sort((a, b) => a.category - b.category || a.tier - b.tier)
72
+
73
+ const rawTotal = acc.buckets.reduce((sum, b) => sum + b.rawKg, 0)
74
+ if (rawTotal > DECOMP_RAW_KG_MAX || acc.processedKg > DECOMP_PROCESSED_KG_MAX) {
75
+ acc.overflow = true
76
+ }
77
+
78
+ out.push({
79
+ itemId: recipe.outputItemId,
80
+ processedKg: acc.overflow ? 0 : acc.processedKg,
81
+ buckets: acc.buckets,
82
+ })
83
+ }
84
+ out.sort((a, b) => a.itemId - b.itemId)
85
+ return out
86
+ }
87
+
88
+ export const DECOMP_REGISTRY: DecompEntry[] = buildRegistry()
89
+
90
+ const byItemId = new Map<number, DecompEntry>()
91
+ for (const entry of DECOMP_REGISTRY) byItemId.set(entry.itemId, entry)
92
+
93
+ export function findDecomp(itemId: number): DecompEntry | undefined {
94
+ return byItemId.get(itemId)
95
+ }
@@ -0,0 +1,106 @@
1
+ import {Checksum256, type Checksum256Type, type Checksum512} from '@wharfkit/antelope'
2
+ import {getLocationProfile} from '../derivation/resources'
3
+ import {Coordinates, type CoordinatesType} from '../types'
4
+ import {hash512} from '../utils/hash'
5
+ import {deriveLocationStatic} from '../utils/system'
6
+ import {RESOURCE_CATEGORY_COUNT} from './constants'
7
+
8
+ export interface DemandTriple {
9
+ peak: bigint
10
+ base: bigint
11
+ floor: bigint
12
+ }
13
+
14
+ export interface DemandView {
15
+ abundantMask: number
16
+ lackingMask: number
17
+ acute: number
18
+ needFp: bigint[]
19
+ }
20
+
21
+ const ALL_CATEGORIES_MASK = 0x1f
22
+
23
+ export function abundantMaskFor(locationType: number, subtype: number): number {
24
+ let mask = 0
25
+ for (const entry of getLocationProfile(locationType, subtype)) {
26
+ mask |= 1 << entry.category
27
+ }
28
+ return mask
29
+ }
30
+
31
+ export function lackingMaskFrom(abundantMask: number): number {
32
+ return ~abundantMask & ALL_CATEGORIES_MASK
33
+ }
34
+
35
+ export function popcount5(mask: number): number {
36
+ let n = 0
37
+ for (let i = 0; i < RESOURCE_CATEGORY_COUNT; i++) if (mask & (1 << i)) n++
38
+ return n
39
+ }
40
+
41
+ export function pickAcuteCategory(lackingMask: number, roll: number): number {
42
+ const count = popcount5(lackingMask)
43
+ if (count === 0) throw new Error('location has no lacking category')
44
+ let index = roll % count
45
+ for (let c = 0; c < RESOURCE_CATEGORY_COUNT; c++) {
46
+ if ((lackingMask & (1 << c)) === 0) continue
47
+ if (index === 0) return c
48
+ index--
49
+ }
50
+ throw new Error('acute pick fell through')
51
+ }
52
+
53
+ export function demandRoll(hash: Checksum512): number {
54
+ const bytes = hash.array
55
+ return ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0
56
+ }
57
+
58
+ export function buildDemand(abundantMask: number, acute: number, triple: DemandTriple): DemandView {
59
+ const lackingMask = lackingMaskFrom(abundantMask)
60
+ const needFp: bigint[] = []
61
+ for (let c = 0; c < RESOURCE_CATEGORY_COUNT; c++) {
62
+ if (c === acute) needFp.push(triple.peak)
63
+ else if (lackingMask & (1 << c)) needFp.push(triple.base)
64
+ else needFp.push(triple.floor)
65
+ }
66
+ return {abundantMask, lackingMask, acute, needFp}
67
+ }
68
+
69
+ export function needForCategory(demand: DemandView, category: number): bigint {
70
+ if (category < 0 || category >= RESOURCE_CATEGORY_COUNT) {
71
+ throw new Error('invalid category')
72
+ }
73
+ return demand.needFp[category]
74
+ }
75
+
76
+ // Two distinct seeds: composition from the genesis game seed, acute rotation from the epoch seed.
77
+ export function deriveDemand(args: {
78
+ gameSeed: Checksum256Type
79
+ epochSeed: Checksum256Type
80
+ coordinates: CoordinatesType
81
+ triple: DemandTriple
82
+ }): DemandView {
83
+ const coords = Coordinates.from(args.coordinates)
84
+ const location = deriveLocationStatic(args.gameSeed, coords)
85
+ const abundant = abundantMaskFor(Number(location.type), Number(location.subtype))
86
+ if (abundant === 0) throw new Error('location has no composition')
87
+
88
+ const hash = hash512(
89
+ Checksum256.from(args.epochSeed),
90
+ `demand-${coords.x.toString()}-${coords.y.toString()}`
91
+ )
92
+ const acute = pickAcuteCategory(lackingMaskFrom(abundant), demandRoll(hash))
93
+ return buildDemand(abundant, acute, args.triple)
94
+ }
95
+
96
+ export function isAbundant(demand: DemandView, category: number): boolean {
97
+ return (demand.abundantMask & (1 << category)) !== 0
98
+ }
99
+
100
+ export function isAcute(demand: DemandView, category: number): boolean {
101
+ return demand.acute === category
102
+ }
103
+
104
+ export function needMultiplier(demand: DemandView, category: number): number {
105
+ return Number(needForCategory(demand, category)) / 10_000
106
+ }
@@ -0,0 +1,36 @@
1
+ import {computeLoaderMass, computeLoaderThrust} from '../nft/description'
2
+ import {PRECISION} from '../types'
3
+ import {CIVIC_LOADER_STAT, CIVIC_LOADER_TIER, DEFAULT_ORBITAL_Z} from './constants'
4
+
5
+ export interface CivicLoaderStats {
6
+ thrust: number
7
+ mass: number
8
+ }
9
+
10
+ export function civicLoader(): CivicLoaderStats {
11
+ return {
12
+ thrust: Math.min(computeLoaderThrust(CIVIC_LOADER_STAT, CIVIC_LOADER_TIER), 65_535),
13
+ mass: computeLoaderMass(CIVIC_LOADER_STAT),
14
+ }
15
+ }
16
+
17
+ function acceleration(thrust: number, mass: number): number {
18
+ if (mass <= 0) return 0
19
+ return (thrust / mass) * PRECISION
20
+ }
21
+
22
+ function flightTime(distance: number, accel: number): number {
23
+ if (accel <= 0 || distance <= 0) return 0
24
+ return Math.floor(2 * Math.sqrt(distance / accel))
25
+ }
26
+
27
+ export function contributeDuration(totalMassKg: number, altitudeZ = 0): number {
28
+ const loader = civicLoader()
29
+ const totalMass = totalMassKg + loader.mass
30
+ const z = Math.max(altitudeZ, DEFAULT_ORBITAL_Z)
31
+ return flightTime(z, acceleration(loader.thrust, totalMass))
32
+ }
33
+
34
+ export function contributeDurationForTonnes(tonnes: number, altitudeZ = 0): number {
35
+ return contributeDuration(Math.floor(tonnes * 1000), altitudeZ)
36
+ }
@@ -0,0 +1,63 @@
1
+ export * from './constants'
2
+ export * from './categories'
3
+ export {decayActive, normalizeActive, type DecayableStanding} from './decay'
4
+ export {getStatCount, statsSumSq, qualityFactor} from './quality'
5
+ export {
6
+ DECOMP_REGISTRY,
7
+ findDecomp,
8
+ type DecompBucket,
9
+ type DecompEntry,
10
+ } from './decomp'
11
+ export {
12
+ abundantMaskFor,
13
+ buildDemand,
14
+ demandRoll,
15
+ deriveDemand,
16
+ isAbundant,
17
+ isAcute,
18
+ lackingMaskFrom,
19
+ needForCategory,
20
+ needMultiplier,
21
+ pickAcuteCategory,
22
+ popcount5,
23
+ type DemandTriple,
24
+ type DemandView,
25
+ } from './demand'
26
+ export {
27
+ componentBaseAtomic,
28
+ componentEffortAtomic,
29
+ pricingFromWeights,
30
+ resourceValueAtomic,
31
+ valueCargoItem,
32
+ valueContribution,
33
+ weightedQualityAtomic,
34
+ type InfluencePricing,
35
+ type ValuedItem,
36
+ } from './valuation'
37
+ export {
38
+ civicLoader,
39
+ contributeDuration,
40
+ contributeDurationForTonnes,
41
+ type CivicLoaderStats,
42
+ } from './duration'
43
+ export {citizenryName, citizenryPatternCount} from './citizenry'
44
+ export {
45
+ charterEffectTargetEntity,
46
+ charterEffectTargetPresent,
47
+ charterEligible,
48
+ charterIneligible,
49
+ charterNode,
50
+ charterPrereqsMet,
51
+ charterSingletonMandate,
52
+ charterSpawnNodeFor,
53
+ CHARTER_INELIGIBILITY_MESSAGES,
54
+ CHARTER_REGISTRY,
55
+ effectiveMandate,
56
+ eligibleCharters,
57
+ type BuiltCharter,
58
+ type CharterEffect,
59
+ type CharterIneligibility,
60
+ type CharterNode,
61
+ type CharterWorld,
62
+ type ChosenCharter,
63
+ } from './charters'
@@ -0,0 +1,317 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {Checksum256} from '@wharfkit/antelope'
3
+ import {ITEM_ORE_T1, ITEM_PLATE, ITEM_CRYSTAL_T1} from '../data/item-ids'
4
+ import {categoryIndex} from './categories'
5
+ import {
6
+ CHARTER_WORKSHOP_COST,
7
+ DEMAND_TRIPLE_SEED,
8
+ INFLUENCE_ATOMIC_PER_POINT,
9
+ INFLUENCE_D1_SEED,
10
+ NEED_FP_SCALE,
11
+ } from './constants'
12
+ import {decayActive, normalizeActive} from './decay'
13
+ import {
14
+ buildDemand,
15
+ deriveDemand,
16
+ lackingMaskFrom,
17
+ needForCategory,
18
+ pickAcuteCategory,
19
+ popcount5,
20
+ } from './demand'
21
+ import {findDecomp, DECOMP_REGISTRY} from './decomp'
22
+ import {contributeDuration} from './duration'
23
+ import {getStatCount, statsSumSq} from './quality'
24
+ import {pricingFromWeights, valueCargoItem, valueContribution} from './valuation'
25
+ import {citizenryName, citizenryPatternCount} from './citizenry'
26
+ import citizenryAdjectives from '../data/citizenry-adjectives.json'
27
+ import citizenryNouns from '../data/citizenry-nouns.json'
28
+
29
+ const PAR = 213
30
+ const WORLD = {x: 56, y: 0}
31
+ const packStats = (a: number, b = a, c = a): bigint =>
32
+ BigInt(a) | (BigInt(b) << 10n) | (BigInt(c) << 20n)
33
+
34
+ const SEED_WEIGHTS_FP = [10000, 10510, 14289, 21964, 32326, 42658, 52913, 61128, 68570, 75380]
35
+
36
+ const pricing = pricingFromWeights(
37
+ INFLUENCE_D1_SEED,
38
+ Array.from({length: 5}, (_, category) =>
39
+ SEED_WEIGHTS_FP.map((weightFp, i) => ({category, tier: i + 1, weightFp}))
40
+ ).flat()
41
+ )
42
+
43
+ const baseDemand = buildDemand(0, 255, DEMAND_TRIPLE_SEED)
44
+
45
+ describe('decay', () => {
46
+ test('zero epochs is the identity', () => {
47
+ expect(decayActive(1_000_000n, 0)).toBe(1_000_000n)
48
+ })
49
+
50
+ test('one epoch retains 19/20 with a floor', () => {
51
+ expect(decayActive(1_000_000n, 1)).toBe(950_000n)
52
+ expect(decayActive(19n, 1)).toBe(18n)
53
+ })
54
+
55
+ test('is path independent', () => {
56
+ const direct = decayActive(123_456_789n, 7)
57
+ const staged = decayActive(decayActive(123_456_789n, 3), 4)
58
+ expect(staged).toBe(direct)
59
+ })
60
+
61
+ test('early-exits at zero and clamps the iteration count', () => {
62
+ expect(decayActive(0n, 5_000)).toBe(0n)
63
+ expect(decayActive(1_000_000n, 100_000)).toBe(decayActive(1_000_000n, 1_024))
64
+ })
65
+
66
+ test('normalizeActive never decays backwards', () => {
67
+ expect(normalizeActive({active: 1_000n, lastUpdateEpoch: 9}, 4)).toBe(1_000n)
68
+ })
69
+ })
70
+
71
+ describe('quality', () => {
72
+ test('par stats give exactly the quality divisor', () => {
73
+ expect(statsSumSq(packStats(PAR), 3)).toBe(3 * INFLUENCE_D1_SEED)
74
+ })
75
+
76
+ test('the retired Workshop falls back to three stats', () => {
77
+ expect(getStatCount(10208)).toBe(3)
78
+ })
79
+
80
+ test('a crafted component reports its recipe slot count', () => {
81
+ expect(getStatCount(ITEM_PLATE)).toBe(3)
82
+ })
83
+ })
84
+
85
+ describe('demand', () => {
86
+ test('the lacking mask is the complement over five categories', () => {
87
+ expect(lackingMaskFrom(0b00101)).toBe(0b11010)
88
+ expect(popcount5(0b11010)).toBe(3)
89
+ })
90
+
91
+ test('the acute pick indexes the lacking set only', () => {
92
+ const lacking = 0b10010
93
+ expect(pickAcuteCategory(lacking, 0)).toBe(1)
94
+ expect(pickAcuteCategory(lacking, 1)).toBe(4)
95
+ expect(pickAcuteCategory(lacking, 2)).toBe(1)
96
+ })
97
+
98
+ test('the triple assigns peak, base and floor', () => {
99
+ const view = buildDemand(0b00001, 1, DEMAND_TRIPLE_SEED)
100
+ expect(needForCategory(view, 0)).toBe(DEMAND_TRIPLE_SEED.floor)
101
+ expect(needForCategory(view, 1)).toBe(DEMAND_TRIPLE_SEED.peak)
102
+ expect(needForCategory(view, 2)).toBe(DEMAND_TRIPLE_SEED.base)
103
+ })
104
+
105
+ test('the acute pick rotates with the epoch seed, not the game seed', () => {
106
+ const gameSeed = Checksum256.hash(Buffer.from('game'))
107
+ const coordinates = WORLD
108
+ const picks = new Set<number>()
109
+ for (let i = 0; i < 24; i++) {
110
+ const epochSeed = Checksum256.hash(Buffer.from(`epoch-${i}`))
111
+ picks.add(
112
+ deriveDemand({gameSeed, epochSeed, coordinates, triple: DEMAND_TRIPLE_SEED}).acute
113
+ )
114
+ }
115
+ expect(picks.size).toBeGreaterThan(1)
116
+ })
117
+
118
+ test('the composition partition is stable across epochs', () => {
119
+ const gameSeed = Checksum256.hash(Buffer.from('game'))
120
+ const coordinates = WORLD
121
+ const masks = new Set<number>()
122
+ for (let i = 0; i < 8; i++) {
123
+ const epochSeed = Checksum256.hash(Buffer.from(`epoch-${i}`))
124
+ masks.add(
125
+ deriveDemand({gameSeed, epochSeed, coordinates, triple: DEMAND_TRIPLE_SEED})
126
+ .abundantMask
127
+ )
128
+ }
129
+ expect(masks.size).toBe(1)
130
+ })
131
+ })
132
+
133
+ describe('decomposition', () => {
134
+ test('the registry is sorted by item id and covers every recipe', () => {
135
+ for (let i = 1; i < DECOMP_REGISTRY.length; i++) {
136
+ expect(DECOMP_REGISTRY[i].itemId).toBeGreaterThan(DECOMP_REGISTRY[i - 1].itemId)
137
+ }
138
+ expect(DECOMP_REGISTRY.length).toBe(57)
139
+ })
140
+
141
+ test('the retired Workshop has no decomposition entry', () => {
142
+ expect(findDecomp(10208)).toBeUndefined()
143
+ })
144
+
145
+ test('buckets are sorted and hold only raw resources', () => {
146
+ const entry = findDecomp(ITEM_PLATE)
147
+ if (!entry) throw new Error('Plate has no decomposition entry')
148
+ expect(entry.processedKg).toBeGreaterThan(0)
149
+ for (let i = 1; i < entry.buckets.length; i++) {
150
+ const prev = entry.buckets[i - 1]
151
+ const cur = entry.buckets[i]
152
+ expect(prev.category < cur.category || prev.tier < cur.tier).toBe(true)
153
+ }
154
+ })
155
+ })
156
+
157
+ describe('valuation', () => {
158
+ test('a tonne of par T1 ore at base demand is worth exactly one point', () => {
159
+ const value = valueCargoItem(
160
+ {itemId: ITEM_ORE_T1, quantity: 1, stats: packStats(PAR)},
161
+ baseDemand,
162
+ pricing
163
+ )
164
+ expect(value).toBe(BigInt(INFLUENCE_ATOMIC_PER_POINT))
165
+ })
166
+
167
+ test('the acute multiplier doubles a resource', () => {
168
+ const acute = buildDemand(0, categoryIndex('crystal'), DEMAND_TRIPLE_SEED)
169
+ const item = {itemId: ITEM_CRYSTAL_T1, quantity: 3, stats: packStats(PAR)}
170
+ expect(valueCargoItem(item, acute, pricing)).toBe(
171
+ valueCargoItem(item, baseDemand, pricing) * 2n
172
+ )
173
+ })
174
+
175
+ test('resource valuation is split invariant', () => {
176
+ const one = valueCargoItem(
177
+ {itemId: ITEM_ORE_T1, quantity: 1, stats: packStats(97)},
178
+ baseDemand,
179
+ pricing
180
+ )
181
+ const many = valueCargoItem(
182
+ {itemId: ITEM_ORE_T1, quantity: 137, stats: packStats(97)},
183
+ baseDemand,
184
+ pricing
185
+ )
186
+ expect(many).toBe(one * 137n)
187
+ })
188
+
189
+ test('component valuation is split invariant', () => {
190
+ const one = valueCargoItem(
191
+ {itemId: ITEM_PLATE, quantity: 1, stats: packStats(PAR)},
192
+ baseDemand,
193
+ pricing
194
+ )
195
+ const many = valueCargoItem(
196
+ {itemId: ITEM_PLATE, quantity: 40, stats: packStats(PAR)},
197
+ baseDemand,
198
+ pricing
199
+ )
200
+ expect(many).toBe(one * 40n)
201
+ })
202
+
203
+ test('components ignore demand entirely', () => {
204
+ const acute = buildDemand(0, categoryIndex('ore'), DEMAND_TRIPLE_SEED)
205
+ const item = {itemId: ITEM_PLATE, quantity: 5, stats: packStats(PAR)}
206
+ expect(valueCargoItem(item, acute, pricing)).toBe(valueCargoItem(item, baseDemand, pricing))
207
+ })
208
+
209
+ test('quality scales quadratically within the per-unit floor tolerance', () => {
210
+ const single = valueCargoItem(
211
+ {itemId: ITEM_ORE_T1, quantity: 100, stats: packStats(100)},
212
+ baseDemand,
213
+ pricing
214
+ )
215
+ const doubled = valueCargoItem(
216
+ {itemId: ITEM_ORE_T1, quantity: 100, stats: packStats(200)},
217
+ baseDemand,
218
+ pricing
219
+ )
220
+ const delta = doubled - single * 4n
221
+ expect(delta >= -105n && delta <= 105n).toBe(true)
222
+ })
223
+
224
+ test('a bundle totals its rows', () => {
225
+ const bundle = [
226
+ {itemId: ITEM_ORE_T1, quantity: 10, stats: packStats(PAR)},
227
+ {itemId: ITEM_PLATE, quantity: 2, stats: packStats(PAR)},
228
+ ]
229
+ expect(valueContribution(bundle, baseDemand, pricing)).toBe(
230
+ valueCargoItem(bundle[0], baseDemand, pricing) +
231
+ valueCargoItem(bundle[1], baseDemand, pricing)
232
+ )
233
+ })
234
+
235
+ test('a packed entity cannot be valued', () => {
236
+ expect(() =>
237
+ valueCargoItem({itemId: 10208, quantity: 1, stats: packStats(PAR)}, baseDemand, pricing)
238
+ ).toThrow()
239
+ })
240
+
241
+ test('the Workshop charter costs 200,000 points of hauled par T1 material', () => {
242
+ const perTonne = valueCargoItem(
243
+ {itemId: ITEM_ORE_T1, quantity: 200_000, stats: packStats(PAR)},
244
+ baseDemand,
245
+ pricing
246
+ )
247
+ expect(perTonne).toBe(CHARTER_WORKSHOP_COST)
248
+ })
249
+ })
250
+
251
+ describe('contribution duration', () => {
252
+ test('grows with mass and never falls below a single tick', () => {
253
+ expect(contributeDuration(1_000)).toBeGreaterThan(0)
254
+ expect(contributeDuration(1_000_000)).toBeGreaterThan(contributeDuration(1_000))
255
+ })
256
+
257
+ test('splitting a haul costs more than one trip', () => {
258
+ const single = contributeDuration(100_000_000)
259
+ const hundred = contributeDuration(1_000_000) * 100
260
+ expect(hundred).toBeGreaterThan(single)
261
+ })
262
+
263
+ test('ground level is floored at the base orbital climb', () => {
264
+ expect(contributeDuration(1_000, 0)).toBe(contributeDuration(1_000, 800))
265
+ })
266
+ })
267
+
268
+ describe('citizenry names', () => {
269
+ const gameSeed = Checksum256.hash(Buffer.from('game'))
270
+
271
+ test('is deterministic for a coordinate', () => {
272
+ const a = citizenryName(gameSeed, WORLD)
273
+ const b = citizenryName(gameSeed, WORLD)
274
+ expect(a).toBeDefined()
275
+ expect(a).toBe(b as string)
276
+ })
277
+
278
+ test('composes the world name with a known noun', () => {
279
+ const name = citizenryName(gameSeed, WORLD) ?? ''
280
+ expect(name.length).toBeGreaterThan(0)
281
+ expect(citizenryNouns.some((noun) => name.includes(noun))).toBe(true)
282
+ })
283
+
284
+ test('spreads across nouns and forms', () => {
285
+ const names: string[] = []
286
+ for (let x = 0; x < 160 && names.length < 200; x++) {
287
+ for (let y = 0; y < 160 && names.length < 200; y++) {
288
+ const name = citizenryName(gameSeed, {x, y})
289
+ if (name) names.push(name)
290
+ }
291
+ }
292
+ expect(names.length).toBeGreaterThan(100)
293
+
294
+ const nouns = new Set(names.map((n) => citizenryNouns.find((noun) => n.includes(noun))))
295
+ expect(nouns.size).toBeGreaterThan(20)
296
+
297
+ const ofForm = names.filter((n) => n.includes(' of ')).length
298
+ expect(ofForm).toBeGreaterThan(0)
299
+ expect(ofForm).toBeLessThan(names.length * 0.6)
300
+ })
301
+
302
+ test('counts every noun and adjective combination', () => {
303
+ expect(citizenryPatternCount()).toBe(
304
+ citizenryNouns.length * (1 + 2 * citizenryAdjectives.length)
305
+ )
306
+ })
307
+
308
+ test('is undefined away from a world', () => {
309
+ expect(citizenryName(gameSeed, {x: 7, y: 11})).toBeUndefined()
310
+ })
311
+ })
312
+
313
+ describe('scales', () => {
314
+ test('the need scale divides out against the atomic unit', () => {
315
+ expect(NEED_FP_SCALE % INFLUENCE_ATOMIC_PER_POINT).toBe(0)
316
+ })
317
+ })
@@ -0,0 +1,25 @@
1
+ import {getRecipe} from '../data/recipes-runtime'
2
+ import {decodeStat} from '../derivation/crafting'
3
+
4
+ const CRAFTED_ITEM_ID_FLOOR = 10_000
5
+ const UNCRAFTED_STAT_COUNT = 3
6
+
7
+ export function getStatCount(itemId: number): number {
8
+ if (itemId < CRAFTED_ITEM_ID_FLOOR) return UNCRAFTED_STAT_COUNT
9
+ const recipe = getRecipe(itemId)
10
+ return recipe ? recipe.statSlots.length : UNCRAFTED_STAT_COUNT
11
+ }
12
+
13
+ export function statsSumSq(packedStats: bigint, nStats: number): number {
14
+ let sum = 0
15
+ for (let i = 0; i < nStats; i++) {
16
+ const s = decodeStat(packedStats, i)
17
+ sum += s * s
18
+ }
19
+ return sum
20
+ }
21
+
22
+ export function qualityFactor(packedStats: bigint, nStats: number, d1: number): number {
23
+ if (nStats <= 0 || d1 <= 0) throw new Error('influence: invalid quality divisor')
24
+ return statsSumSq(packedStats, nStats) / (nStats * d1)
25
+ }