@rapierphysicsplugin/server 1.0.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.
Files changed (78) hide show
  1. package/dist/__tests__/input-buffer.test.d.ts +2 -0
  2. package/dist/__tests__/input-buffer.test.d.ts.map +1 -0
  3. package/dist/__tests__/input-buffer.test.js +53 -0
  4. package/dist/__tests__/input-buffer.test.js.map +1 -0
  5. package/dist/__tests__/integration.test.d.ts +2 -0
  6. package/dist/__tests__/integration.test.d.ts.map +1 -0
  7. package/dist/__tests__/integration.test.js +182 -0
  8. package/dist/__tests__/integration.test.js.map +1 -0
  9. package/dist/__tests__/physics-world-collisions.test.d.ts +2 -0
  10. package/dist/__tests__/physics-world-collisions.test.d.ts.map +1 -0
  11. package/dist/__tests__/physics-world-collisions.test.js +129 -0
  12. package/dist/__tests__/physics-world-collisions.test.js.map +1 -0
  13. package/dist/__tests__/physics-world.test.d.ts +2 -0
  14. package/dist/__tests__/physics-world.test.d.ts.map +1 -0
  15. package/dist/__tests__/physics-world.test.js +164 -0
  16. package/dist/__tests__/physics-world.test.js.map +1 -0
  17. package/dist/__tests__/room.test.d.ts +2 -0
  18. package/dist/__tests__/room.test.d.ts.map +1 -0
  19. package/dist/__tests__/room.test.js +189 -0
  20. package/dist/__tests__/room.test.js.map +1 -0
  21. package/dist/__tests__/state-manager.test.d.ts +2 -0
  22. package/dist/__tests__/state-manager.test.d.ts.map +1 -0
  23. package/dist/__tests__/state-manager.test.js +122 -0
  24. package/dist/__tests__/state-manager.test.js.map +1 -0
  25. package/dist/client-connection.d.ts +18 -0
  26. package/dist/client-connection.d.ts.map +1 -0
  27. package/dist/client-connection.js +41 -0
  28. package/dist/client-connection.js.map +1 -0
  29. package/dist/clock-sync.d.ts +4 -0
  30. package/dist/clock-sync.d.ts.map +1 -0
  31. package/dist/clock-sync.js +10 -0
  32. package/dist/clock-sync.js.map +1 -0
  33. package/dist/index.d.ts +8 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +35 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/input-buffer.d.ts +11 -0
  38. package/dist/input-buffer.d.ts.map +1 -0
  39. package/dist/input-buffer.js +40 -0
  40. package/dist/input-buffer.js.map +1 -0
  41. package/dist/physics-world.d.ts +33 -0
  42. package/dist/physics-world.d.ts.map +1 -0
  43. package/dist/physics-world.js +326 -0
  44. package/dist/physics-world.js.map +1 -0
  45. package/dist/room.d.ts +60 -0
  46. package/dist/room.d.ts.map +1 -0
  47. package/dist/room.js +393 -0
  48. package/dist/room.js.map +1 -0
  49. package/dist/server.d.ts +17 -0
  50. package/dist/server.d.ts.map +1 -0
  51. package/dist/server.js +268 -0
  52. package/dist/server.js.map +1 -0
  53. package/dist/simulation-loop.d.ts +14 -0
  54. package/dist/simulation-loop.d.ts.map +1 -0
  55. package/dist/simulation-loop.js +42 -0
  56. package/dist/simulation-loop.js.map +1 -0
  57. package/dist/state-manager.d.ts +20 -0
  58. package/dist/state-manager.d.ts.map +1 -0
  59. package/dist/state-manager.js +120 -0
  60. package/dist/state-manager.js.map +1 -0
  61. package/package.json +24 -0
  62. package/src/__tests__/input-buffer.test.ts +64 -0
  63. package/src/__tests__/integration.test.ts +227 -0
  64. package/src/__tests__/physics-world-collisions.test.ts +155 -0
  65. package/src/__tests__/physics-world.test.ts +197 -0
  66. package/src/__tests__/room.test.ts +232 -0
  67. package/src/__tests__/state-manager.test.ts +152 -0
  68. package/src/client-connection.ts +52 -0
  69. package/src/clock-sync.ts +16 -0
  70. package/src/index.ts +40 -0
  71. package/src/input-buffer.ts +46 -0
  72. package/src/physics-world.ts +400 -0
  73. package/src/room.ts +487 -0
  74. package/src/server.ts +312 -0
  75. package/src/simulation-loop.ts +48 -0
  76. package/src/state-manager.ts +136 -0
  77. package/tsconfig.json +11 -0
  78. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect, beforeAll, afterEach } from 'vitest';
