@shipload/sdk 1.0.0-next.34 → 1.0.0-next.35
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 +194 -4
- package/lib/shipload.js +708 -8
- package/lib/shipload.js.map +1 -1
- package/lib/shipload.m.js +689 -9
- package/lib/shipload.m.js.map +1 -1
- package/lib/testing.d.ts +35 -0
- package/lib/testing.js +107 -1
- package/lib/testing.js.map +1 -1
- package/lib/testing.m.js +107 -1
- package/lib/testing.m.js.map +1 -1
- package/package.json +1 -1
- package/src/contracts/server.ts +103 -1
- package/src/coordinates/address.ts +84 -0
- package/src/coordinates/constants.ts +21 -0
- package/src/coordinates/index.ts +4 -0
- package/src/coordinates/permutation.ts +77 -0
- package/src/coordinates/regions.ts +48 -0
- package/src/coordinates/sectors.ts +115 -0
- package/src/derivation/wormhole.ts +115 -0
- package/src/errors.ts +2 -0
- package/src/index-module.ts +23 -0
- package/src/managers/actions.ts +45 -2
- package/src/scheduling/availability.ts +1 -1
- package/src/scheduling/cancel.test.ts +327 -0
- package/src/scheduling/cancel.ts +209 -0
- package/src/scheduling/projection.ts +2 -0
- package/src/scheduling/schedule.ts +3 -1
- package/src/travel/travel.ts +14 -4
- package/src/types.ts +1 -0
- package/src/utils/system.ts +11 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {ServerContract, TaskType, TaskCancelable} from '../index-module'
|
|
3
|
+
import {cancelEligibility, CancelBlockReason} from './cancel'
|
|
4
|
+
|
|
5
|
+
const T0 = '2026-06-19T00:00:00'
|
|
6
|
+
|
|
7
|
+
function task(over: Partial<{type: number; duration: number; cancelable: number; group: number}>) {
|
|
8
|
+
return ServerContract.Types.task.from({
|
|
9
|
+
type: over.type ?? TaskType.TRAVEL,
|
|
10
|
+
duration: over.duration ?? 100,
|
|
11
|
+
cancelable: over.cancelable ?? TaskCancelable.BEFORE_START,
|
|
12
|
+
cargo: [],
|
|
13
|
+
...(over.group ? {entitygroup: over.group} : {}),
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function entity(tasks: ReturnType<typeof task>[], startedISO = T0) {
|
|
18
|
+
return ServerContract.Types.entity_info.from({
|
|
19
|
+
type: 'ship',
|
|
20
|
+
id: 1,
|
|
21
|
+
owner: 'player.gm',
|
|
22
|
+
entity_name: 'Ship 1',
|
|
23
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
24
|
+
item_id: 1,
|
|
25
|
+
cargomass: 0,
|
|
26
|
+
cargo: [],
|
|
27
|
+
modules: [],
|
|
28
|
+
lanes: [{lane_key: 0, schedule: {started: startedISO, tasks}}],
|
|
29
|
+
holds: [],
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('cancelEligibility — local gates', () => {
|
|
34
|
+
const now = new Date('2026-06-19T00:00:10.000Z') // 10s in: task 0 running, task 1 upcoming
|
|
35
|
+
|
|
36
|
+
test('cancelling the last upcoming task: ok, count 1', () => {
|
|
37
|
+
const e = entity([task({}), task({})])
|
|
38
|
+
const plan = cancelEligibility(e, 0, 1, {now})
|
|
39
|
+
expect(plan.ok).toBe(true)
|
|
40
|
+
expect(plan.range.count).toBe(1)
|
|
41
|
+
expect(plan.range.taskIndices).toEqual([1])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('NEVER task is blocked', () => {
|
|
45
|
+
const e = entity([
|
|
46
|
+
task({}),
|
|
47
|
+
task({cancelable: TaskCancelable.NEVER, type: TaskType.GATHER}),
|
|
48
|
+
])
|
|
49
|
+
expect(cancelEligibility(e, 0, 1, {now}).blockedReason).toBe(CancelBlockReason.TASK_NEVER)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('BEFORE_START task that is running is blocked', () => {
|
|
53
|
+
const e = entity([task({cancelable: TaskCancelable.BEFORE_START, duration: 100})])
|
|
54
|
+
// task 0 is running at now=10s
|
|
55
|
+
expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
|
|
56
|
+
CancelBlockReason.BEFORE_START_RUNNING
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('done task is blocked', () => {
|
|
61
|
+
const e = entity([task({duration: 5})]) // completes at 5s, now=10s
|
|
62
|
+
expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(CancelBlockReason.DONE)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('unknown lane: count 0, ok false', () => {
|
|
66
|
+
const e = entity([task({})])
|
|
67
|
+
const plan = cancelEligibility(e, 9, 0, {now})
|
|
68
|
+
expect(plan.ok).toBe(false)
|
|
69
|
+
expect(plan.range.count).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('multi-task range: 4 tasks, fromIndex 1 yields count 3', () => {
|
|
73
|
+
const e = entity([
|
|
74
|
+
task({duration: 100}),
|
|
75
|
+
task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
|
|
76
|
+
task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
|
|
77
|
+
task({cancelable: TaskCancelable.ALWAYS, duration: 100}),
|
|
78
|
+
])
|
|
79
|
+
const plan = cancelEligibility(e, 0, 1, {now})
|
|
80
|
+
expect(plan.ok).toBe(true)
|
|
81
|
+
expect(plan.range.count).toBe(3)
|
|
82
|
+
expect(plan.range.taskIndices).toEqual([1, 2, 3])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('cancelEligibility — linked tasks', () => {
|
|
87
|
+
const now = new Date('2026-06-19T00:00:10.000Z')
|
|
88
|
+
test('range containing a linked (entitygroup) task is blocked', () => {
|
|
89
|
+
const e = entity([task({}), task({group: 42}), task({})])
|
|
90
|
+
expect(cancelEligibility(e, 0, 0, {now}).blockedReason).toBe(
|
|
91
|
+
CancelBlockReason.CONTAINS_LINKED_TASK
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
test('task after the linked one cancels normally', () => {
|
|
95
|
+
const e = entity([task({}), task({group: 42}), task({})])
|
|
96
|
+
expect(cancelEligibility(e, 0, 2, {now}).ok).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const HOLD_PULL = 1
|
|
101
|
+
function loadTask(giverType: string, giverId: number, holdId: number, qty: number) {
|
|
102
|
+
return ServerContract.Types.task.from({
|
|
103
|
+
type: TaskType.LOAD,
|
|
104
|
+
duration: 100,
|
|
105
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
106
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: qty}],
|
|
107
|
+
entitytarget: {entity_type: giverType, entity_id: giverId},
|
|
108
|
+
hold: holdId,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('cancelEligibility — effects', () => {
|
|
113
|
+
const now = new Date('2026-06-19T00:00:10.000Z')
|
|
114
|
+
|
|
115
|
+
test('abandonsRunning true when the front of range is a running ALWAYS task', () => {
|
|
116
|
+
const e = entity([
|
|
117
|
+
task({cancelable: TaskCancelable.ALWAYS, type: TaskType.RECHARGE, duration: 100}),
|
|
118
|
+
])
|
|
119
|
+
expect(cancelEligibility(e, 0, 0, {now}).effects.abandonsRunning).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('buildplot cancel reports keepsPlotDeposits', () => {
|
|
123
|
+
const e = entity([
|
|
124
|
+
ServerContract.Types.task.from({
|
|
125
|
+
type: TaskType.BUILDPLOT,
|
|
126
|
+
duration: 100,
|
|
127
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
128
|
+
cargo: [],
|
|
129
|
+
entitytarget: {entity_type: 'plot', entity_id: 55},
|
|
130
|
+
}),
|
|
131
|
+
])
|
|
132
|
+
const plan = cancelEligibility(e, 0, 0, {now})
|
|
133
|
+
expect(plan.effects.keepsPlotDeposits?.plot.entity_id.toNumber()).toBe(55)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('PULL load cancel refunds cargo to the giver', () => {
|
|
137
|
+
const lt = loadTask('warehouse', 6, 1, 4)
|
|
138
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
139
|
+
const e = ServerContract.Types.entity_info.from({
|
|
140
|
+
type: 'ship',
|
|
141
|
+
id: 1,
|
|
142
|
+
owner: 'player.gm',
|
|
143
|
+
entity_name: 'Ship 1',
|
|
144
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
145
|
+
item_id: 1,
|
|
146
|
+
cargomass: 0,
|
|
147
|
+
cargo: [],
|
|
148
|
+
modules: [],
|
|
149
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
150
|
+
holds: [
|
|
151
|
+
{
|
|
152
|
+
id: 1,
|
|
153
|
+
kind: HOLD_PULL,
|
|
154
|
+
counterpart: {entity_type: 'warehouse', entity_id: 6},
|
|
155
|
+
until: T0,
|
|
156
|
+
incoming_mass: 0,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
161
|
+
expect(plan.effects.refunds[0]?.giver.entity_id.toNumber()).toBe(6)
|
|
162
|
+
expect(plan.effects.refunds[0]?.cargo[0].quantity.toNumber()).toBe(4)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('PULL load cancel populates releasedHolds with kind and counterpart', () => {
|
|
166
|
+
const lt = loadTask('warehouse', 6, 1, 4)
|
|
167
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
168
|
+
const e = ServerContract.Types.entity_info.from({
|
|
169
|
+
type: 'ship',
|
|
170
|
+
id: 1,
|
|
171
|
+
owner: 'player.gm',
|
|
172
|
+
entity_name: 'Ship 1',
|
|
173
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
174
|
+
item_id: 1,
|
|
175
|
+
cargomass: 0,
|
|
176
|
+
cargo: [],
|
|
177
|
+
modules: [],
|
|
178
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
179
|
+
holds: [
|
|
180
|
+
{
|
|
181
|
+
id: 1,
|
|
182
|
+
kind: HOLD_PULL,
|
|
183
|
+
counterpart: {entity_type: 'warehouse', entity_id: 6},
|
|
184
|
+
until: T0,
|
|
185
|
+
incoming_mass: 0,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
})
|
|
189
|
+
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
190
|
+
expect(plan.effects.releasedHolds[0]?.kind).toBe(1)
|
|
191
|
+
expect(plan.effects.releasedHolds[0]?.counterpart.entity_id.toNumber()).toBe(6)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('unresolvable hold emits no releasedHolds entry', () => {
|
|
195
|
+
const lt = loadTask('warehouse', 6, 99, 4)
|
|
196
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
197
|
+
const e = ServerContract.Types.entity_info.from({
|
|
198
|
+
type: 'ship',
|
|
199
|
+
id: 1,
|
|
200
|
+
owner: 'player.gm',
|
|
201
|
+
entity_name: 'Ship 1',
|
|
202
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
203
|
+
item_id: 1,
|
|
204
|
+
cargomass: 0,
|
|
205
|
+
cargo: [],
|
|
206
|
+
modules: [],
|
|
207
|
+
lanes: [{lane_key: 0, schedule: {started: T0, tasks: [lt]}}],
|
|
208
|
+
holds: [],
|
|
209
|
+
})
|
|
210
|
+
const plan = cancelEligibility(e, 0, 0, {now: upcoming})
|
|
211
|
+
expect(plan.effects.releasedHolds).toHaveLength(0)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('cancelEligibility — feasibility', () => {
|
|
216
|
+
const upcoming = new Date('2026-06-18T23:59:50.000Z')
|
|
217
|
+
|
|
218
|
+
test('WOULD_STRAND when cancelling a producer a later consumer needs', () => {
|
|
219
|
+
const producer = ServerContract.Types.task.from({
|
|
220
|
+
type: TaskType.LOAD,
|
|
221
|
+
duration: 50,
|
|
222
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
223
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
|
|
224
|
+
})
|
|
225
|
+
const consumer = ServerContract.Types.task.from({
|
|
226
|
+
type: TaskType.CRAFT,
|
|
227
|
+
duration: 50,
|
|
228
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
229
|
+
cargo: [
|
|
230
|
+
{item_id: 7, stats: 0, modules: [], quantity: 2},
|
|
231
|
+
{item_id: 9, stats: 0, modules: [], quantity: 1},
|
|
232
|
+
],
|
|
233
|
+
})
|
|
234
|
+
const e = ServerContract.Types.entity_info.from({
|
|
235
|
+
type: 'ship',
|
|
236
|
+
id: 1,
|
|
237
|
+
owner: 'player.gm',
|
|
238
|
+
entity_name: 'S',
|
|
239
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
240
|
+
item_id: 1,
|
|
241
|
+
cargomass: 0,
|
|
242
|
+
cargo: [],
|
|
243
|
+
modules: [],
|
|
244
|
+
lanes: [
|
|
245
|
+
{
|
|
246
|
+
lane_key: 1,
|
|
247
|
+
schedule: {started: '2026-06-19T00:00:00', tasks: [producer]},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
lane_key: 2,
|
|
251
|
+
schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]},
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
holds: [],
|
|
255
|
+
})
|
|
256
|
+
expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
|
|
257
|
+
CancelBlockReason.WOULD_STRAND
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('benign cancel on independent lane does NOT strand', () => {
|
|
262
|
+
const producer = ServerContract.Types.task.from({
|
|
263
|
+
type: TaskType.LOAD,
|
|
264
|
+
duration: 50,
|
|
265
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
266
|
+
cargo: [{item_id: 7, stats: 0, modules: [], quantity: 2}],
|
|
267
|
+
})
|
|
268
|
+
const independent = ServerContract.Types.task.from({
|
|
269
|
+
type: TaskType.TRAVEL,
|
|
270
|
+
duration: 50,
|
|
271
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
272
|
+
cargo: [],
|
|
273
|
+
})
|
|
274
|
+
const e = ServerContract.Types.entity_info.from({
|
|
275
|
+
type: 'ship',
|
|
276
|
+
id: 1,
|
|
277
|
+
owner: 'player.gm',
|
|
278
|
+
entity_name: 'S',
|
|
279
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
280
|
+
item_id: 1,
|
|
281
|
+
cargomass: 0,
|
|
282
|
+
cargo: [],
|
|
283
|
+
modules: [],
|
|
284
|
+
lanes: [
|
|
285
|
+
{lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
|
|
286
|
+
{lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [independent]}},
|
|
287
|
+
],
|
|
288
|
+
holds: [],
|
|
289
|
+
})
|
|
290
|
+
expect(cancelEligibility(e, 2, 0, {now: upcoming}).ok).toBe(true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('WOULD_STRAND when cancelling producer of a MODULAR cargo the consumer needs', () => {
|
|
294
|
+
const moduledCargo = {item_id: 7, stats: 0, modules: [{type: 3}], quantity: 2}
|
|
295
|
+
const producer = ServerContract.Types.task.from({
|
|
296
|
+
type: TaskType.LOAD,
|
|
297
|
+
duration: 50,
|
|
298
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
299
|
+
cargo: [moduledCargo],
|
|
300
|
+
})
|
|
301
|
+
const consumer = ServerContract.Types.task.from({
|
|
302
|
+
type: TaskType.CRAFT,
|
|
303
|
+
duration: 50,
|
|
304
|
+
cancelable: TaskCancelable.ALWAYS,
|
|
305
|
+
cargo: [moduledCargo, {item_id: 9, stats: 0, modules: [], quantity: 1}],
|
|
306
|
+
})
|
|
307
|
+
const e = ServerContract.Types.entity_info.from({
|
|
308
|
+
type: 'ship',
|
|
309
|
+
id: 1,
|
|
310
|
+
owner: 'player.gm',
|
|
311
|
+
entity_name: 'S',
|
|
312
|
+
coordinates: {x: 0, y: 0, z: 0},
|
|
313
|
+
item_id: 1,
|
|
314
|
+
cargomass: 0,
|
|
315
|
+
cargo: [],
|
|
316
|
+
modules: [],
|
|
317
|
+
lanes: [
|
|
318
|
+
{lane_key: 1, schedule: {started: '2026-06-19T00:00:00', tasks: [producer]}},
|
|
319
|
+
{lane_key: 2, schedule: {started: '2026-06-19T00:00:00', tasks: [consumer]}},
|
|
320
|
+
],
|
|
321
|
+
holds: [],
|
|
322
|
+
})
|
|
323
|
+
expect(cancelEligibility(e, 1, 0, {now: upcoming}).blockedReason).toBe(
|
|
324
|
+
CancelBlockReason.WOULD_STRAND
|
|
325
|
+
)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -303,6 +303,7 @@ function applyTask(projected: ProjectedEntity, task: ServerContract.Types.task):
|
|
|
303
303
|
break
|
|
304
304
|
case TaskType.TRAVEL:
|
|
305
305
|
case TaskType.WARP:
|
|
306
|
+
case TaskType.TRANSIT:
|
|
306
307
|
applyFlightTask(projected, task, {complete: true})
|
|
307
308
|
break
|
|
308
309
|
case TaskType.LOAD:
|
|
@@ -461,6 +462,7 @@ export function projectEntityAt(entity: Projectable, now: Date): ProjectedEntity
|
|
|
461
462
|
break
|
|
462
463
|
case TaskType.TRAVEL:
|
|
463
464
|
case TaskType.WARP:
|
|
465
|
+
case TaskType.TRANSIT:
|
|
464
466
|
applyFlightTask(projected, task, {complete: taskComplete, progress})
|
|
465
467
|
break
|
|
466
468
|
case TaskType.LOAD:
|
|
@@ -294,7 +294,9 @@ function entityDoesTaskType(entity: ScheduleData, taskType: TaskType, now: Date)
|
|
|
294
294
|
|
|
295
295
|
export function isInFlight(entity: ScheduleData, now: Date): boolean {
|
|
296
296
|
const lane = mobilityLane(entity)
|
|
297
|
-
|
|
297
|
+
if (!lane) return false
|
|
298
|
+
const t = core.currentTaskType(lane.schedule, now)
|
|
299
|
+
return t === TaskType.TRAVEL || t === TaskType.TRANSIT
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
export function isRecharging(entity: ScheduleData, now: Date): boolean {
|
package/src/travel/travel.ts
CHANGED
|
@@ -37,9 +37,14 @@ import {
|
|
|
37
37
|
import {EntityClass} from '../data/kind-registry'
|
|
38
38
|
import {getItem} from '../data/catalog'
|
|
39
39
|
import {hasSystem} from '../utils/system'
|
|
40
|
+
import {WH} from '../derivation/wormhole'
|
|
40
41
|
import * as scheduleModel from '../scheduling/schedule'
|
|
41
42
|
import type {ScheduleData} from '../scheduling/schedule'
|
|
42
43
|
|
|
44
|
+
function isPositionalTask(task: ServerContract.Types.task): boolean {
|
|
45
|
+
return task.type.equals(TaskType.TRAVEL) || task.type.equals(TaskType.TRANSIT)
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
export function calc_orbital_altitude(mass: number): number {
|
|
44
49
|
if (mass <= BASE_ORBITAL_MASS) {
|
|
45
50
|
return MIN_ORBITAL_ALTITUDE
|
|
@@ -125,7 +130,7 @@ export function getInterpolatedPosition(
|
|
|
125
130
|
return {x: Number(settled.x), y: Number(settled.y)}
|
|
126
131
|
}
|
|
127
132
|
const task = tasks[taskIndex]
|
|
128
|
-
if (!task
|
|
133
|
+
if (!isPositionalTask(task) || !task.coordinates) {
|
|
129
134
|
const origin = getFlightOrigin(entity, taskIndex)
|
|
130
135
|
return {x: Number(origin.x), y: Number(origin.y)}
|
|
131
136
|
}
|
|
@@ -198,6 +203,11 @@ export function calc_flighttime(distance: UInt64Type, acceleration: number): UIn
|
|
|
198
203
|
return UInt32.from(2 * Math.sqrt(Number(distance) / acceleration))
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
export function calc_transit_duration(ax: number, ay: number, bx: number, by: number): UInt32 {
|
|
207
|
+
const distance = distanceBetweenPoints(ax, ay, bx, by)
|
|
208
|
+
return UInt32.from(Math.floor(distance.toNumber() / (PRECISION * WH.TRANSIT_SPEED)))
|
|
209
|
+
}
|
|
210
|
+
|
|
201
211
|
export function calc_loader_flighttime(ship: ShipLike, mass: UInt64, altitude?: number): UInt32 {
|
|
202
212
|
const z = altitude ?? ship.coordinates.z?.toNumber() ?? calc_orbital_altitude(Number(mass))
|
|
203
213
|
return calc_flighttime(z, calc_loader_acceleration(ship, mass))
|
|
@@ -449,7 +459,7 @@ export function getFlightOrigin(
|
|
|
449
459
|
let origin = entity.coordinates
|
|
450
460
|
for (let i = 0; i < flightTaskIndex && i < tasks.length; i++) {
|
|
451
461
|
const task = tasks[i]
|
|
452
|
-
if (task
|
|
462
|
+
if (isPositionalTask(task) && task.coordinates) {
|
|
453
463
|
origin = task.coordinates
|
|
454
464
|
}
|
|
455
465
|
}
|
|
@@ -462,7 +472,7 @@ export function getDestinationLocation(
|
|
|
462
472
|
const tasks = mobilityTasks(entity)
|
|
463
473
|
for (let i = tasks.length - 1; i >= 0; i--) {
|
|
464
474
|
const task = tasks[i]
|
|
465
|
-
if (task
|
|
475
|
+
if (isPositionalTask(task) && task.coordinates) {
|
|
466
476
|
return task.coordinates
|
|
467
477
|
}
|
|
468
478
|
}
|
|
@@ -485,7 +495,7 @@ export function getPositionAt(
|
|
|
485
495
|
|
|
486
496
|
const task = tasks[taskIndex]
|
|
487
497
|
|
|
488
|
-
if (!task
|
|
498
|
+
if (!isPositionalTask(task) || !task.coordinates) {
|
|
489
499
|
return getFlightOrigin(entity, taskIndex)
|
|
490
500
|
}
|
|
491
501
|
|
package/src/types.ts
CHANGED
package/src/utils/system.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {hash512} from './hash'
|
|
|
3
3
|
import {Coordinates, type CoordinatesType, LocationType} from '../types'
|
|
4
4
|
import {ServerContract} from '../contracts'
|
|
5
5
|
import {deriveLocationSize} from '../derivation/location-size'
|
|
6
|
+
import {wormholeAt} from '../derivation/wormhole'
|
|
6
7
|
import syllables from '../data/syllables.json'
|
|
7
8
|
import nebulaAdjectives from '../data/nebula-adjectives.json'
|
|
8
9
|
import nebulaNouns from '../data/nebula-nouns.json'
|
|
@@ -110,6 +111,16 @@ export function hasSystem(gameSeed: Checksum256Type, coordinates: CoordinatesTyp
|
|
|
110
111
|
return getLocationType(gameSeed, coordinates) !== LocationType.EMPTY
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
export function getLocationKind(
|
|
115
|
+
gameSeed: Checksum256Type,
|
|
116
|
+
x: number,
|
|
117
|
+
y: number
|
|
118
|
+
): 'wormhole' | 'system' | 'empty' {
|
|
119
|
+
if (wormholeAt(gameSeed, x, y)) return 'wormhole'
|
|
120
|
+
if (hasSystem(gameSeed, {x, y})) return 'system'
|
|
121
|
+
return 'empty'
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
export function deriveLocationStatic(
|
|
114
125
|
gameSeed: Checksum256Type,
|
|
115
126
|
coordinates: CoordinatesType
|