@shipload/sdk 1.0.0-next.4 → 1.0.0-next.41
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/scan.d.ts +34 -0
- package/lib/scan.js +136 -0
- package/lib/scan.js.map +1 -0
- package/lib/scan.m.js +129 -0
- package/lib/scan.m.js.map +1 -0
- package/lib/shipload.d.ts +2473 -973
- package/lib/shipload.js +11529 -5211
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +11338 -5162
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.d.ts +970 -0
- package/lib/testing.js +4013 -0
- package/lib/testing.js.map +1 -0
- package/lib/testing.m.js +4007 -0
- package/lib/testing.m.js.map +1 -0
- package/package.json +20 -2
- package/src/capabilities/craftable.ts +51 -0
- package/src/capabilities/crafting.test.ts +7 -0
- package/src/capabilities/crafting.ts +5 -6
- package/src/capabilities/gathering.test.ts +16 -0
- package/src/capabilities/gathering.ts +35 -18
- package/src/capabilities/index.ts +0 -1
- package/src/capabilities/modules.ts +9 -0
- package/src/capabilities/storage.ts +16 -1
- package/src/contracts/platform.ts +231 -3
- package/src/contracts/server.ts +1021 -481
- package/src/coordinates/address.ts +88 -0
- package/src/coordinates/constants.test.ts +15 -0
- package/src/coordinates/constants.ts +23 -0
- package/src/coordinates/index.ts +15 -0
- package/src/coordinates/memo.test.ts +47 -0
- package/src/coordinates/memo.ts +20 -0
- package/src/coordinates/permutation.ts +77 -0
- package/src/coordinates/regions.ts +48 -0
- package/src/coordinates/sectors.ts +115 -0
- package/src/data/capabilities.ts +12 -5
- package/src/data/capability-formulas.ts +14 -7
- package/src/data/catalog.ts +0 -5
- package/src/data/colors.ts +14 -47
- package/src/data/entities.json +76 -10
- package/src/data/item-ids.ts +18 -12
- package/src/data/items.json +321 -38
- package/src/data/kind-registry.json +109 -0
- package/src/data/kind-registry.ts +165 -0
- package/src/data/metadata.ts +119 -33
- package/src/data/recipes-runtime.ts +3 -23
- package/src/data/recipes.json +238 -117
- package/src/derivation/build-methods.ts +45 -0
- package/src/derivation/capabilities.test.ts +151 -0
- package/src/derivation/capabilities.ts +512 -0
- package/src/derivation/capability-mappings.ts +9 -12
- package/src/derivation/crafting.ts +23 -24
- package/src/derivation/index.ts +25 -2
- package/src/derivation/recipe-usage.test.ts +78 -0
- package/src/derivation/recipe-usage.ts +141 -0
- package/src/derivation/reserve-regen.ts +34 -0
- package/src/derivation/resources.ts +125 -38
- package/src/derivation/rollups.test.ts +55 -0
- package/src/derivation/rollups.ts +56 -0
- package/src/derivation/stars.test.ts +51 -0
- package/src/derivation/stars.ts +15 -0
- package/src/derivation/stats.ts +6 -6
- package/src/derivation/stratum.ts +17 -20
- package/src/derivation/tiers.ts +40 -7
- package/src/derivation/wormhole.ts +136 -0
- package/src/entities/entity.ts +98 -0
- package/src/entities/gamestate.ts +3 -28
- package/src/entities/makers.ts +124 -134
- package/src/entities/slot-multiplier.ts +43 -0
- package/src/errors.ts +12 -16
- package/src/format.ts +26 -4
- package/src/index-module.ts +267 -47
- package/src/managers/actions.ts +528 -95
- package/src/managers/base.ts +6 -2
- package/src/managers/construction-types.ts +80 -0
- package/src/managers/construction.ts +412 -0
- package/src/managers/context.ts +20 -1
- package/src/managers/coordinates.ts +14 -0
- package/src/managers/entities.ts +18 -66
- package/src/managers/epochs.ts +40 -0
- package/src/managers/index.ts +17 -1
- package/src/managers/locations.ts +25 -29
- package/src/managers/nft.test.ts +14 -0
- package/src/managers/nft.ts +70 -0
- package/src/managers/plot.ts +122 -0
- package/src/nft/atomicassets.abi.json +1342 -0
- package/src/nft/atomicassets.ts +237 -0
- package/src/nft/atomicdata.ts +130 -0
- package/src/nft/buildImmutableData.ts +338 -0
- package/src/nft/description.ts +98 -24
- package/src/nft/index.ts +3 -0
- package/src/planner/index.ts +127 -0
- package/src/planner/planner.test.ts +319 -0
- package/src/resolution/describe-module.ts +18 -13
- package/src/resolution/display-name.ts +38 -10
- package/src/resolution/resolve-item.test.ts +37 -0
- package/src/resolution/resolve-item.ts +55 -24
- package/src/scan/index.ts +180 -0
- package/src/scan/scan-wasm.base64.ts +2 -0
- package/src/scheduling/accessor.ts +68 -22
- package/src/scheduling/availability.ts +108 -0
- package/src/scheduling/cancel.test.ts +348 -0
- package/src/scheduling/cancel.ts +209 -0
- package/src/scheduling/energy.ts +47 -0
- package/src/scheduling/idle-resolve.ts +45 -0
- package/src/scheduling/lane-core.ts +128 -0
- package/src/scheduling/lanes.test.ts +249 -0
- package/src/scheduling/lanes.ts +198 -0
- package/src/scheduling/projection.ts +209 -105
- package/src/scheduling/schedule.ts +241 -104
- package/src/scheduling/task-cargo.ts +46 -0
- package/src/shipload.ts +21 -1
- package/src/subscriptions/manager.ts +229 -142
- package/src/subscriptions/mappers.ts +5 -8
- package/src/subscriptions/types.ts +11 -3
- package/src/testing/catalog-hash.ts +19 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/projection-parity.ts +167 -0
- package/src/travel/reach.ts +23 -0
- package/src/travel/route-planner.ts +196 -0
- package/src/travel/travel.ts +200 -112
- package/src/types/capabilities.ts +29 -6
- package/src/types/entity.ts +3 -3
- package/src/types/index.ts +0 -1
- package/src/types.ts +28 -13
- package/src/utils/cargo.ts +27 -0
- package/src/utils/display-name.ts +70 -0
- package/src/utils/system.ts +36 -24
- package/src/capabilities/loading.ts +0 -8
- package/src/entities/container.ts +0 -108
- package/src/entities/ship-deploy.ts +0 -259
- package/src/entities/ship.ts +0 -204
- package/src/entities/warehouse.ts +0 -119
- package/src/types/entity-traits.ts +0 -69
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type {ServerContract} from '../contracts'
|
|
2
|
+
import {TaskCancelable, TaskType} from '../types'
|
|
3
|
+
import {calcCargoItemMass} from '../capabilities/storage'
|
|
4
|
+
import {taskCargoEffect, cargoKey} from './availability'
|
|
5
|
+
import * as schedule from './schedule'
|
|
6
|
+
import {validateSchedule, type Projectable} from './projection'
|
|
7
|
+
|
|
8
|
+
export enum CancelBlockReason {
|
|
9
|
+
TASK_NEVER = 'TASK_NEVER',
|
|
10
|
+
BEFORE_START_RUNNING = 'BEFORE_START_RUNNING',
|
|
11
|
+
DONE = 'DONE',
|
|
12
|
+
CONTAINS_LINKED_TASK = 'CONTAINS_LINKED_TASK',
|
|
13
|
+
WOULD_STRAND = 'WOULD_STRAND',
|
|
14
|
+
WOULD_OVERFILL = 'WOULD_OVERFILL',
|
|
15
|
+
NOT_OWNER = 'NOT_OWNER',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Task = InstanceType<typeof ServerContract.Types.task>
|
|
19
|
+
type EntityInfo = InstanceType<typeof ServerContract.Types.entity_info>
|
|
20
|
+
type EntityRef = InstanceType<typeof ServerContract.Types.entity_ref>
|
|
21
|
+
type CargoItem = InstanceType<typeof ServerContract.Types.cargo_item>
|
|
22
|
+
|
|
23
|
+
export interface CancelRefund {
|
|
24
|
+
giver: EntityRef
|
|
25
|
+
cargo: CargoItem[]
|
|
26
|
+
}
|
|
27
|
+
export interface CancelReleasedHold {
|
|
28
|
+
counterpart: EntityRef
|
|
29
|
+
kind: number
|
|
30
|
+
}
|
|
31
|
+
export interface CancelEffects {
|
|
32
|
+
refunds: CancelRefund[]
|
|
33
|
+
releasedHolds: CancelReleasedHold[]
|
|
34
|
+
abandonsRunning: boolean
|
|
35
|
+
keepsPlotDeposits?: {plot: EntityRef}
|
|
36
|
+
energyForfeited?: number
|
|
37
|
+
}
|
|
38
|
+
export interface CancelPlan {
|
|
39
|
+
ok: boolean
|
|
40
|
+
blockedReason?: CancelBlockReason
|
|
41
|
+
range: {count: number; taskIndices: number[]}
|
|
42
|
+
effects: CancelEffects
|
|
43
|
+
}
|
|
44
|
+
export interface CancelEligibilityInput {
|
|
45
|
+
now: Date
|
|
46
|
+
counterparts?: Map<string, EntityInfo>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const EMPTY_EFFECTS: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
|
|
50
|
+
|
|
51
|
+
function postCancelEntity(entity: EntityInfo, laneKey: number, fromTaskIndex: number): EntityInfo {
|
|
52
|
+
const clone = (entity.constructor as typeof ServerContract.Types.entity_info).from(
|
|
53
|
+
JSON.parse(JSON.stringify(entity.toJSON()))
|
|
54
|
+
) as EntityInfo
|
|
55
|
+
const lane = clone.lanes.find((l) => l.lane_key.toNumber() === laneKey)!
|
|
56
|
+
lane.schedule.tasks = lane.schedule.tasks.slice(0, fromTaskIndex)
|
|
57
|
+
return clone
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function feasibleAfterCancel(post: EntityInfo): boolean {
|
|
61
|
+
const ordered = schedule.orderedTasks(post as unknown as Projectable)
|
|
62
|
+
const base = new Map<string, number>()
|
|
63
|
+
for (const c of post.cargo ?? []) {
|
|
64
|
+
const k = cargoKey(c)
|
|
65
|
+
base.set(k, (base.get(k) ?? 0) + c.quantity.toNumber())
|
|
66
|
+
}
|
|
67
|
+
const isConsumer = (t: Task) =>
|
|
68
|
+
t.type.toNumber() === TaskType.CRAFT || t.type.toNumber() === TaskType.UNLOAD
|
|
69
|
+
for (const self of ordered) {
|
|
70
|
+
if (!isConsumer(self.task)) continue
|
|
71
|
+
const map = new Map(base)
|
|
72
|
+
for (const other of ordered) {
|
|
73
|
+
if (other.completesAt.getTime() >= self.completesAt.getTime()) continue
|
|
74
|
+
for (const out of taskCargoEffect(other.task).added) {
|
|
75
|
+
map.set(cargoKey(out), (map.get(cargoKey(out)) ?? 0) + out.quantity.toNumber())
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const other of ordered) {
|
|
79
|
+
if (other === self) continue
|
|
80
|
+
for (const inp of taskCargoEffect(other.task).removed) {
|
|
81
|
+
const cur = map.get(cargoKey(inp)) ?? 0
|
|
82
|
+
map.set(cargoKey(inp), Math.max(0, cur - inp.quantity.toNumber()))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const inp of taskCargoEffect(self.task).removed) {
|
|
86
|
+
if ((map.get(cargoKey(inp)) ?? 0) < inp.quantity.toNumber()) return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
validateSchedule(post as unknown as Projectable)
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Timing {
|
|
98
|
+
startsAt: number
|
|
99
|
+
completesAt: number
|
|
100
|
+
running: boolean
|
|
101
|
+
done: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function laneTiming(
|
|
105
|
+
lane: {schedule: {started: {toDate(): Date}; tasks: Task[]}},
|
|
106
|
+
nowMs: number
|
|
107
|
+
): Timing[] {
|
|
108
|
+
const startedMs = lane.schedule.started.toDate().getTime()
|
|
109
|
+
let endSec = 0
|
|
110
|
+
return lane.schedule.tasks.map((t) => {
|
|
111
|
+
const startsAt = startedMs + endSec * 1000
|
|
112
|
+
endSec += t.duration.toNumber()
|
|
113
|
+
const completesAt = startedMs + endSec * 1000
|
|
114
|
+
return {
|
|
115
|
+
startsAt,
|
|
116
|
+
completesAt,
|
|
117
|
+
running: nowMs >= startsAt && nowMs < completesAt,
|
|
118
|
+
done: nowMs >= completesAt,
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function cancelEligibility(
|
|
124
|
+
entity: EntityInfo,
|
|
125
|
+
laneKey: number,
|
|
126
|
+
fromTaskIndex: number,
|
|
127
|
+
input: CancelEligibilityInput
|
|
128
|
+
): CancelPlan {
|
|
129
|
+
const lane = (entity.lanes ?? []).find((l) => l.lane_key.equals(laneKey))
|
|
130
|
+
if (!lane || fromTaskIndex < 0 || fromTaskIndex >= lane.schedule.tasks.length) {
|
|
131
|
+
return {ok: false, range: {count: 0, taskIndices: []}, effects: {...EMPTY_EFFECTS}}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tasks = lane.schedule.tasks
|
|
135
|
+
const timing = laneTiming(lane, input.now.getTime())
|
|
136
|
+
const taskIndices: number[] = []
|
|
137
|
+
for (let i = fromTaskIndex; i < tasks.length; i++) taskIndices.push(i)
|
|
138
|
+
const range = {count: taskIndices.length, taskIndices}
|
|
139
|
+
|
|
140
|
+
const block = (blockedReason: CancelBlockReason): CancelPlan => ({
|
|
141
|
+
ok: false,
|
|
142
|
+
blockedReason,
|
|
143
|
+
range,
|
|
144
|
+
effects: {...EMPTY_EFFECTS},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
for (const i of taskIndices) {
|
|
148
|
+
const t = tasks[i]
|
|
149
|
+
if (t.entitygroup && !t.entitygroup.equals(0))
|
|
150
|
+
return block(CancelBlockReason.CONTAINS_LINKED_TASK)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const i of taskIndices) {
|
|
154
|
+
const t = tasks[i]
|
|
155
|
+
if (timing[i].done) return block(CancelBlockReason.DONE)
|
|
156
|
+
if (t.cancelable.equals(TaskCancelable.NEVER) && !t.type.equals(TaskType.IDLE))
|
|
157
|
+
return block(CancelBlockReason.TASK_NEVER)
|
|
158
|
+
if (t.cancelable.equals(TaskCancelable.BEFORE_START) && timing[i].running)
|
|
159
|
+
return block(CancelBlockReason.BEFORE_START_RUNNING)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const post = postCancelEntity(entity, laneKey, fromTaskIndex)
|
|
163
|
+
if (!feasibleAfterCancel(post)) return block(CancelBlockReason.WOULD_STRAND)
|
|
164
|
+
|
|
165
|
+
const effects: CancelEffects = {refunds: [], releasedHolds: [], abandonsRunning: false}
|
|
166
|
+
let energyForfeited = 0
|
|
167
|
+
for (const i of taskIndices) {
|
|
168
|
+
const t = tasks[i]
|
|
169
|
+
if (timing[i].running && t.cancelable.equals(TaskCancelable.ALWAYS))
|
|
170
|
+
effects.abandonsRunning = true
|
|
171
|
+
if (t.energy_cost) energyForfeited += t.energy_cost.toNumber()
|
|
172
|
+
if (t.type.equals(TaskType.BUILDPLOT) && t.entitytarget)
|
|
173
|
+
effects.keepsPlotDeposits = {plot: t.entitytarget}
|
|
174
|
+
if (t.hold && t.entitytarget) {
|
|
175
|
+
const counterpart = input.counterparts?.get(t.entitytarget.entity_id.toString())
|
|
176
|
+
const hold =
|
|
177
|
+
(counterpart?.holds ?? []).find((h) => h.id.equals(t.hold!)) ??
|
|
178
|
+
(entity.holds ?? []).find((h) => h.id.equals(t.hold!))
|
|
179
|
+
if (hold) {
|
|
180
|
+
const kind = hold.kind.toNumber()
|
|
181
|
+
effects.releasedHolds.push({counterpart: t.entitytarget, kind})
|
|
182
|
+
if (kind === 1 /* HOLD_PULL */) {
|
|
183
|
+
effects.refunds.push({giver: t.entitytarget, cargo: t.cargo})
|
|
184
|
+
const giver = counterpart
|
|
185
|
+
if (giver) {
|
|
186
|
+
const returned = t.cargo.reduce(
|
|
187
|
+
(s, c) => s + calcCargoItemMass(c).toNumber(),
|
|
188
|
+
0
|
|
189
|
+
)
|
|
190
|
+
const cap = giver.capacity
|
|
191
|
+
? giver.capacity.toNumber()
|
|
192
|
+
: Number.MAX_SAFE_INTEGER
|
|
193
|
+
if (giver.cargomass.toNumber() + returned > cap) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
blockedReason: CancelBlockReason.WOULD_OVERFILL,
|
|
197
|
+
range,
|
|
198
|
+
effects: {...EMPTY_EFFECTS},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (energyForfeited > 0) effects.energyForfeited = energyForfeited
|
|
207
|
+
|
|
208
|
+
return {ok: true, range, effects}
|
|
209
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {TaskType} from '../types'
|
|
2
|
+
import {createProjectedEntity, type Projectable} from './projection'
|
|
3
|
+
import {orderedTasks} from './schedule'
|
|
4
|
+
|
|
5
|
+
export function energyAtTime(entity: Projectable, now: Date): number {
|
|
6
|
+
const projected = createProjectedEntity(entity)
|
|
7
|
+
const capacity = projected.generator ? Number(projected.generator.capacity) : undefined
|
|
8
|
+
|
|
9
|
+
const clamp = (value: number): number => {
|
|
10
|
+
const floored = Math.max(0, value)
|
|
11
|
+
return capacity !== undefined ? Math.min(capacity, floored) : floored
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let running = Number(projected.energy)
|
|
15
|
+
|
|
16
|
+
const ordered = orderedTasks(entity)
|
|
17
|
+
if (ordered.length === 0) return clamp(running)
|
|
18
|
+
|
|
19
|
+
const nowMs = now.getTime()
|
|
20
|
+
|
|
21
|
+
for (const {task, startsAt} of ordered) {
|
|
22
|
+
const duration = task.duration.toNumber()
|
|
23
|
+
const elapsed = Math.min(
|
|
24
|
+
Math.max(0, Math.floor((nowMs - startsAt.getTime()) / 1000)),
|
|
25
|
+
duration
|
|
26
|
+
)
|
|
27
|
+
const complete = elapsed >= duration
|
|
28
|
+
const inProgress = !complete && elapsed > 0 && elapsed < duration
|
|
29
|
+
|
|
30
|
+
if (!complete && !inProgress) continue
|
|
31
|
+
|
|
32
|
+
const fraction = complete ? 1 : duration === 0 ? 1 : elapsed / duration
|
|
33
|
+
|
|
34
|
+
if (task.type.toNumber() === TaskType.RECHARGE) {
|
|
35
|
+
if (capacity !== undefined) {
|
|
36
|
+
running = complete ? capacity : running + (capacity - running) * fraction
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
const cost = Number(task.energy_cost ?? 0)
|
|
40
|
+
running -= cost * fraction
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
running = clamp(running)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return clamp(running)
|
|
47
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {Action, UInt64} from '@wharfkit/antelope'
|
|
2
|
+
import type {ServerContract} from '../contracts'
|
|
3
|
+
import type {ActionsManager} from '../managers/actions'
|
|
4
|
+
import {hasResolvable, type ScheduleData} from './schedule'
|
|
5
|
+
|
|
6
|
+
type EntityInfo = ServerContract.Types.entity_info
|
|
7
|
+
|
|
8
|
+
export type CounterpartLookup = (entityId: UInt64) => EntityInfo | undefined
|
|
9
|
+
|
|
10
|
+
export type IdleResolveTarget = ScheduleData & {id: UInt64}
|
|
11
|
+
|
|
12
|
+
// A hold's driving task lives on its counterpart, so a hold resolves the counterpart, never the blocker.
|
|
13
|
+
export function composeIdleResolve(
|
|
14
|
+
blocker: IdleResolveTarget,
|
|
15
|
+
action: Action,
|
|
16
|
+
actions: ActionsManager,
|
|
17
|
+
now: Date,
|
|
18
|
+
lookupCounterpart?: CounterpartLookup
|
|
19
|
+
): Action[] {
|
|
20
|
+
const ids: UInt64[] = []
|
|
21
|
+
const seen = new Set<string>()
|
|
22
|
+
|
|
23
|
+
const add = (id: UInt64) => {
|
|
24
|
+
const key = id.toString()
|
|
25
|
+
if (seen.has(key)) return
|
|
26
|
+
seen.add(key)
|
|
27
|
+
ids.push(id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (hasResolvable(blocker, now)) {
|
|
31
|
+
add(blocker.id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Without a lookup we cannot confirm the counterpart has a completed task, so skip it.
|
|
35
|
+
if (lookupCounterpart) {
|
|
36
|
+
for (const hold of blocker.holds ?? []) {
|
|
37
|
+
const counterpartId = hold.counterpart.entity_id
|
|
38
|
+
const counterpart = lookupCounterpart(counterpartId)
|
|
39
|
+
if (!counterpart || !hasResolvable(counterpart, now)) continue
|
|
40
|
+
add(counterpartId)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [...ids.map((id) => actions.resolve(id)), action]
|
|
45
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type {ServerContract} from '../contracts'
|
|
2
|
+
import type {TaskType} from '../types'
|
|
3
|
+
|
|
4
|
+
type Schedule = ServerContract.Types.schedule
|
|
5
|
+
type Task = ServerContract.Types.task
|
|
6
|
+
|
|
7
|
+
export function laneDuration(schedule: Schedule): number {
|
|
8
|
+
return schedule.tasks.reduce((sum, task) => sum + task.duration.toNumber(), 0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function laneRawElapsed(schedule: Schedule, now: Date): number {
|
|
12
|
+
const started = schedule.started.toDate()
|
|
13
|
+
return Math.floor((now.getTime() - started.getTime()) / 1000)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function laneElapsed(schedule: Schedule, now: Date): number {
|
|
17
|
+
return Math.max(0, laneRawElapsed(schedule, now))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function laneStartsIn(schedule: Schedule, now: Date): number {
|
|
21
|
+
return Math.max(0, -laneRawElapsed(schedule, now))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function laneRemaining(schedule: Schedule, now: Date): number {
|
|
25
|
+
return Math.max(0, laneDuration(schedule) - laneRawElapsed(schedule, now))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function laneComplete(schedule: Schedule, now: Date): boolean {
|
|
29
|
+
if (schedule.tasks.length === 0) return false
|
|
30
|
+
return laneRemaining(schedule, now) === 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function laneProgress(schedule: Schedule, now: Date): number {
|
|
34
|
+
const duration = laneDuration(schedule)
|
|
35
|
+
if (duration === 0) return schedule.tasks.length > 0 ? 1 : 0
|
|
36
|
+
return Math.min(1, laneElapsed(schedule, now) / duration)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function currentTaskIndexForLane(schedule: Schedule, now: Date): number {
|
|
40
|
+
if (schedule.tasks.length === 0) return -1
|
|
41
|
+
if (laneRawElapsed(schedule, now) < 0) return -1
|
|
42
|
+
const elapsed = laneElapsed(schedule, now)
|
|
43
|
+
let timeAccum = 0
|
|
44
|
+
for (let i = 0; i < schedule.tasks.length; i++) {
|
|
45
|
+
const taskDuration = schedule.tasks[i].duration.toNumber()
|
|
46
|
+
if (elapsed < timeAccum + taskDuration) return i
|
|
47
|
+
timeAccum += taskDuration
|
|
48
|
+
}
|
|
49
|
+
return -1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function currentTask(schedule: Schedule, now: Date): Task | undefined {
|
|
53
|
+
const index = currentTaskIndexForLane(schedule, now)
|
|
54
|
+
if (index < 0) return undefined
|
|
55
|
+
return schedule.tasks[index]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function currentTaskType(schedule: Schedule, now: Date): TaskType | undefined {
|
|
59
|
+
const task = currentTask(schedule, now)
|
|
60
|
+
return task ? (task.type.toNumber() as TaskType) : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function laneTaskStartTime(schedule: Schedule, index: number): number {
|
|
64
|
+
if (index < 0 || index >= schedule.tasks.length) return 0
|
|
65
|
+
let timeAccum = 0
|
|
66
|
+
for (let i = 0; i < index; i++) {
|
|
67
|
+
timeAccum += schedule.tasks[i].duration.toNumber()
|
|
68
|
+
}
|
|
69
|
+
return timeAccum
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function laneTaskElapsed(schedule: Schedule, index: number, now: Date): number {
|
|
73
|
+
if (index < 0 || index >= schedule.tasks.length) return 0
|
|
74
|
+
const elapsed = laneElapsed(schedule, now)
|
|
75
|
+
const taskStart = laneTaskStartTime(schedule, index)
|
|
76
|
+
const taskDuration = schedule.tasks[index].duration.toNumber()
|
|
77
|
+
if (elapsed <= taskStart) return 0
|
|
78
|
+
return Math.min(elapsed - taskStart, taskDuration)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function laneTaskRemaining(schedule: Schedule, index: number, now: Date): number {
|
|
82
|
+
if (index < 0 || index >= schedule.tasks.length) return 0
|
|
83
|
+
const taskDuration = schedule.tasks[index].duration.toNumber()
|
|
84
|
+
return Math.max(0, taskDuration - laneTaskElapsed(schedule, index, now))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function laneTaskComplete(schedule: Schedule, index: number, now: Date): boolean {
|
|
88
|
+
if (index < 0 || index >= schedule.tasks.length) return false
|
|
89
|
+
const taskDuration = schedule.tasks[index].duration.toNumber()
|
|
90
|
+
return laneTaskElapsed(schedule, index, now) >= taskDuration
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function laneTaskInProgress(schedule: Schedule, index: number, now: Date): boolean {
|
|
94
|
+
if (index < 0 || index >= schedule.tasks.length) return false
|
|
95
|
+
const taskElapsed = laneTaskElapsed(schedule, index, now)
|
|
96
|
+
const taskDuration = schedule.tasks[index].duration.toNumber()
|
|
97
|
+
return taskElapsed > 0 && taskElapsed < taskDuration
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function laneCompletesAt(schedule: Schedule, index: number): Date {
|
|
101
|
+
const startedMs = schedule.started.toDate().getTime()
|
|
102
|
+
const endSec =
|
|
103
|
+
laneTaskStartTime(schedule, index) + (schedule.tasks[index]?.duration.toNumber() ?? 0)
|
|
104
|
+
return new Date(startedMs + endSec * 1000)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function currentTaskProgress(schedule: Schedule, now: Date): number {
|
|
108
|
+
const index = currentTaskIndexForLane(schedule, now)
|
|
109
|
+
if (index < 0) return 0
|
|
110
|
+
const elapsed = laneTaskElapsed(schedule, index, now)
|
|
111
|
+
const duration = schedule.tasks[index].duration.toNumber()
|
|
112
|
+
if (duration === 0) return 1
|
|
113
|
+
return Math.min(1, elapsed / duration)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function currentTaskProgressFloatForLane(schedule: Schedule, now: Date): number {
|
|
117
|
+
if (schedule.tasks.length === 0) return 0
|
|
118
|
+
const index = currentTaskIndexForLane(schedule, now)
|
|
119
|
+
if (index < 0) return 0
|
|
120
|
+
const task = schedule.tasks[index]
|
|
121
|
+
const durationMs = task.duration.toNumber() * 1000
|
|
122
|
+
if (durationMs === 0) return 1
|
|
123
|
+
const startedMs = schedule.started.toDate().getTime()
|
|
124
|
+
const taskStartMs = startedMs + laneTaskStartTime(schedule, index) * 1000
|
|
125
|
+
const elapsedMs = now.getTime() - taskStartMs
|
|
126
|
+
if (elapsedMs <= 0) return 0
|
|
127
|
+
return Math.min(1, elapsedMs / durationMs)
|
|
128
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {UInt8, UInt16, UInt64} from '@wharfkit/antelope'
|
|
2
|
+
import {expect, test} from 'bun:test'
|
|
3
|
+
import {encodeStats} from '../derivation/crafting'
|
|
4
|
+
import {computeGathererYield, computeGathererDepth, computeGathererDrain} from '../nft/description'
|
|
5
|
+
import {computeLoaderThrust, computeLoaderMass} from '../nft/description'
|
|
6
|
+
import {computeCrafterSpeed, computeCrafterDrain} from '../nft/description'
|
|
7
|
+
import {applySlotMultiplier} from '../entities/slot-multiplier'
|
|
8
|
+
import {getSlotAmp} from '../entities/slot-multiplier'
|
|
9
|
+
import {getEntityLayout} from '../data/recipes-runtime'
|
|
10
|
+
import {ITEM_GATHERER_T1, ITEM_LOADER_T1, ITEM_CRAFTER_T1} from '../data/item-ids'
|
|
11
|
+
import {
|
|
12
|
+
workerLaneKey,
|
|
13
|
+
resolveLaneGatherer,
|
|
14
|
+
resolveLaneCrafter,
|
|
15
|
+
resolveLaneLoader,
|
|
16
|
+
selectGatherLane,
|
|
17
|
+
} from './lanes'
|
|
18
|
+
import {LANE_MOBILITY} from './schedule'
|
|
19
|
+
import type {ServerContract} from '../contracts'
|
|
20
|
+
|
|
21
|
+
type ModuleEntry = ServerContract.Types.module_entry
|
|
22
|
+
type Lane = ServerContract.Types.lane
|
|
23
|
+
|
|
24
|
+
// Real gatherer-bearing entity: extractor 10203 has [generator@0, gatherer@1].
|
|
25
|
+
const EXTRACTOR_GATHERER = 10203
|
|
26
|
+
const GATHERER_SLOT_IDX = 1
|
|
27
|
+
const GATHERER_LANE_KEY = GATHERER_SLOT_IDX + 1 // = 2
|
|
28
|
+
|
|
29
|
+
function makeModuleEntry(itemId: number, stats: bigint): ModuleEntry {
|
|
30
|
+
return {
|
|
31
|
+
type: UInt8.from(0),
|
|
32
|
+
installed: {
|
|
33
|
+
item_id: UInt16.from(itemId),
|
|
34
|
+
stats: UInt64.from(stats),
|
|
35
|
+
},
|
|
36
|
+
} as unknown as ModuleEntry
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeBusyLane(laneKey: number): Lane {
|
|
40
|
+
return {
|
|
41
|
+
lane_key: UInt8.from(laneKey),
|
|
42
|
+
schedule: {
|
|
43
|
+
started: {toDate: () => new Date()} as any,
|
|
44
|
+
tasks: [{duration: UInt64.from(100)} as any],
|
|
45
|
+
},
|
|
46
|
+
} as unknown as Lane
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// gatherer: stats = [str=300, tol=200, con=400], tier=1
|
|
50
|
+
const GATH_STR = 300
|
|
51
|
+
const GATH_TOL = 200
|
|
52
|
+
const GATH_CON = 400
|
|
53
|
+
const gathererStats1 = encodeStats([GATH_STR, GATH_TOL, GATH_CON])
|
|
54
|
+
|
|
55
|
+
// loader: stats = [ins=300, pla=500]
|
|
56
|
+
const LOADER_INS = 300
|
|
57
|
+
const LOADER_PLA = 500
|
|
58
|
+
const loaderStats = encodeStats([LOADER_INS, LOADER_PLA])
|
|
59
|
+
|
|
60
|
+
// crafter: stats = [rea=400, fin=300]
|
|
61
|
+
const CRAFTER_REA = 400
|
|
62
|
+
const CRAFTER_FIN = 300
|
|
63
|
+
const crafterStats = encodeStats([CRAFTER_REA, CRAFTER_FIN])
|
|
64
|
+
|
|
65
|
+
// --- resolveLaneGatherer ---
|
|
66
|
+
|
|
67
|
+
test('resolveLaneGatherer reads the layout slot amp and applies it to yield (parity formula)', () => {
|
|
68
|
+
// Gatherer at the entity's real gatherer slot (index 1 => laneKey 2); amp comes from getEntityLayout(10203).
|
|
69
|
+
const modules: ModuleEntry[] = [
|
|
70
|
+
makeModuleEntry(ITEM_GATHERER_T1, gathererStats1), // slot 0 (generator slot, ignored)
|
|
71
|
+
makeModuleEntry(ITEM_GATHERER_T1, gathererStats1), // slot 1 (gatherer slot)
|
|
72
|
+
]
|
|
73
|
+
const result = resolveLaneGatherer(modules, EXTRACTOR_GATHERER, GATHERER_LANE_KEY)
|
|
74
|
+
|
|
75
|
+
const layout = getEntityLayout(EXTRACTOR_GATHERER)?.slots ?? []
|
|
76
|
+
const ampFromLayout = getSlotAmp(layout, GATHERER_SLOT_IDX)
|
|
77
|
+
|
|
78
|
+
// The resolver routes through the real layout's amp, not a hardcoded 100.
|
|
79
|
+
expect(result.outputPct).toBe(ampFromLayout)
|
|
80
|
+
// Yield equals the contract formula clamp_to_uint16(compute_gatherer_yield(str) * amp / 100).
|
|
81
|
+
expect(result.yield).toBe(applySlotMultiplier(computeGathererYield(GATH_STR), ampFromLayout))
|
|
82
|
+
expect(result.drain).toBe(computeGathererDrain(GATH_CON))
|
|
83
|
+
expect(result.depth).toBe(computeGathererDepth(GATH_TOL, 1))
|
|
84
|
+
expect(result.slotIndex).toBe(GATHERER_SLOT_IDX)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('resolveLaneGatherer amp-scaling parity holds for a non-100 amp', () => {
|
|
88
|
+
// Parity for a non-100 amp: clamp_to_uint16(value * amp / 100).
|
|
89
|
+
expect(applySlotMultiplier(computeGathererYield(GATH_STR), 80)).toBe(
|
|
90
|
+
Math.min(Math.floor((computeGathererYield(GATH_STR) * 80) / 100), 65535)
|
|
91
|
+
)
|
|
92
|
+
expect(applySlotMultiplier(computeGathererYield(GATH_STR), 120)).toBe(
|
|
93
|
+
Math.floor((computeGathererYield(GATH_STR) * 120) / 100)
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('resolveLaneGatherer throws on out-of-range laneKey', () => {
|
|
98
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, gathererStats1)]
|
|
99
|
+
expect(() => resolveLaneGatherer(modules, ITEM_GATHERER_T1, 5)).toThrow(
|
|
100
|
+
'gatherer lane has no module'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('resolveLaneGatherer throws on laneKey=0 (slot=255 off-by-one boundary)', () => {
|
|
105
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, gathererStats1)]
|
|
106
|
+
expect(() => resolveLaneGatherer(modules, ITEM_GATHERER_T1, 0)).toThrow(
|
|
107
|
+
'gatherer lane has no module'
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// --- resolveLaneCrafter ---
|
|
112
|
+
|
|
113
|
+
test('resolveLaneCrafter returns correct stats for slot 0 (laneKey=1)', () => {
|
|
114
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
115
|
+
const result = resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 1)
|
|
116
|
+
const layout = getEntityLayout(ITEM_CRAFTER_T1)?.slots ?? []
|
|
117
|
+
const amp = getSlotAmp(layout, 0)
|
|
118
|
+
expect(result.slotIndex).toBe(0)
|
|
119
|
+
expect(result.speed).toBe(applySlotMultiplier(computeCrafterSpeed(CRAFTER_REA), amp))
|
|
120
|
+
expect(result.drain).toBe(computeCrafterDrain(CRAFTER_FIN))
|
|
121
|
+
expect(result.outputPct).toBe(amp)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('resolveLaneCrafter throws on out-of-range laneKey', () => {
|
|
125
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
126
|
+
expect(() => resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 5)).toThrow(
|
|
127
|
+
'crafter lane has no module'
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('resolveLaneCrafter throws on laneKey=0 (slot=255 off-by-one boundary)', () => {
|
|
132
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
133
|
+
expect(() => resolveLaneCrafter(modules, ITEM_CRAFTER_T1, 0)).toThrow(
|
|
134
|
+
'crafter lane has no module'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// --- resolveLaneLoader ---
|
|
139
|
+
|
|
140
|
+
test('resolveLaneLoader returns correct stats for slot 0 (laneKey=1)', () => {
|
|
141
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
|
|
142
|
+
const result = resolveLaneLoader(modules, ITEM_LOADER_T1, 1)
|
|
143
|
+
const layout = getEntityLayout(ITEM_LOADER_T1)?.slots ?? []
|
|
144
|
+
const amp = getSlotAmp(layout, 0)
|
|
145
|
+
expect(result.valid).toBe(true)
|
|
146
|
+
expect(result.slotIndex).toBe(0)
|
|
147
|
+
expect(result.thrust).toBe(applySlotMultiplier(computeLoaderThrust(LOADER_PLA), amp))
|
|
148
|
+
expect(result.mass).toBe(computeLoaderMass(LOADER_INS))
|
|
149
|
+
expect(result.outputPct).toBe(amp)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('resolveLaneLoader soft-returns invalid for LANE_MOBILITY (key 0), never throws', () => {
|
|
153
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
|
|
154
|
+
const result = resolveLaneLoader(modules, ITEM_LOADER_T1, LANE_MOBILITY)
|
|
155
|
+
expect(result.valid).toBe(false)
|
|
156
|
+
expect(result.thrust).toBe(0)
|
|
157
|
+
expect(result.mass).toBe(0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('resolveLaneLoader soft-returns invalid for a missing/out-of-range module, never throws', () => {
|
|
161
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_LOADER_T1, loaderStats)]
|
|
162
|
+
const result = resolveLaneLoader(modules, ITEM_LOADER_T1, 5)
|
|
163
|
+
expect(result.valid).toBe(false)
|
|
164
|
+
expect(result.thrust).toBe(0)
|
|
165
|
+
expect(result.mass).toBe(0)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// --- depth-aware workerLaneKey ---
|
|
169
|
+
|
|
170
|
+
test('workerLaneKey with stratum picks first-free reaching gatherer', () => {
|
|
171
|
+
const shallowStats = encodeStats([100, 200, 100]) // tol=200 => depth=1500 for tier1
|
|
172
|
+
const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
|
|
173
|
+
const modules: ModuleEntry[] = [
|
|
174
|
+
makeModuleEntry(ITEM_GATHERER_T1, shallowStats),
|
|
175
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
176
|
+
]
|
|
177
|
+
const laneKey = workerLaneKey(modules, 'gatherer', [], 2000)
|
|
178
|
+
expect(laneKey).toBe(2) // slot 1 => laneKey 2
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('workerLaneKey with stratum: first free reaching is preferred over busy reaching', () => {
|
|
182
|
+
const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
|
|
183
|
+
const modules: ModuleEntry[] = [
|
|
184
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
185
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
186
|
+
]
|
|
187
|
+
const lanes: Lane[] = [makeBusyLane(1)] // slot 0 is busy
|
|
188
|
+
const laneKey = workerLaneKey(modules, 'gatherer', lanes, 2000)
|
|
189
|
+
expect(laneKey).toBe(2) // slot 1 (laneKey=2) is free and reaching
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('workerLaneKey with stratum: returns lowest reaching (busy) when all reaching are busy', () => {
|
|
193
|
+
const deepStats = encodeStats([100, 900, 100]) // tol=900 => depth=5000 for tier1
|
|
194
|
+
const modules: ModuleEntry[] = [
|
|
195
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
196
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
197
|
+
]
|
|
198
|
+
const lanes: Lane[] = [makeBusyLane(1), makeBusyLane(2)]
|
|
199
|
+
const laneKey = workerLaneKey(modules, 'gatherer', lanes, 2000)
|
|
200
|
+
expect(laneKey).toBe(1) // lowest reaching is slot 0 => laneKey 1
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('workerLaneKey with stratum throws "no gatherer reaches this stratum" when none reach', () => {
|
|
204
|
+
const shallowStats = encodeStats([100, 10, 100]) // tol=10 => depth=550 for tier1
|
|
205
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
|
|
206
|
+
expect(() => workerLaneKey(modules, 'gatherer', [], 5000)).toThrow(
|
|
207
|
+
'no gatherer reaches this stratum'
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('workerLaneKey without stratum still works (non-gatherer path)', () => {
|
|
212
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_CRAFTER_T1, crafterStats)]
|
|
213
|
+
const laneKey = workerLaneKey(modules, 'crafter', [])
|
|
214
|
+
expect(laneKey).toBe(1)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// --- selectGatherLane: both real error paths ---
|
|
218
|
+
|
|
219
|
+
test('selectGatherLane auto-pick returns the depth-aware lane', () => {
|
|
220
|
+
const shallowStats = encodeStats([100, 200, 100]) // depth=1500 for tier1
|
|
221
|
+
const deepStats = encodeStats([100, 900, 100]) // depth=5000 for tier1
|
|
222
|
+
const modules: ModuleEntry[] = [
|
|
223
|
+
makeModuleEntry(ITEM_GATHERER_T1, shallowStats),
|
|
224
|
+
makeModuleEntry(ITEM_GATHERER_T1, deepStats),
|
|
225
|
+
]
|
|
226
|
+
expect(selectGatherLane(modules, ITEM_GATHERER_T1, [], 2000)).toBe(2)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('selectGatherLane auto-pick throws "no gatherer reaches this stratum" when none reach', () => {
|
|
230
|
+
const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
|
|
231
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
|
|
232
|
+
expect(() => selectGatherLane(modules, ITEM_GATHERER_T1, [], 5000)).toThrow(
|
|
233
|
+
'no gatherer reaches this stratum'
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('selectGatherLane explicit slot returns its laneKey when stratum is within depth', () => {
|
|
238
|
+
const deepStats = encodeStats([100, 900, 100]) // depth=5000 for tier1
|
|
239
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, deepStats)]
|
|
240
|
+
expect(selectGatherLane(modules, ITEM_GATHERER_T1, [], 2000, 0)).toBe(1) // slot 0 => laneKey 1
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('selectGatherLane explicit slot too shallow throws "stratum exceeds gatherer depth"', () => {
|
|
244
|
+
const shallowStats = encodeStats([100, 10, 100]) // depth=550 for tier1
|
|
245
|
+
const modules: ModuleEntry[] = [makeModuleEntry(ITEM_GATHERER_T1, shallowStats)]
|
|
246
|
+
expect(() => selectGatherLane(modules, ITEM_GATHERER_T1, [], 5000, 0)).toThrow(
|
|
247
|
+
'stratum exceeds gatherer depth'
|
|
248
|
+
)
|
|
249
|
+
})
|