@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.
- package/lib/shipload.d.ts +1829 -501
- package/lib/shipload.js +4325 -1324
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +4286 -1389
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.d.ts +391 -13
- package/lib/testing.js +1692 -107
- package/lib/testing.js.map +1 -1
- package/lib/testing.m.js +1693 -108
- package/lib/testing.m.js.map +1 -1
- package/package.json +2 -2
- package/src/contracts/fund.ts +201 -0
- package/src/contracts/platform.ts +73 -1
- package/src/contracts/server.ts +946 -26
- package/src/contracts/token.ts +170 -0
- package/src/contracts.ts +2 -0
- package/src/data/catalog.test.ts +19 -0
- package/src/data/catalog.ts +6 -0
- package/src/data/charters.json +198 -0
- package/src/data/citizenry-adjectives.json +42 -0
- package/src/data/citizenry-nouns.json +50 -0
- package/src/data/entities.json +36 -0
- package/src/data/item-ids.ts +2 -0
- package/src/data/items.json +12 -0
- package/src/data/kind-registry.json +20 -3
- package/src/data/kind-registry.ts +5 -0
- package/src/data/metadata.ts +14 -0
- package/src/data/recipes.json +0 -48
- package/src/derivation/capabilities.ts +14 -0
- package/src/derivation/recipe-usage.test.ts +0 -2
- package/src/format.ts +9 -0
- package/src/index-module.ts +32 -2
- package/src/influence/categories.ts +32 -0
- package/src/influence/charters.test.ts +126 -0
- package/src/influence/charters.ts +120 -0
- package/src/influence/citizenry.ts +38 -0
- package/src/influence/constants.ts +63 -0
- package/src/influence/decay.ts +20 -0
- package/src/influence/decomp.ts +95 -0
- package/src/influence/demand.ts +106 -0
- package/src/influence/duration.ts +36 -0
- package/src/influence/index.ts +63 -0
- package/src/influence/influence.test.ts +317 -0
- package/src/influence/quality.ts +25 -0
- package/src/influence/valuation.ts +154 -0
- package/src/managers/actions.ts +227 -5
- package/src/managers/balances.test.ts +161 -0
- package/src/managers/balances.ts +93 -0
- package/src/managers/construction.ts +11 -1
- package/src/managers/context.ts +18 -0
- package/src/managers/entities.ts +11 -0
- package/src/managers/index.ts +15 -1
- package/src/managers/influence.test.ts +58 -0
- package/src/managers/influence.ts +347 -0
- package/src/managers/nft.test.ts +8 -1
- package/src/managers/nft.ts +56 -16
- package/src/nft/description.ts +7 -0
- package/src/scheduling/task-cargo.ts +1 -0
- package/src/shipload.ts +10 -0
- package/src/types.ts +23 -4
- package/src/utils/hash.ts +4 -0
- 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
|
+
}
|