@shipload/sdk 1.0.0-next.7 → 1.0.0-next.70

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 (203) hide show
  1. package/lib/scan.d.ts +52 -0
  2. package/lib/scan.js +162 -0
  3. package/lib/scan.js.map +1 -0
  4. package/lib/scan.m.js +152 -0
  5. package/lib/scan.m.js.map +1 -0
  6. package/lib/shipload.d.ts +5696 -1503
  7. package/lib/shipload.js +20294 -5200
  8. package/lib/shipload.js.map +1 -1
  9. package/lib/shipload.m.js +20371 -5683
  10. package/lib/shipload.m.js.map +1 -1
  11. package/lib/testing.d.ts +1648 -0
  12. package/lib/testing.js +6980 -0
  13. package/lib/testing.js.map +1 -0
  14. package/lib/testing.m.js +6974 -0
  15. package/lib/testing.m.js.map +1 -0
  16. package/package.json +20 -2
  17. package/src/capabilities/craftable.test.ts +82 -0
  18. package/src/capabilities/craftable.ts +82 -0
  19. package/src/capabilities/crafting.test.ts +80 -0
  20. package/src/capabilities/crafting.ts +50 -8
  21. package/src/capabilities/gathering.test.ts +29 -0
  22. package/src/capabilities/gathering.ts +38 -20
  23. package/src/capabilities/hauling.ts +0 -5
  24. package/src/capabilities/index.ts +0 -1
  25. package/src/capabilities/modules.ts +27 -30
  26. package/src/capabilities/movement.ts +1 -1
  27. package/src/capabilities/storage.test.ts +26 -0
  28. package/src/capabilities/storage.ts +22 -7
  29. package/src/contracts/fund.ts +306 -0
  30. package/src/contracts/platform.ts +312 -3
  31. package/src/contracts/server.ts +2661 -466
  32. package/src/contracts/token.ts +170 -0
  33. package/src/contracts.ts +2 -0
  34. package/src/coordinates/address.ts +88 -0
  35. package/src/coordinates/constants.test.ts +15 -0
  36. package/src/coordinates/constants.ts +23 -0
  37. package/src/coordinates/index.ts +15 -0
  38. package/src/coordinates/memo.test.ts +47 -0
  39. package/src/coordinates/memo.ts +20 -0
  40. package/src/coordinates/permutation.ts +77 -0
  41. package/src/coordinates/regions.ts +48 -0
  42. package/src/coordinates/sectors.ts +115 -0
  43. package/src/data/capabilities.ts +49 -18
  44. package/src/data/capability-formulas.ts +26 -14
  45. package/src/data/catalog.test.ts +19 -0
  46. package/src/data/catalog.ts +13 -3
  47. package/src/data/charters.json +396 -0
  48. package/src/data/citizenry-adjectives.json +42 -0
  49. package/src/data/citizenry-nouns.json +50 -0
  50. package/src/data/colors.ts +14 -32
  51. package/src/data/entities.json +421 -15
  52. package/src/data/item-ids.ts +53 -13
  53. package/src/data/items.json +330 -77
  54. package/src/data/kind-registry.json +212 -0
  55. package/src/data/kind-registry.ts +192 -0
  56. package/src/data/metadata.ts +312 -51
  57. package/src/data/recipes-runtime.ts +6 -23
  58. package/src/data/recipes.json +2173 -173
  59. package/src/data/tiers.ts +9 -6
  60. package/src/derivation/build-methods.test.ts +71 -0
  61. package/src/derivation/build-methods.ts +50 -0
  62. package/src/derivation/capabilities.test.ts +348 -0
  63. package/src/derivation/capabilities.ts +707 -0
  64. package/src/derivation/capability-mappings.ts +60 -16
  65. package/src/derivation/crafting.test.ts +17 -0
  66. package/src/derivation/crafting.ts +57 -34
  67. package/src/derivation/index.ts +27 -2
  68. package/src/derivation/recipe-usage.test.ts +88 -0
  69. package/src/derivation/recipe-usage.ts +141 -0
  70. package/src/derivation/reserve-regen.ts +34 -0
  71. package/src/derivation/resources.ts +9 -1
  72. package/src/derivation/rollups.test.ts +55 -0
  73. package/src/derivation/rollups.ts +72 -0
  74. package/src/derivation/stars.test.ts +67 -0
  75. package/src/derivation/stars.ts +25 -0
  76. package/src/derivation/stat-scaling.ts +12 -0
  77. package/src/derivation/stats.ts +6 -6
  78. package/src/derivation/strata.ts +5 -3
  79. package/src/derivation/stratum.ts +26 -22
  80. package/src/derivation/tiers.ts +40 -7
  81. package/src/derivation/upgrades.ts +14 -0
  82. package/src/derivation/wormhole.ts +154 -0
  83. package/src/entities/entity.ts +102 -0
  84. package/src/entities/gamestate.ts +3 -28
  85. package/src/entities/makers.ts +144 -136
  86. package/src/entities/slot-multiplier.ts +43 -0
  87. package/src/entity/hydrate.test.ts +156 -0
  88. package/src/entity/hydrate.ts +86 -0
  89. package/src/errors.ts +21 -16
  90. package/src/format.ts +42 -9
  91. package/src/index-module.ts +411 -32
  92. package/src/influence/ballot.test.ts +103 -0
  93. package/src/influence/ballot.ts +98 -0
  94. package/src/influence/categories.ts +32 -0
  95. package/src/influence/charter-metadata.test.ts +136 -0
  96. package/src/influence/charter-metadata.ts +236 -0
  97. package/src/influence/charters.test.ts +112 -0
  98. package/src/influence/charters.ts +150 -0
  99. package/src/influence/citizenry.ts +38 -0
  100. package/src/influence/civic-shuttle.test.ts +202 -0
  101. package/src/influence/civic-shuttle.ts +142 -0
  102. package/src/influence/civic.test.ts +287 -0
  103. package/src/influence/civic.ts +269 -0
  104. package/src/influence/constants.ts +84 -0
  105. package/src/influence/decay.ts +20 -0
  106. package/src/influence/decomp.ts +95 -0
  107. package/src/influence/demand.ts +106 -0
  108. package/src/influence/duration.ts +101 -0
  109. package/src/influence/index.ts +118 -0
  110. package/src/influence/influence.test.ts +450 -0
  111. package/src/influence/quality.ts +25 -0
  112. package/src/influence/valuation.ts +154 -0
  113. package/src/managers/actions.ts +940 -93
  114. package/src/managers/balances.test.ts +161 -0
  115. package/src/managers/balances.ts +93 -0
  116. package/src/managers/base.ts +10 -2
  117. package/src/managers/cluster.test.ts +39 -0
  118. package/src/managers/cluster.ts +53 -0
  119. package/src/managers/construction-types.ts +80 -0
  120. package/src/managers/construction.ts +453 -0
  121. package/src/managers/context.ts +58 -1
  122. package/src/managers/coordinates.ts +14 -0
  123. package/src/managers/entities.ts +35 -63
  124. package/src/managers/epochs.ts +72 -1
  125. package/src/managers/index.ts +38 -1
  126. package/src/managers/influence.test.ts +56 -0
  127. package/src/managers/influence.ts +425 -0
  128. package/src/managers/jobs.test.ts +80 -0
  129. package/src/managers/jobs.ts +54 -0
  130. package/src/managers/locations.ts +25 -29
  131. package/src/managers/nft.test.ts +21 -0
  132. package/src/managers/nft.ts +110 -0
  133. package/src/managers/players.ts +25 -0
  134. package/src/managers/plot.ts +122 -0
  135. package/src/nft/atomicassets.abi.json +1342 -0
  136. package/src/nft/atomicassets.ts +237 -0
  137. package/src/nft/atomicdata.ts +130 -0
  138. package/src/nft/buildImmutableData.ts +388 -0
  139. package/src/nft/description.ts +222 -100
  140. package/src/nft/index.ts +3 -0
  141. package/src/planner/index.ts +296 -0
  142. package/src/planner/planner.test.ts +459 -0
  143. package/src/receipts.ts +22 -0
  144. package/src/resolution/describe-module.ts +43 -25
  145. package/src/resolution/display-name.ts +39 -11
  146. package/src/resolution/resolve-item.test.ts +48 -0
  147. package/src/resolution/resolve-item.ts +112 -58
  148. package/src/scan/index.ts +244 -0
  149. package/src/scan/scan-wasm.base64.ts +2 -0
  150. package/src/scheduling/accessor.ts +65 -23
  151. package/src/scheduling/availability.test.ts +320 -0
  152. package/src/scheduling/availability.ts +251 -0
  153. package/src/scheduling/cancel.test.ts +550 -0
  154. package/src/scheduling/cancel.ts +254 -0
  155. package/src/scheduling/cluster-stock.test.ts +31 -0
  156. package/src/scheduling/cluster-stock.ts +15 -0
  157. package/src/scheduling/energy.ts +47 -0
  158. package/src/scheduling/epoch.ts +1 -0
  159. package/src/scheduling/idle-resolve.ts +45 -0
  160. package/src/scheduling/jobs.test.ts +49 -0
  161. package/src/scheduling/jobs.ts +137 -0
  162. package/src/scheduling/lane-core.ts +128 -0
  163. package/src/scheduling/lanes.test.ts +250 -0
  164. package/src/scheduling/lanes.ts +227 -0
  165. package/src/scheduling/projection.ts +265 -149
  166. package/src/scheduling/schedule.test.ts +163 -0
  167. package/src/scheduling/schedule.ts +282 -116
  168. package/src/scheduling/task-cargo.test.ts +110 -0
  169. package/src/scheduling/task-cargo.ts +62 -0
  170. package/src/scheduling/task-effects.test.ts +35 -0
  171. package/src/scheduling/task-effects.ts +34 -0
  172. package/src/scheduling/unwrap.test.ts +86 -0
  173. package/src/scheduling/unwrap.ts +190 -0
  174. package/src/shipload.ts +63 -4
  175. package/src/subscriptions/manager.cluster.test.ts +51 -0
  176. package/src/subscriptions/manager.ts +245 -172
  177. package/src/subscriptions/mappers.ts +5 -8
  178. package/src/subscriptions/types.ts +28 -3
  179. package/src/testing/catalog-hash.ts +19 -0
  180. package/src/testing/index.ts +2 -0
  181. package/src/testing/projection-parity.ts +167 -0
  182. package/src/travel/reach.test.ts +86 -0
  183. package/src/travel/reach.ts +34 -0
  184. package/src/travel/route-build.test.ts +63 -0
  185. package/src/travel/route-build.ts +60 -0
  186. package/src/travel/route-planner.ts +288 -0
  187. package/src/travel/route-simulator.ts +170 -0
  188. package/src/travel/travel.ts +190 -123
  189. package/src/types/capabilities.ts +37 -8
  190. package/src/types/entity.ts +3 -3
  191. package/src/types/index.ts +0 -1
  192. package/src/types.ts +81 -23
  193. package/src/utils/cargo.test.ts +14 -0
  194. package/src/utils/cargo.ts +2 -0
  195. package/src/utils/display-name.ts +70 -0
  196. package/src/utils/hash.ts +22 -2
  197. package/src/utils/system.ts +24 -5
  198. package/src/capabilities/loading.ts +0 -8
  199. package/src/entities/container.ts +0 -123
  200. package/src/entities/ship-deploy.ts +0 -295
  201. package/src/entities/ship.ts +0 -221
  202. package/src/entities/warehouse.ts +0 -127
  203. package/src/types/entity-traits.ts +0 -69
