@hla4ts/spacekit 0.1.0

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.
@@ -0,0 +1,275 @@
1
+ import type {
2
+ AttributeHandle,
3
+ AttributeHandleValueMap,
4
+ InteractionClassHandle,
5
+ LogicalTime,
6
+ MessageRetractionReturn,
7
+ ObjectClassHandle,
8
+ ObjectInstanceHandle,
9
+ ParameterHandle,
10
+ ParameterHandleValueMap,
11
+ RTIAmbassador,
12
+ UserSuppliedTag,
13
+ } from "@hla4ts/hla-api";
14
+ import { handleToHex } from "@hla4ts/hla-api";
15
+
16
+ export interface AttributeSpec<T> {
17
+ name: string;
18
+ encode: (value: T) => Uint8Array;
19
+ decode?: (data: Uint8Array) => T;
20
+ }
21
+
22
+ export type AttributeSpecMap<T extends object> = {
23
+ [K in keyof T]: AttributeSpec<T[K]>;
24
+ };
25
+
26
+ export class ObjectClassAdapter<T extends object> {
27
+ private _classHandle: ObjectClassHandle | null = null;
28
+ private readonly _attributeKeys: Array<keyof T & string>;
29
+ private readonly _attributeHandles = new Map<keyof T & string, AttributeHandle>();
30
+ private readonly _attributeNamesByHandle = new Map<string, keyof T & string>();
31
+
32
+ constructor(
33
+ public readonly className: string,
34
+ public readonly attributes: AttributeSpecMap<T>
35
+ ) {
36
+ this._attributeKeys = Object.keys(attributes) as Array<keyof T & string>;
37
+ }
38
+
39
+ get isResolved(): boolean {
40
+ return this._classHandle !== null;
41
+ }
42
+
43
+ get classHandle(): ObjectClassHandle {
44
+ if (!this._classHandle) {
45
+ throw new Error(`ObjectClassAdapter(${this.className}) is not resolved.`);
46
+ }
47
+ return this._classHandle;
48
+ }
49
+
50
+ get attributeHandleSet(): AttributeHandle[] {
51
+ return Array.from(this._attributeHandles.values());
52
+ }
53
+
54
+ getAttributeHandle(name: keyof T & string): AttributeHandle {
55
+ const handle = this._attributeHandles.get(name);
56
+ if (!handle) {
57
+ throw new Error(`Attribute handle not resolved: ${String(name)}`);
58
+ }
59
+ return handle;
60
+ }
61
+
62
+ async resolve(rti: RTIAmbassador): Promise<void> {
63
+ this._classHandle = await rti.getObjectClassHandle(this.className);
64
+ this._attributeHandles.clear();
65
+ this._attributeNamesByHandle.clear();
66
+
67
+ for (const key of this._attributeKeys) {
68
+ const spec = this.attributes[key];
69
+ const handle = await rti.getAttributeHandle(this._classHandle, spec.name);
70
+ this._attributeHandles.set(key, handle);
71
+ this._attributeNamesByHandle.set(handleToHex(handle), key);
72
+ }
73
+ }
74
+
75
+ async publish(rti: RTIAmbassador): Promise<void> {
76
+ await rti.publishObjectClassAttributes(this.classHandle, this.attributeHandleSet);
77
+ }
78
+
79
+ async subscribe(rti: RTIAmbassador): Promise<void> {
80
+ await rti.subscribeObjectClassAttributes(this.classHandle, this.attributeHandleSet);
81
+ }
82
+
83
+ async registerInstance(
84
+ rti: RTIAmbassador,
85
+ name?: string
86
+ ): Promise<ObjectInstanceAdapter<T>> {
87
+ const objectInstance = name
88
+ ? await rti.registerObjectInstanceWithName(this.classHandle, name)
89
+ : await rti.registerObjectInstance(this.classHandle);
90
+ return new ObjectInstanceAdapter<T>(this, objectInstance, name);
91
+ }
92
+
93
+ encode(values: Partial<T>): AttributeHandleValueMap {
94
+ if (!this._classHandle) {
95
+ throw new Error(`ObjectClassAdapter(${this.className}) is not resolved.`);
96
+ }
97
+ const entries: AttributeHandleValueMap = [];
98
+ for (const key of this._attributeKeys) {
99
+ if (!(key in values)) continue;
100
+ const value = values[key];
101
+ if (value === undefined) continue;
102
+ const spec = this.attributes[key];
103
+ const handle = this._attributeHandles.get(key);
104
+ if (!handle) {
105
+ throw new Error(`Attribute handle not resolved: ${String(key)}`);
106
+ }
107
+ entries.push({ attributeHandle: handle, value: spec.encode(value) });
108
+ }
109
+ return entries;
110
+ }
111
+
112
+ decode(values: AttributeHandleValueMap): Partial<T> {
113
+ const result: Partial<T> = {};
114
+ for (const entry of values) {
115
+ const key = this._attributeNamesByHandle.get(handleToHex(entry.attributeHandle));
116
+ if (!key) continue;
117
+ const spec = this.attributes[key];
118
+ if (!spec.decode) continue;
119
+ result[key] = spec.decode(entry.value);
120
+ }
121
+ return result;
122
+ }
123
+ }
124
+
125
+ export class ObjectInstanceAdapter<T extends object> {
126
+ constructor(
127
+ public readonly objectClass: ObjectClassAdapter<T>,
128
+ public readonly objectInstance: ObjectInstanceHandle,
129
+ public objectInstanceName?: string
130
+ ) {}
131
+
132
+ get className(): string {
133
+ return this.objectClass.className;
134
+ }
135
+
136
+ encode(values: Partial<T>): AttributeHandleValueMap {
137
+ return this.objectClass.encode(values);
138
+ }
139
+
140
+ decode(values: AttributeHandleValueMap): Partial<T> {
141
+ return this.objectClass.decode(values);
142
+ }
143
+
144
+ async updateAttributes(
145
+ rti: RTIAmbassador,
146
+ values: Partial<T>,
147
+ userSuppliedTag: UserSuppliedTag = new Uint8Array(),
148
+ time?: LogicalTime
149
+ ): Promise<MessageRetractionReturn | void> {
150
+ const payload = this.objectClass.encode(values);
151
+ if (time) {
152
+ return rti.updateAttributeValuesWithTime(
153
+ this.objectInstance,
154
+ payload,
155
+ userSuppliedTag,
156
+ time
157
+ );
158
+ }
159
+ await rti.updateAttributeValues(this.objectInstance, payload, userSuppliedTag);
160
+ return undefined;
161
+ }
162
+
163
+ async deleteInstance(
164
+ rti: RTIAmbassador,
165
+ userSuppliedTag: UserSuppliedTag = new Uint8Array(),
166
+ time?: LogicalTime
167
+ ): Promise<MessageRetractionReturn | void> {
168
+ if (time) {
169
+ return rti.deleteObjectInstanceWithTime(this.objectInstance, userSuppliedTag, time);
170
+ }
171
+ await rti.deleteObjectInstance(this.objectInstance, userSuppliedTag);
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ export interface ParameterSpec<T> {
177
+ name: string;
178
+ encode: (value: T) => Uint8Array;
179
+ decode?: (data: Uint8Array) => T;
180
+ }
181
+
182
+ export type ParameterSpecMap<T extends object> = {
183
+ [K in keyof T]: ParameterSpec<T[K]>;
184
+ };
185
+
186
+ export class InteractionClassAdapter<T extends object> {
187
+ private _interactionHandle: InteractionClassHandle | null = null;
188
+ private readonly _parameterKeys: Array<keyof T & string>;
189
+ private readonly _parameterHandles = new Map<keyof T & string, ParameterHandle>();
190
+ private readonly _parameterNamesByHandle = new Map<string, keyof T & string>();
191
+
192
+ constructor(
193
+ public readonly className: string,
194
+ public readonly parameters: ParameterSpecMap<T>
195
+ ) {
196
+ this._parameterKeys = Object.keys(parameters) as Array<keyof T & string>;
197
+ }
198
+
199
+ get isResolved(): boolean {
200
+ return this._interactionHandle !== null;
201
+ }
202
+
203
+ get interactionHandle(): InteractionClassHandle {
204
+ if (!this._interactionHandle) {
205
+ throw new Error(`InteractionClassAdapter(${this.className}) is not resolved.`);
206
+ }
207
+ return this._interactionHandle;
208
+ }
209
+
210
+ async resolve(rti: RTIAmbassador): Promise<void> {
211
+ this._interactionHandle = await rti.getInteractionClassHandle(this.className);
212
+ this._parameterHandles.clear();
213
+ this._parameterNamesByHandle.clear();
214
+
215
+ for (const key of this._parameterKeys) {
216
+ const spec = this.parameters[key];
217
+ const handle = await rti.getParameterHandle(this._interactionHandle, spec.name);
218
+ this._parameterHandles.set(key, handle);
219
+ this._parameterNamesByHandle.set(handleToHex(handle), key);
220
+ }
221
+ }
222
+
223
+ async publish(rti: RTIAmbassador): Promise<void> {
224
+ await rti.publishInteractionClass(this.interactionHandle);
225
+ }
226
+
227
+ async subscribe(rti: RTIAmbassador): Promise<void> {
228
+ await rti.subscribeInteractionClass(this.interactionHandle);
229
+ }
230
+
231
+ encode(values: Partial<T>): ParameterHandleValueMap {
232
+ if (!this._interactionHandle) {
233
+ throw new Error(`InteractionClassAdapter(${this.className}) is not resolved.`);
234
+ }
235
+ const entries: ParameterHandleValueMap = [];
236
+ for (const key of this._parameterKeys) {
237
+ if (!(key in values)) continue;
238
+ const value = values[key];
239
+ if (value === undefined) continue;
240
+ const spec = this.parameters[key];
241
+ const handle = this._parameterHandles.get(key);
242
+ if (!handle) {
243
+ throw new Error(`Parameter handle not resolved: ${String(key)}`);
244
+ }
245
+ entries.push({ parameterHandle: handle, value: spec.encode(value) });
246
+ }
247
+ return entries;
248
+ }
249
+
250
+ decode(values: ParameterHandleValueMap): Partial<T> {
251
+ const result: Partial<T> = {};
252
+ for (const entry of values) {
253
+ const key = this._parameterNamesByHandle.get(handleToHex(entry.parameterHandle));
254
+ if (!key) continue;
255
+ const spec = this.parameters[key];
256
+ if (!spec.decode) continue;
257
+ result[key] = spec.decode(entry.value);
258
+ }
259
+ return result;
260
+ }
261
+
262
+ async send(
263
+ rti: RTIAmbassador,
264
+ values: Partial<T>,
265
+ userSuppliedTag: UserSuppliedTag = new Uint8Array(),
266
+ time?: LogicalTime
267
+ ): Promise<void> {
268
+ const payload = this.encode(values);
269
+ if (time) {
270
+ await rti.sendInteractionWithTime(this.interactionHandle, payload, userSuppliedTag, time);
271
+ } else {
272
+ await rti.sendInteraction(this.interactionHandle, payload, userSuppliedTag);
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,62 @@
1
+ import { expect, test } from "bun:test";
2
+ import { SpacekitApp } from "./see-app.ts";
3
+
4
+ test("SpacekitApp exposes SEE timing helpers", () => {
5
+ const app = new SpacekitApp({
6
+ federationName: "SpaceFederation",
7
+ federateName: "TestFederate",
8
+ federateType: "TestFederate",
9
+ lookaheadMicros: 500_000n,
10
+ updatePeriodMicros: 1_000_000n,
11
+ });
12
+
13
+ expect(app.lookaheadMicros).toBe(500_000n);
14
+ expect(app.updatePeriodMicros).toBe(1_000_000n);
15
+ expect(app.getSendTimeMicros(10_000_000n)).toBe(10_500_000n);
16
+ expect(app.shouldLogTick(1)).toBe(true);
17
+ });
18
+
19
+ test("SpacekitApp can continue when a reference frame is missing", async () => {
20
+ const warnings: Array<{ message: string; data?: Record<string, unknown> }> = [];
21
+ const app = new SpacekitApp({
22
+ federationName: "SpaceFederation",
23
+ federateName: "TestFederate",
24
+ federateType: "TestFederate",
25
+ referenceFrameTimeoutMs: 1,
26
+ missingReferenceFrameMode: "continue",
27
+ logger: {
28
+ debug() {},
29
+ info() {},
30
+ warn(message, data) {
31
+ warnings.push({ message, data });
32
+ },
33
+ error() {},
34
+ },
35
+ });
36
+
37
+ (app as unknown as { _bootstrapState: unknown })._bootstrapState = {
38
+ referenceFrames: [],
39
+ };
40
+
41
+ await expect(app.waitForReferenceFrame("MissingFrame", 1)).resolves.toBeUndefined();
42
+ expect(
43
+ warnings.some((entry) => entry.message === "Proceeding without discovered reference frame.")
44
+ ).toBe(true);
45
+ });
46
+
47
+ test("SpacekitApp still errors by default when a reference frame is missing", async () => {
48
+ const app = new SpacekitApp({
49
+ federationName: "SpaceFederation",
50
+ federateName: "TestFederate",
51
+ federateType: "TestFederate",
52
+ referenceFrameTimeoutMs: 1,
53
+ });
54
+
55
+ (app as unknown as { _bootstrapState: unknown })._bootstrapState = {
56
+ referenceFrames: [],
57
+ };
58
+
59
+ await expect(app.waitForReferenceFrame("MissingFrame", 1)).rejects.toThrow(
60
+ "Reference frame not discovered: MissingFrame"
61
+ );
62
+ });