2
+ import RAPIER from '@dimforge/rapier3d-compat';
3
+ import { WebSocket } from 'ws';
4
+ import { PhysicsServer } from '../server.js';
5
+ import {
6
+ MessageType,
7
+ encodeMessage,
8
+ decodeServerMessage,
9
+ } from '@rapierphysicsplugin/shared';
10
+ import type { ServerMessage, RoomJoinedMessage, RoomStateMessage } from '@rapierphysicsplugin/shared';
11
+
12
+ const TEST_PORT = 9876;
13
+
14
+ function connectClient(port: number): Promise<WebSocket> {
15
+ return new Promise((resolve, reject) => {
16
+ const ws = new WebSocket(`ws://localhost:${port}`);
17
+ ws.on('open', () => resolve(ws));
18
+ ws.on('error', reject);
19
+ });
20
+ }
21
+
22
+ function waitForMessage(ws: WebSocket, type: MessageType, timeoutMs = 5000): Promise<ServerMessage> {
23
+ return new Promise((resolve, reject) => {
24
+ const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
25
+
26
+ const handler = (data: Buffer) => {
27
+ const msg = decodeServerMessage(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
28
+ if (msg.type === type) {
29
+ clearTimeout(timeout);
30
+ ws.off('message', handler);
31
+ resolve(msg);
32
+ }
33
+ };
34
+ ws.on('message', handler);
35
+ });
36
+ }
37
+
38
+ describe('Integration: Server with WebSocket clients', () => {
39
+ let server: PhysicsServer;
40
+
41
+ beforeAll(async () => {
42
+ await RAPIER.init();
43
+ });
44
+
45
+ afterEach(() => {
46
+ server?.stop();
47
+ });
48
+
49
+ it('should allow a client to create and join a room', async () => {
50
+ server = new PhysicsServer(RAPIER);
51
+ await server.start(TEST_PORT);
52
+
53
+ const ws = await connectClient(TEST_PORT);
54
+
55
+ // Create room
56
+ ws.send(encodeMessage({
57
+ type: MessageType.CREATE_ROOM,
58
+ roomId: 'test-room',
59
+ initialBodies: [
60
+ {
61
+ id: 'box1',
62
+ shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
63
+ motionType: 'dynamic',
64
+ position: { x: 0, y: 5, z: 0 },
65
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
66
+ mass: 1.0,
67
+ },
68
+ ],
69
+ }));
70
+
71
+ const created = await waitForMessage(ws, MessageType.ROOM_CREATED);
72
+ expect(created.type).toBe(MessageType.ROOM_CREATED);
73
+
74
+ // Join room
75
+ ws.send(encodeMessage({
76
+ type: MessageType.JOIN_ROOM,
77
+ roomId: 'test-room',
78
+ }));
79
+
80
+ const joined = await waitForMessage(ws, MessageType.ROOM_JOINED) as RoomJoinedMessage;
81
+ expect(joined.roomId).toBe('test-room');
82
+ expect(joined.snapshot.bodies).toHaveLength(1);
83
+ expect(joined.snapshot.bodies[0].id).toBe('box1');
84
+
85
+ ws.close();
86
+ });
87
+
88
+ it('should send state updates after starting simulation', async () => {
89
+ server = new PhysicsServer(RAPIER);
90
+ await server.start(TEST_PORT + 1);
91
+
92
+ const ws = await connectClient(TEST_PORT + 1);
93
+
94
+ ws.send(encodeMessage({
95
+ type: MessageType.CREATE_ROOM,
96
+ roomId: 'state-room',
97
+ initialBodies: [
98
+ {
99
+ id: 'ball',
100
+ shape: { type: 'sphere', params: { radius: 0.5 } },
101
+ motionType: 'dynamic',
102
+ position: { x: 0, y: 10, z: 0 },
103
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
104
+ mass: 1.0,
105
+ },
106
+ ],
107
+ }));
108
+ await waitForMessage(ws, MessageType.ROOM_CREATED);
109
+
110
+ ws.send(encodeMessage({
111
+ type: MessageType.JOIN_ROOM,
112
+ roomId: 'state-room',
113
+ }));
114
+ await waitForMessage(ws, MessageType.ROOM_JOINED);
115
+
116
+ // Start simulation (no longer auto-starts on join)
117
+ ws.send(encodeMessage({ type: MessageType.START_SIMULATION }));
118
+ await waitForMessage(ws, MessageType.SIMULATION_STARTED);
119
+
120
+ // Wait for a state update — should arrive within a few seconds
121
+ const stateMsg = await waitForMessage(ws, MessageType.ROOM_STATE) as RoomStateMessage;
122
+ expect(stateMsg.tick).toBeGreaterThan(0);
123
+ expect(stateMsg.bodies.length).toBeGreaterThanOrEqual(1);
124
+
125
+ ws.close();
126
+ });
127
+
128
+ it('should allow two clients to share a room', async () => {
129
+ server = new PhysicsServer(RAPIER);
130
+ await server.start(TEST_PORT + 2);
131
+
132
+ const ws1 = await connectClient(TEST_PORT + 2);
133
+ const ws2 = await connectClient(TEST_PORT + 2);
134
+
135
+ // Client 1 creates room
136
+ ws1.send(encodeMessage({
137
+ type: MessageType.CREATE_ROOM,
138
+ roomId: 'shared-room',
139
+ initialBodies: [
140
+ {
141
+ id: 'shared-box',
142
+ shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
143
+ motionType: 'dynamic',
144
+ position: { x: 0, y: 5, z: 0 },
145
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
146
+ mass: 1.0,
147
+ },
148
+ ],
149
+ }));
150
+ await waitForMessage(ws1, MessageType.ROOM_CREATED);
151
+
152
+ // Both clients join
153
+ ws1.send(encodeMessage({ type: MessageType.JOIN_ROOM, roomId: 'shared-room' }));
154
+ const joined1 = await waitForMessage(ws1, MessageType.ROOM_JOINED) as RoomJoinedMessage;
155
+ expect(joined1.snapshot.bodies).toHaveLength(1);
156
+
157
+ ws2.send(encodeMessage({ type: MessageType.JOIN_ROOM, roomId: 'shared-room' }));
158
+ const joined2 = await waitForMessage(ws2, MessageType.ROOM_JOINED) as RoomJoinedMessage;
159
+ expect(joined2.snapshot.bodies).toHaveLength(1);
160
+
161
+ // Start simulation (no longer auto-starts on join)
162
+ ws1.send(encodeMessage({ type: MessageType.START_SIMULATION }));
163
+ await waitForMessage(ws1, MessageType.SIMULATION_STARTED);
164
+ await waitForMessage(ws2, MessageType.SIMULATION_STARTED);
165
+
166
+ // Client 1 sends input
167
+ ws1.send(encodeMessage({
168
+ type: MessageType.CLIENT_INPUT,
169
+ input: {
170
+ tick: 0,
171
+ sequenceNum: 0,
172
+ actions: [
173
+ { type: 'applyImpulse', bodyId: 'shared-box', data: { impulse: { x: 20, y: 0, z: 0 } } },
174
+ ],
175
+ },
176
+ }));
177
+
178
+ // Both should receive state updates
179
+ const state1 = await waitForMessage(ws1, MessageType.ROOM_STATE) as RoomStateMessage;
180
+ const state2 = await waitForMessage(ws2, MessageType.ROOM_STATE) as RoomStateMessage;
181
+
182
+ expect(state1.bodies.length).toBeGreaterThanOrEqual(1);
183
+ expect(state2.bodies.length).toBeGreaterThanOrEqual(1);
184
+
185
+ ws1.close();
186
+ ws2.close();
187
+ });
188
+
189
+ it('should handle clock sync requests', async () => {
190
+ server = new PhysicsServer(RAPIER);
191
+ await server.start(TEST_PORT + 3);
192
+
193
+ const ws = await connectClient(TEST_PORT + 3);
194
+
195
+ const clientTimestamp = Date.now();
196
+ ws.send(encodeMessage({
197
+ type: MessageType.CLOCK_SYNC_REQUEST,
198
+ clientTimestamp,
199
+ }));
200
+
201
+ const response = await waitForMessage(ws, MessageType.CLOCK_SYNC_RESPONSE);
202
+ expect(response.type).toBe(MessageType.CLOCK_SYNC_RESPONSE);
203
+ if (response.type === MessageType.CLOCK_SYNC_RESPONSE) {
204
+ expect(response.clientTimestamp).toBe(clientTimestamp);
205
+ expect(response.serverTimestamp).toBeGreaterThan(0);
206
+ }
207
+
208
+ ws.close();
209
+ });
210
+
211
+ it('should return error for non-existent room', async () => {
212
+ server = new PhysicsServer(RAPIER);
213
+ await server.start(TEST_PORT + 4);
214
+
215
+ const ws = await connectClient(TEST_PORT + 4);
216
+
217
+ ws.send(encodeMessage({
218
+ type: MessageType.JOIN_ROOM,
219
+ roomId: 'nonexistent',
220
+ }));
221
+
222
+ const errorMsg = await waitForMessage(ws, MessageType.ERROR);
223
+ expect(errorMsg.type).toBe(MessageType.ERROR);
224
+
225
+ ws.close();
226
+ });
227
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
2
+ import RAPIER from '@dimforge/rapier3d-compat';
3
+ import { PhysicsWorld } from '../physics-world.js';
4
+ import type { BodyDescriptor } from '@rapierphysicsplugin/shared';
5
+
6
+ describe('PhysicsWorld collision events', () => {
7
+ let world: PhysicsWorld;
8
+
9
+ beforeAll(async () => {
10
+ await RAPIER.init();
11
+ });
12
+
13
+ beforeEach(() => {
14
+ world = new PhysicsWorld(RAPIER);
15
+ });
16
+
17
+ afterEach(() => {
18
+ world.destroy();
19
+ });
20
+
21
+ function makeGround(): BodyDescriptor {
22
+ return {
23
+ id: 'ground',
24
+ shape: { type: 'box', params: { halfExtents: { x: 50, y: 0.5, z: 50 } } },
25
+ motionType: 'static',
26
+ position: { x: 0, y: -0.5, z: 0 },
27
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
28
+ };
29
+ }
30
+
31
+ function makeBox(id: string, y: number): BodyDescriptor {
32
+ return {
33
+ id,
34
+ shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
35
+ motionType: 'dynamic',
36
+ position: { x: 0, y, z: 0 },
37
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
38
+ mass: 1.0,
39
+ };
40
+ }
41
+
42
+ function makeSensor(id: string, y: number): BodyDescriptor {
43
+ return {
44
+ id,
45
+ shape: { type: 'box', params: { halfExtents: { x: 2, y: 2, z: 2 } } },
46
+ motionType: 'static',
47
+ position: { x: 0, y, z: 0 },
48
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
49
+ isTrigger: true,
50
+ };
51
+ }
52
+
53
+ it('should return empty events when no collisions occur', () => {
54
+ world.addBody(makeBox('box1', 100));
55
+ const events = world.step();
56
+ expect(events).toEqual([]);
57
+ });
58
+
59
+ it('should detect collision when box falls onto ground', () => {
60
+ world.addBody(makeGround());
61
+ world.addBody(makeBox('box1', 1.5));
62
+
63
+ // Step many times until box reaches ground
64
+ let allEvents: ReturnType<typeof world.step> = [];
65
+ for (let i = 0; i < 120; i++) {
66
+ const events = world.step();
67
+ allEvents.push(...events);
68
+ }
69
+
70
+ const collisionStarted = allEvents.filter(e => e.type === 'COLLISION_STARTED');
71
+ expect(collisionStarted.length).toBeGreaterThan(0);
72
+
73
+ const event = collisionStarted[0];
74
+ // Should involve box1 and ground (order may vary)
75
+ const ids = [event.bodyIdA, event.bodyIdB].sort();
76
+ expect(ids).toEqual(['box1', 'ground']);
77
+ });
78
+
79
+ it('should have contact point and normal for COLLISION_STARTED', () => {
80
+ world.addBody(makeGround());
81
+ world.addBody(makeBox('box1', 1.5));
82
+
83
+ let collisionEvent = null;
84
+ for (let i = 0; i < 120; i++) {
85
+ const events = world.step();
86
+ const started = events.find(e => e.type === 'COLLISION_STARTED');
87
+ if (started) {
88
+ collisionEvent = started;
89
+ break;
90
+ }
91
+ }
92
+
93
+ expect(collisionEvent).not.toBeNull();
94
+ // Contact point should exist for non-sensor collisions
95
+ // Note: contact point may be null if Rapier doesn't provide it on the first frame
96
+ // but normal should typically be available
97
+ if (collisionEvent!.point) {
98
+ expect(typeof collisionEvent!.point.x).toBe('number');
99
+ expect(typeof collisionEvent!.point.y).toBe('number');
100
+ expect(typeof collisionEvent!.point.z).toBe('number');
101
+ }
102
+ });
103
+
104
+ it('should detect TRIGGER_ENTERED when box enters sensor', () => {
105
+ world.addBody(makeSensor('sensor1', 2));
106
+ world.addBody(makeBox('box1', 10));
107
+
108
+ let allEvents: ReturnType<typeof world.step> = [];
109
+ for (let i = 0; i < 120; i++) {
110
+ const events = world.step();
111
+ allEvents.push(...events);
112
+ }
113
+
114
+ const triggerEntered = allEvents.filter(e => e.type === 'TRIGGER_ENTERED');
115
+ expect(triggerEntered.length).toBeGreaterThan(0);
116
+
117
+ const event = triggerEntered[0];
118
+ const ids = [event.bodyIdA, event.bodyIdB].sort();
119
+ expect(ids).toEqual(['box1', 'sensor1']);
120
+
121
+ // Sensor events should not have contact info
122
+ expect(event.point).toBeNull();
123
+ expect(event.normal).toBeNull();
124
+ expect(event.impulse).toBe(0);
125
+ });
126
+
127
+ it('should map body IDs correctly for collision events', () => {
128
+ world.addBody(makeGround());
129
+ world.addBody(makeBox('alpha', 1.5));
130
+ world.addBody(makeBox('beta', 5));
131
+
132
+ let allEvents: ReturnType<typeof world.step> = [];
133
+ for (let i = 0; i < 200; i++) {
134
+ const events = world.step();
135
+ allEvents.push(...events);
136
+ }
137
+
138
+ // All events should reference known body IDs
139
+ for (const event of allEvents) {
140
+ expect(['alpha', 'beta', 'ground']).toContain(event.bodyIdA);
141
+ expect(['alpha', 'beta', 'ground']).toContain(event.bodyIdB);
142
+ }
143
+ });
144
+
145
+ it('should return events from step() without modifying previous results', () => {
146
+ world.addBody(makeGround());
147
+ world.addBody(makeBox('box1', 1.5));
148
+
149
+ const firstEvents = world.step();
150
+ const secondEvents = world.step();
151
+
152
+ // Each call returns its own array
153
+ expect(firstEvents).not.toBe(secondEvents);
154
+ });
155
+ });
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
2
+ import RAPIER from '@dimforge/rapier3d-compat';
3
+ import { PhysicsWorld } from '../physics-world.js';
4
+ import type { BodyDescriptor } from '@rapierphysicsplugin/shared';
5
+
6
+ describe('PhysicsWorld', () => {
7
+ let world: PhysicsWorld;
8
+
9
+ beforeAll(async () => {
10
+ await RAPIER.init();
11
+ });
12
+
13
+ beforeEach(() => {
14
+ world = new PhysicsWorld(RAPIER);
15
+ });
16
+
17
+ afterEach(() => {
18
+ world.destroy();
19
+ });
20
+
21
+ function makeBox(id: string, y: number = 5): BodyDescriptor {
22
+ return {
23
+ id,
24
+ shape: { type: 'box', params: { halfExtents: { x: 0.5, y: 0.5, z: 0.5 } } },
25
+ motionType: 'dynamic',
26
+ position: { x: 0, y, z: 0 },
27
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
28
+ mass: 1.0,
29
+ };
30
+ }
31
+
32
+ it('should add a body', () => {
33
+ const id = world.addBody(makeBox('box1'));
34
+ expect(id).toBe('box1');
35
+ expect(world.bodyCount).toBe(1);
36
+ expect(world.hasBody('box1')).toBe(true);
37
+ });
38
+
39
+ it('should throw when adding duplicate body', () => {
40
+ world.addBody(makeBox('box1'));
41
+ expect(() => world.addBody(makeBox('box1'))).toThrow('already exists');
42
+ });
43
+
44
+ it('should remove a body', () => {
45
+ world.addBody(makeBox('box1'));
46
+ world.removeBody('box1');
47
+ expect(world.bodyCount).toBe(0);
48
+ expect(world.hasBody('box1')).toBe(false);
49
+ });
50
+
51
+ it('should get body state', () => {
52
+ world.addBody(makeBox('box1', 10));
53
+ const state = world.getBodyState('box1');
54
+ expect(state).not.toBeNull();
55
+ expect(state!.id).toBe('box1');
56
+ expect(state!.position.y).toBeCloseTo(10);
57
+ expect(state!.rotation.w).toBeCloseTo(1);
58
+ });
59
+
60
+ it('should return null for non-existent body', () => {
61
+ expect(world.getBodyState('nonexistent')).toBeNull();
62
+ });
63
+
64
+ it('should step the physics world', () => {
65
+ world.addBody(makeBox('box1', 10));
66
+ const stateBefore = world.getBodyState('box1')!;
67
+
68
+ // Step multiple times so gravity takes effect
69
+ for (let i = 0; i < 60; i++) {
70
+ world.step();
71
+ }
72
+
73
+ const stateAfter = world.getBodyState('box1')!;
74
+ // Box should have fallen due to gravity
75
+ expect(stateAfter.position.y).toBeLessThan(stateBefore.position.y);
76
+ });
77
+
78
+ it('should get snapshot of all bodies', () => {
79
+ world.addBody(makeBox('box1', 5));
80
+ world.addBody(makeBox('box2', 10));
81
+
82
+ const snapshot = world.getSnapshot();
83
+ expect(snapshot).toHaveLength(2);
84
+ expect(snapshot.map(s => s.id).sort()).toEqual(['box1', 'box2']);
85
+ });
86
+
87
+ it('should apply force to a body', () => {
88
+ world.addBody(makeBox('box1', 5));
89
+ world.applyForce('box1', { x: 0, y: 100, z: 0 });
90
+ world.step();
91
+
92
+ const state = world.getBodyState('box1')!;
93
+ // Should have upward velocity from the applied force
94
+ expect(state.linVel.y).toBeGreaterThan(0);
95
+ });
96
+
97
+ it('should apply impulse to a body', () => {
98
+ world.addBody(makeBox('box1', 5));
99
+ world.applyImpulse('box1', { x: 10, y: 0, z: 0 });
100
+ world.step();
101
+
102
+ const state = world.getBodyState('box1')!;
103
+ expect(state.linVel.x).toBeGreaterThan(0);
104
+ });
105
+
106
+ it('should set body velocity', () => {
107
+ world.addBody(makeBox('box1', 5));
108
+ world.setBodyVelocity('box1', { x: 5, y: 0, z: 0 });
109
+
110
+ const state = world.getBodyState('box1')!;
111
+ expect(state.linVel.x).toBeCloseTo(5);
112
+ });
113
+
114
+ it('should add a sphere body', () => {
115
+ const descriptor: BodyDescriptor = {
116
+ id: 'sphere1',
117
+ shape: { type: 'sphere', params: { radius: 1 } },
118
+ motionType: 'dynamic',
119
+ position: { x: 0, y: 5, z: 0 },
120
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
121
+ };
122
+ world.addBody(descriptor);
123
+ expect(world.hasBody('sphere1')).toBe(true);
124
+ });
125
+
126
+ it('should add a static body', () => {
127
+ const descriptor: BodyDescriptor = {
128
+ id: 'ground',
129
+ shape: { type: 'box', params: { halfExtents: { x: 50, y: 0.5, z: 50 } } },
130
+ motionType: 'static',
131
+ position: { x: 0, y: -0.5, z: 0 },
132
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
133
+ };
134
+ world.addBody(descriptor);
135
+
136
+ // Static body shouldn't move
137
+ world.step();
138
+ const state = world.getBodyState('ground')!;
139
+ expect(state.position.y).toBeCloseTo(-0.5);
140
+ });
141
+
142
+ it('should apply input actions', () => {
143
+ world.addBody(makeBox('box1', 5));
144
+ world.applyInput({
145
+ type: 'applyForce',
146
+ bodyId: 'box1',
147
+ data: { force: { x: 0, y: 50, z: 0 } },
148
+ });
149
+ world.step();
150
+
151
+ const state = world.getBodyState('box1')!;
152
+ expect(state.linVel.y).toBeGreaterThan(0);
153
+ });
154
+
155
+ it('should load state from body descriptors', () => {
156
+ world.loadState([
157
+ makeBox('box1', 5),
158
+ makeBox('box2', 10),
159
+ makeBox('box3', 15),
160
+ ]);
161
+ expect(world.bodyCount).toBe(3);
162
+ });
163
+
164
+ it('should reset world with new body descriptors', () => {
165
+ world.loadState([makeBox('box1', 5), makeBox('box2', 10)]);
166
+ expect(world.bodyCount).toBe(2);
167
+
168
+ // Step to move bodies
169
+ for (let i = 0; i < 10; i++) {
170
+ world.step();
171
+ }
172
+ const stateBeforeReset = world.getBodyState('box1')!;
173
+ expect(stateBeforeReset.position.y).not.toBeCloseTo(5);
174
+
175
+ // Reset with same descriptors
176
+ world.reset([makeBox('box1', 5), makeBox('box2', 10)]);
177
+ expect(world.bodyCount).toBe(2);
178
+
179
+ // Bodies should be back at initial positions
180
+ const stateAfterReset = world.getBodyState('box1')!;
181
+ expect(stateAfterReset.position.y).toBeCloseTo(5);
182
+ const box2State = world.getBodyState('box2')!;
183
+ expect(box2State.position.y).toBeCloseTo(10);
184
+ });
185
+
186
+ it('should reset with different body descriptors', () => {
187
+ world.loadState([makeBox('box1', 5)]);
188
+ expect(world.bodyCount).toBe(1);
189
+
190
+ world.reset([makeBox('boxA', 3), makeBox('boxB', 7), makeBox('boxC', 12)]);
191
+ expect(world.bodyCount).toBe(3);
192
+ expect(world.hasBody('box1')).toBe(false);
193
+ expect(world.hasBody('boxA')).toBe(true);
194
+ expect(world.hasBody('boxB')).toBe(true);
195
+ expect(world.hasBody('boxC')).toBe(true);
196
+ });
197
+ });