@@ -0,0 +1,103 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {
3
+ charterEligible,
4
+ charterEligibleChained,
5
+ CHARTER_REGISTRY,
6
+ type CharterWorld,
7
+ } from './charters'
8
+ import {projectBallot} from './ballot'
9
+
10
+ const NEXUS = 20001
11
+ const DOCK = 30100001
12
+ const WORKSHOP_TUNEUP = 10100201
13
+ const DOCK_TUNEUP = 30100301
14
+ const DEPOT = 40100001
15
+
16
+ const world: CharterWorld = {built: [{nodeId: 10001, repeats: 0}]}
17
+
18
+ describe('charterEligibleChained', () => {
19
+ test('with nothing seated it equals charterEligible', () => {
20
+ for (const node of CHARTER_REGISTRY) {
21
+ expect(charterEligibleChained(world, node, [])).toBe(charterEligible(world, node))
22
+ }
23
+ })
24
+
25
+ test('a seated prerequisite opens a dependent, a seated node closes itself', () => {
26
+ const tuneup = CHARTER_REGISTRY.find((n) => n.nodeId === DOCK_TUNEUP)!
27
+ expect(charterEligibleChained(world, tuneup, [])).toBe(false)
28
+ expect(charterEligibleChained(world, tuneup, [DOCK])).toBe(true)
29
+ const dock = CHARTER_REGISTRY.find((n) => n.nodeId === DOCK)!
30
+ expect(charterEligibleChained(world, dock, [DOCK])).toBe(false)
31
+ })
32
+ })
33
+
34
+ describe('projectBallot', () => {
35
+ const voters = [
36
+ {account: 'carol', weight: 9_000n, picks: [DOCK, DOCK_TUNEUP]},
37
+ {account: 'dave', weight: 5_000n, picks: [NEXUS, DOCK_TUNEUP]},
38
+ {account: 'erin', weight: 0n, picks: [NEXUS]},
39
+ ]
40
+
41
+ test('one seat: the heavier pick wins and the frontier carries round weights', () => {
42
+ const result = projectBallot({world, seats: 1, voters})
43
+ expect(result.seats).toEqual([{nodeId: DOCK, weight: 9_000n}])
44
+ const nexus = result.options.find((o) => o.nodeId === NEXUS)!
45
+ expect(nexus.seat).toBe(0)
46
+ expect(nexus.weight).toBe(5_000n)
47
+ const tuneup = result.options.find((o) => o.nodeId === DOCK_TUNEUP)!
48
+ expect(tuneup.seat).toBe(0)
49
+ expect(tuneup.weight).toBe(9_000n)
50
+ expect(result.options.find((o) => o.nodeId === 10001)).toBeUndefined()
51
+ })
52
+
53
+ test('two seats: the dock tune-up chains behind the dock', () => {
54
+ const result = projectBallot({world, seats: 2, voters})
55
+ expect(result.seats.map((s) => s.nodeId)).toEqual([DOCK, DOCK_TUNEUP])
56
+ expect(result.seats[1].weight).toBe(9_000n)
57
+ })
58
+
59
+ test('a tie goes to the lower option id', () => {
60
+ const tied = [
61
+ {account: 'a', weight: 100n, picks: [DOCK]},
62
+ {account: 'b', weight: 100n, picks: [NEXUS]},
63
+ ]
64
+ expect(projectBallot({world, seats: 1, voters: tied}).seats[0].nodeId).toBe(NEXUS)
65
+ })
66
+
67
+ test('a zero round ends early and an empty queue stays empty, even with one node left', () => {
68
+ expect(projectBallot({world, seats: 4, voters}).seats.length).toBe(3)
69
+ expect(projectBallot({world, seats: 1, voters: []}).seats).toEqual([])
70
+ const almostDone: CharterWorld = {
71
+ built: CHARTER_REGISTRY.filter((n) => n.nodeId !== 40100404).map((n) => ({
72
+ nodeId: n.nodeId,
73
+ repeats: 0,
74
+ })),
75
+ }
76
+ expect(projectBallot({world: almostDone, seats: 1, voters: []}).seats).toEqual([])
77
+ expect(
78
+ projectBallot({
79
+ world: almostDone,
80
+ seats: 1,
81
+ voters: [{account: 'a', weight: 5n, picks: [40100404]}],
82
+ }).seats
83
+ ).toEqual([{nodeId: 40100404, weight: 5n}])
84
+ })
85
+
86
+ test('the caller picks are ranked on the options', () => {
87
+ const result = projectBallot({world, seats: 1, voters, picks: [NEXUS, DOCK]})
88
+ expect(result.options.find((o) => o.nodeId === NEXUS)!.rank).toBe(1)
89
+ expect(result.options.find((o) => o.nodeId === DOCK)!.rank).toBe(2)
90
+ expect(result.options.find((o) => o.nodeId === WORKSHOP_TUNEUP)!.rank).toBe(0)
91
+ })
92
+
93
+ test('a pick slot is consumed by the first eligible option regardless of the voter weight behind it', () => {
94
+ const scenario = [
95
+ {account: 'grace', weight: 50n, picks: [NEXUS, DEPOT]},
96
+ {account: 'frank', weight: 45n, picks: [DEPOT]},
97
+ {account: 'erin', weight: 0n, picks: [NEXUS, DEPOT]},
98
+ ]
99
+ expect(projectBallot({world, seats: 1, voters: scenario}).seats).toEqual([
100
+ {nodeId: NEXUS, weight: 50n},
101
+ ])
102
+ })
103
+ })
@@ -0,0 +1,98 @@
1
+ import {CHARTER_REGISTRY, charterEligibleChained, type CharterWorld} from './charters'
2
+ import {CHARTER_NONE} from './constants'
3
+
4
+ export interface BallotVoter {
5
+ account: string
6
+ weight: bigint
7
+ picks: number[]
8
+ }
9
+
10
+ /** Projects a full settlement from scratch; it does not resume an in-flight partial round. */
11
+ export interface BallotInput {
12
+ world: CharterWorld
13
+ seats: number
14
+ voters: BallotVoter[]
15
+ picks?: number[]
16
+ }
17
+
18
+ export interface ProjectedSeat {
19
+ nodeId: number
20
+ weight: bigint
21
+ }
22
+
23
+ export interface ProjectedOption {
24
+ nodeId: number
25
+ cost: bigint
26
+ seat: number
27
+ weight: bigint
28
+ rank: number
29
+ }
30
+
31
+ export interface BallotProjection {
32
+ seats: ProjectedSeat[]
33
+ options: ProjectedOption[]
34
+ }
35
+
36
+ function tallyRound(input: BallotInput, seated: number[]): Map<number, bigint> {
37
+ const tallies = new Map<number, bigint>()
38
+ for (const voter of input.voters) {
39
+ for (const pick of voter.picks) {
40
+ const node = CHARTER_REGISTRY.find((n) => n.nodeId === pick)
41
+ if (!node || !charterEligibleChained(input.world, node, seated)) continue
42
+ if (voter.weight > 0n) tallies.set(pick, (tallies.get(pick) ?? 0n) + voter.weight)
43
+ break
44
+ }
45
+ }
46
+ return tallies
47
+ }
48
+
49
+ function leader(tallies: Map<number, bigint>): ProjectedSeat | undefined {
50
+ let winner = CHARTER_NONE
51
+ let best = 0n
52
+ for (const [option, weight] of tallies) {
53
+ if (weight === 0n) continue
54
+ if (winner === CHARTER_NONE || weight > best || (weight === best && option < winner)) {
55
+ best = weight
56
+ winner = option
57
+ }
58
+ }
59
+ return winner === CHARTER_NONE ? undefined : {nodeId: winner, weight: best}
60
+ }
61
+
62
+ export function projectBallot(input: BallotInput): BallotProjection {
63
+ const seats: ProjectedSeat[] = []
64
+ while (seats.length < input.seats) {
65
+ const seat = leader(
66
+ tallyRound(
67
+ input,
68
+ seats.map((s) => s.nodeId)
69
+ )
70
+ )
71
+ if (!seat) break
72
+ seats.push(seat)
73
+ }
74
+
75
+ const projected = seats.map((s) => s.nodeId)
76
+ const next = tallyRound(input, projected)
77
+ const picks = input.picks ?? []
78
+
79
+ const options: ProjectedOption[] = []
80
+ for (const node of CHARTER_REGISTRY) {
81
+ const seatIndex = projected.indexOf(node.nodeId)
82
+ const option: ProjectedOption = {
83
+ nodeId: node.nodeId,
84
+ cost: node.cost,
85
+ seat: seatIndex + 1,
86
+ weight: 0n,
87
+ rank: picks.indexOf(node.nodeId) + 1,
88
+ }
89
+ if (seatIndex >= 0) {
90
+ option.weight = seats[seatIndex].weight
91
+ } else {
92
+ if (!charterEligibleChained(input.world, node, projected)) continue
93
+ option.weight = next.get(node.nodeId) ?? 0n
94
+ }
95
+ options.push(option)
96
+ }
97
+ return {seats, options}
98
+ }
@@ -0,0 +1,32 @@
1
+ import {categoryFromIndex} from '../data/catalog'
2
+ import type {ResourceCategory} from '../types'
3
+ import {
4
+ RESOURCE_BIOMASS,
5
+ RESOURCE_CATEGORY_COUNT,
6
+ RESOURCE_CRYSTAL,
7
+ RESOURCE_GAS,
8
+ RESOURCE_ORE,
9
+ RESOURCE_REGOLITH,
10
+ } from './constants'
11
+
12
+ const INDEX_BY_CATEGORY: Record<ResourceCategory, number> = {
13
+ ore: RESOURCE_ORE,
14
+ gas: RESOURCE_GAS,
15
+ regolith: RESOURCE_REGOLITH,
16
+ biomass: RESOURCE_BIOMASS,
17
+ crystal: RESOURCE_CRYSTAL,
18
+ }
19
+
20
+ export function categoryIndex(category: ResourceCategory): number {
21
+ return INDEX_BY_CATEGORY[category]
22
+ }
23
+
24
+ export function categoryAtIndex(index: number): ResourceCategory {
25
+ const category = categoryFromIndex(index)
26
+ if (!category) throw new Error(`influence: invalid resource category index ${index}`)
27
+ return category
28
+ }
29
+
30
+ export function allCategoryIndexes(): number[] {
31
+ return Array.from({length: RESOURCE_CATEGORY_COUNT}, (_, i) => i)
32
+ }
@@ -0,0 +1,136 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+
3
+ import {CHARTER_REGISTRY, type CharterNode} from './charters'
4
+ import {
5
+ charterMetadata,
6
+ charterName,
7
+ charterSignature,
8
+ charterSummary,
9
+ romanNumeral,
10
+ type CharterSignature,
11
+ } from './charter-metadata'
12
+
13
+ function nodeById(nodeId: number): CharterNode {
14
+ const node = CHARTER_REGISTRY.find((candidate) => candidate.nodeId === nodeId)
15
+ if (!node) throw new Error(`no charter node ${nodeId} in the registry`)
16
+ return node
17
+ }
18
+
19
+ function signatureFor(nodeId: number): CharterSignature {
20
+ const signature = charterSignature(nodeById(nodeId))
21
+ if (!signature) throw new Error(`no signature for charter node ${nodeId}`)
22
+ return signature
23
+ }
24
+
25
+ function sharesWord(name: string, label: string): boolean {
26
+ const haystack = name.toLowerCase()
27
+ return label
28
+ .split(/\s+/)
29
+ .filter((word) => word.length > 2)
30
+ .some((word) => haystack.includes(word.toLowerCase()))
31
+ }
32
+
33
+ describe('charter metadata', () => {
34
+ test('covers every node in the chain-synced registry', () => {
35
+ const missing = CHARTER_REGISTRY.filter((node) => !charterMetadata[node.nodeId])
36
+ expect(missing).toEqual([])
37
+ })
38
+
39
+ test('carries no entry for a node the registry does not define', () => {
40
+ const known = new Set(CHARTER_REGISTRY.map((node) => node.nodeId))
41
+ const orphans = Object.keys(charterMetadata)
42
+ .map(Number)
43
+ .filter((nodeId) => !known.has(nodeId))
44
+ expect(orphans).toEqual([])
45
+ })
46
+
47
+ test('every entry has a name and a summary', () => {
48
+ for (const node of CHARTER_REGISTRY) {
49
+ expect(charterName(node.nodeId).length).toBeGreaterThan(0)
50
+ expect(charterSummary(node.nodeId).length).toBeGreaterThan(0)
51
+ }
52
+ })
53
+
54
+ test('falls back to a node-numbered name for an unknown id', () => {
55
+ expect(charterName(9999)).toBe('Charter 9999')
56
+ expect(charterSummary(9999)).toBe('')
57
+ })
58
+ })
59
+
60
+ describe('charter names agree with the grant they are derived from', () => {
61
+ for (const node of CHARTER_REGISTRY) {
62
+ const signature = signatureFor(node.nodeId)
63
+ const name = charterName(node.nodeId)
64
+
65
+ test(`node ${node.nodeId} names the building it grants (${signature.buildingLabel})`, () => {
66
+ expect(sharesWord(name, signature.buildingLabel)).toBeTrue()
67
+ })
68
+
69
+ if (signature.kind !== 'gate' && signature.rankCount > 1) {
70
+ test(`node ${node.nodeId} carries rank ${signature.rank} of ${signature.rankCount}`, () => {
71
+ expect(name.endsWith(` ${romanNumeral(signature.rank)}`)).toBeTrue()
72
+ })
73
+ }
74
+ }
75
+ })
76
+
77
+ describe('charter signatures', () => {
78
+ test('reads the depot level gate from its grant list', () => {
79
+ expect(signatureFor(40100001)).toEqual({kind: 'gate', buildingLabel: 'Depot', level: 1})
80
+ })
81
+
82
+ test('ranks the depot bays by their prereq chain', () => {
83
+ expect([40100101, 40100102, 40100103, 40100104].map(signatureFor)).toEqual(
84
+ [1, 2, 3, 4].map((rank) => ({
85
+ kind: 'module',
86
+ buildingLabel: 'Depot',
87
+ count: 1,
88
+ rank,
89
+ rankCount: 4,
90
+ }))
91
+ )
92
+ })
93
+
94
+ test('ranks the depot transfer rungs by their prereq chain', () => {
95
+ expect([40100401, 40100402, 40100403, 40100404].map(signatureFor)).toEqual([
96
+ {
97
+ kind: 'rung',
98
+ buildingLabel: 'Depot',
99
+ statLabel: 'transfer speed',
100
+ rank: 1,
101
+ rankCount: 4,
102
+ },
103
+ {
104
+ kind: 'rung',
105
+ buildingLabel: 'Depot',
106
+ statLabel: 'transfer speed',
107
+ rank: 2,
108
+ rankCount: 4,
109
+ },
110
+ {
111
+ kind: 'rung',
112
+ buildingLabel: 'Depot',
113
+ statLabel: 'transfer speed',
114
+ rank: 3,
115
+ rankCount: 4,
116
+ },
117
+ {
118
+ kind: 'rung',
119
+ buildingLabel: 'Depot',
120
+ statLabel: 'transfer speed',
121
+ rank: 4,
122
+ rankCount: 4,
123
+ },
124
+ ])
125
+ })
126
+
127
+ test('ranks the Workshop tune-ups as a four-rung ladder', () => {
128
+ expect(signatureFor(10100203)).toEqual({
129
+ kind: 'rung',
130
+ buildingLabel: 'Workshop',
131
+ statLabel: 'crafting speed',
132
+ rank: 3,
133
+ rankCount: 4,
134
+ })
135
+ })
136
+ })
@@ -0,0 +1,236 @@
1
+ import {CHARTER_REGISTRY, type CharterGrant, type CharterNode} from './charters'
2
+ import {
3
+ CHARTER_NONE,
4
+ CIVIC_DEPOT,
5
+ CIVIC_DOCK,
6
+ CIVIC_GRANT_LEVEL_GATE,
7
+ CIVIC_GRANT_MODULE,
8
+ CIVIC_GRANT_RUNG,
9
+ CIVIC_NEXUS,
10
+ CIVIC_STAT_BUILD_SPEED,
11
+ CIVIC_STAT_CRAFT_SPEED,
12
+ CIVIC_STAT_STORAGE_CAPACITY,
13
+ CIVIC_STAT_TRANSFER_SPEED,
14
+ CIVIC_WORKSHOP,
15
+ } from './constants'
16
+
17
+ const BUILDING_LABELS: Record<number, string> = {
18
+ [CIVIC_WORKSHOP]: 'Workshop',
19
+ [CIVIC_NEXUS]: 'Nexus',
20
+ [CIVIC_DOCK]: 'Construction Dock',
21
+ [CIVIC_DEPOT]: 'Depot',
22
+ }
23
+
24
+ const STAT_LABELS: Record<number, string> = {
25
+ [CIVIC_STAT_CRAFT_SPEED]: 'crafting speed',
26
+ [CIVIC_STAT_BUILD_SPEED]: 'build speed',
27
+ [CIVIC_STAT_TRANSFER_SPEED]: 'transfer speed',
28
+ [CIVIC_STAT_STORAGE_CAPACITY]: 'storage capacity',
29
+ }
30
+
31
+ export function civicBuildingLabel(building: number): string {
32
+ return BUILDING_LABELS[building] ?? `Building ${building}`
33
+ }
34
+
35
+ export function civicStatLabel(stat: number): string {
36
+ return STAT_LABELS[stat] ?? `Stat ${stat}`
37
+ }
38
+
39
+ export interface CharterMeta {
40
+ name: string
41
+ summary: string
42
+ }
43
+
44
+ export const charterMetadata: Record<number, CharterMeta> = {
45
+ 10001: {
46
+ name: 'Communal Workshop',
47
+ summary: 'Shared crafting for anyone at this world',
48
+ },
49
+ 10100201: {
50
+ name: 'Workshop tune-up I',
51
+ summary: "Raises the Workshop's Fabricator stats",
52
+ },
53
+ 10100202: {
54
+ name: 'Workshop tune-up II',
55
+ summary: "Raises the Workshop's Fabricator stats",
56
+ },
57
+ 10100203: {
58
+ name: 'Workshop tune-up III',
59
+ summary: "Raises the Workshop's Fabricator stats",
60
+ },
61
+ 10100204: {
62
+ name: 'Workshop tune-up IV',
63
+ summary: "Raises the Workshop's Fabricator stats",
64
+ },
65
+ 20001: {
66
+ name: 'Civic Nexus',
67
+ summary: 'Advances the world to Tier II; wrap items for trade and unwrap them here',
68
+ },
69
+ 30100001: {
70
+ name: 'Construction Dock',
71
+ summary: 'Ships queue here for upgrades',
72
+ },
73
+ 30100301: {
74
+ name: 'Dock tune-up I',
75
+ summary: "Raises the Dock's Assembly Arm stats",
76
+ },
77
+ 30100302: {
78
+ name: 'Dock tune-up II',
79
+ summary: "Raises the Dock's Assembly Arm stats",
80
+ },
81
+ 30100303: {
82
+ name: 'Dock tune-up III',
83
+ summary: "Raises the Dock's Assembly Arm stats",
84
+ },
85
+ 30100304: {
86
+ name: 'Dock tune-up IV',
87
+ summary: "Raises the Dock's Assembly Arm stats",
88
+ },
89
+ 40100001: {
90
+ name: 'Public Depot',
91
+ summary: 'Storage at this world for every player',
92
+ },
93
+ 40100101: {
94
+ name: 'Depot Cargo Hold I',
95
+ summary: 'Raises what each player can keep at the depot',
96
+ },
97
+ 40100102: {
98
+ name: 'Depot Cargo Hold II',
99
+ summary: 'Raises what each player can keep at the depot',
100
+ },
101
+ 40100103: {
102
+ name: 'Depot Cargo Hold III',
103
+ summary: 'Raises what each player can keep at the depot',
104
+ },
105
+ 40100104: {
106
+ name: 'Depot Cargo Hold IV',
107
+ summary: 'Raises what each player can keep at the depot',
108
+ },
109
+ 40100401: {
110
+ name: 'Depot Shuttle Bay I',
111
+ summary: 'Moves cargo in and out of the depot faster',
112
+ },
113
+ 40100402: {
114
+ name: 'Depot Shuttle Bay II',
115
+ summary: 'Moves cargo in and out of the depot faster',
116
+ },
117
+ 40100403: {
118
+ name: 'Depot Shuttle Bay III',
119
+ summary: 'Moves cargo in and out of the depot faster',
120
+ },
121
+ 40100404: {
122
+ name: 'Depot Shuttle Bay IV',
123
+ summary: 'Moves cargo in and out of the depot faster',
124
+ },
125
+ }
126
+
127
+ export function charterName(nodeId: number): string {
128
+ return charterMetadata[nodeId]?.name ?? `Charter ${nodeId}`
129
+ }
130
+
131
+ export function charterSummary(nodeId: number): string {
132
+ return charterMetadata[nodeId]?.summary ?? ''
133
+ }
134
+
135
+ export interface CharterGateSignature {
136
+ kind: 'gate'
137
+ buildingLabel: string
138
+ level: number
139
+ }
140
+
141
+ export interface CharterModuleSignature {
142
+ kind: 'module'
143
+ buildingLabel: string
144
+ count: number
145
+ rank: number
146
+ rankCount: number
147
+ }
148
+
149
+ export interface CharterRungSignature {
150
+ kind: 'rung'
151
+ buildingLabel: string
152
+ statLabel: string
153
+ rank: number
154
+ rankCount: number
155
+ }
156
+
157
+ export type CharterSignature = CharterGateSignature | CharterModuleSignature | CharterRungSignature
158
+
159
+ const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
160
+
161
+ export function romanNumeral(rank: number): string {
162
+ return ROMAN[rank - 1] ?? String(rank)
163
+ }
164
+
165
+ function headlineGrant(node: CharterNode): CharterGrant | undefined {
166
+ return (
167
+ node.grants.find((grant) => grant.kind === CIVIC_GRANT_LEVEL_GATE) ??
168
+ node.grants.find((grant) => grant.kind === CIVIC_GRANT_MODULE) ??
169
+ node.grants.find((grant) => grant.kind === CIVIC_GRANT_RUNG)
170
+ )
171
+ }
172
+
173
+ function headlineSiblings(headline: CharterGrant): CharterNode[] {
174
+ return CHARTER_REGISTRY.filter((other) => {
175
+ const grant = headlineGrant(other)
176
+ return (
177
+ grant !== undefined &&
178
+ grant.kind === headline.kind &&
179
+ grant.building === headline.building &&
180
+ grant.stat === headline.stat
181
+ )
182
+ })
183
+ }
184
+
185
+ function rungRank(node: CharterNode, siblings: CharterNode[]): number {
186
+ const byId = new Map(siblings.map((sibling) => [sibling.nodeId, sibling]))
187
+ const seen = new Set<number>([node.nodeId])
188
+ let current: CharterNode | undefined = node
189
+ let rank = 1
190
+ while (current) {
191
+ const previousId = current.prereqs.find((id) => byId.has(id) && !seen.has(id))
192
+ if (previousId === undefined) return rank
193
+ seen.add(previousId)
194
+ current = byId.get(previousId)
195
+ rank += 1
196
+ }
197
+ return rank
198
+ }
199
+
200
+ export function charterSignature(node: CharterNode): CharterSignature | undefined {
201
+ const grant = headlineGrant(node)
202
+ if (!grant) return undefined
203
+ if (grant.kind === CIVIC_GRANT_LEVEL_GATE) {
204
+ return {
205
+ kind: 'gate',
206
+ buildingLabel: civicBuildingLabel(grant.building),
207
+ level: grant.value,
208
+ }
209
+ }
210
+ const siblings = headlineSiblings(grant)
211
+ if (grant.kind === CIVIC_GRANT_MODULE) {
212
+ return {
213
+ kind: 'module',
214
+ buildingLabel: civicBuildingLabel(grant.building),
215
+ count: grant.value,
216
+ rank: rungRank(node, siblings),
217
+ rankCount: siblings.length,
218
+ }
219
+ }
220
+ return {
221
+ kind: 'rung',
222
+ buildingLabel: civicBuildingLabel(grant.building),
223
+ statLabel: civicStatLabel(grant.stat),
224
+ rank: rungRank(node, siblings),
225
+ rankCount: siblings.length,
226
+ }
227
+ }
228
+
229
+ for (const node of CHARTER_REGISTRY) {
230
+ if (node.nodeId === CHARTER_NONE) continue
231
+ if (!charterMetadata[node.nodeId]) {
232
+ throw new Error(
233
+ `Missing charter metadata for node ${node.nodeId}. Add an entry to charter-metadata.ts.`
234
+ )
235
+ }
236
+ }