@forgeax/engine-physics-rapier3d 0.1.2

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,2362 @@
1
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
2
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
3
+ //
4
+ // Source files (N=5):
5
+ // - packages/physics-rapier3d/__tests__/collision-event.test.ts
6
+ // - packages/physics-rapier3d/__tests__/despawn-cleanup.test.ts
7
+ // - packages/physics-rapier3d/__tests__/raycast-teleport.test.ts
8
+ // - packages/physics-rapier3d/__tests__/tick-pipeline.test.ts
9
+ // - packages/physics-rapier3d/__tests__/wasm-loader.test.ts
10
+ //
11
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
12
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
13
+ //
14
+ // Note: merged from __tests__/ into src/__tests__/; import paths adjusted (../src/xxx → ../xxx).
15
+
16
+ import { World } from '@forgeax/engine-ecs';
17
+ import {
18
+ CharacterController,
19
+ Collider,
20
+ ColliderShapeValue,
21
+ CollidingEntities,
22
+ PhysicsError,
23
+ RigidBody,
24
+ RigidBodyTypeValue,
25
+ registerPhysicsComponents,
26
+ } from '@forgeax/engine-physics';
27
+ import { ChildOf, registerPropagateTransforms, Transform } from '@forgeax/engine-scene';
28
+ import { describe, expect, it, vi } from 'vitest';
29
+ import { createRapier3DPhysicsWorld, registerPhysicsSystems } from '../rapier-physics-world-3d';
30
+ import { loadRapier3D } from '../wasm-loader';
31
+
32
+ function prepareWorld(): World {
33
+ const world = new World();
34
+ world.components.register(Transform).unwrap();
35
+ registerPhysicsComponents(world);
36
+ return world;
37
+ }
38
+
39
+ function runPhysicsTicks(world: World, count = 1): void {
40
+ for (let index = 0; index < count; index += 1) {
41
+ world.update(1 / 60).unwrap();
42
+ world.update(1 / 60).unwrap();
43
+ }
44
+ }
45
+
46
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier exposes runtime WASM classes.
47
+ function rapierBodyFor(pw: any, entity: number): any | undefined {
48
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier exposes runtime WASM classes.
49
+ let found: any | undefined;
50
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier exposes runtime WASM classes.
51
+ pw.raw.bodies.forEach((body: any) => {
52
+ if (body.userData === entity) found = body;
53
+ });
54
+ return found;
55
+ }
56
+
57
+ {
58
+ // ─── from collision-event.test.ts ───
59
+
60
+ describe('collision-event.test.ts', () => {
61
+ describe('feat-20260528 M2 t13 Rapier3D collision events', () => {
62
+ it('two dynamic spheres fall and collide', async () => {
63
+ const RAPIER = await loadRapier3D();
64
+ if ('code' in RAPIER) {
65
+ expect(RAPIER.code).toBe('wasm-load-failed');
66
+ return;
67
+ }
68
+
69
+ const pw = createRapier3DPhysicsWorld(RAPIER);
70
+
71
+ const b1 = pw.raw.createRigidBody(
72
+ RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 1, -0.3),
73
+ );
74
+ b1.userData = 101;
75
+ pw.raw.createCollider(
76
+ RAPIER.ColliderDesc.ball(0.5).setFriction(0.1).setRestitution(0.3),
77
+ b1,
78
+ );
79
+ pw.registerBody(101, b1.handle);
80
+
81
+ const b2 = pw.raw.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 1, 0.3));
82
+ b2.userData = 102;
83
+ pw.raw.createCollider(
84
+ RAPIER.ColliderDesc.ball(0.5).setFriction(0.1).setRestitution(0.3),
85
+ b2,
86
+ );
87
+ pw.registerBody(102, b2.handle);
88
+
89
+ for (let i = 0; i < 120; i++) {
90
+ pw.step(1 / 60);
91
+ }
92
+
93
+ const pos1 = b1.translation();
94
+ const pos2 = b2.translation();
95
+ expect(pos1.y).toBeLessThan(1);
96
+ expect(pos2.y).toBeLessThan(1);
97
+ });
98
+
99
+ it('userData can be read after setting', async () => {
100
+ const RAPIER = await loadRapier3D();
101
+ if ('code' in RAPIER) {
102
+ expect(RAPIER.code).toBe('wasm-load-failed');
103
+ return;
104
+ }
105
+
106
+ const rw = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
107
+ const body = rw.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0));
108
+
109
+ body.userData = 42;
110
+ expect(body.userData).toBe(42);
111
+ });
112
+
113
+ it('ball bounces on ground without errors', async () => {
114
+ const RAPIER = await loadRapier3D();
115
+ if ('code' in RAPIER) {
116
+ expect(RAPIER.code).toBe('wasm-load-failed');
117
+ return;
118
+ }
119
+
120
+ const pw = createRapier3DPhysicsWorld(RAPIER);
121
+
122
+ const ground = pw.raw.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, 0, 0));
123
+ ground.userData = 200;
124
+ pw.raw.createCollider(RAPIER.ColliderDesc.cuboid(10, 0.5, 10).setRestitution(0.3), ground);
125
+ pw.registerBody(200, ground.handle);
126
+
127
+ const ball = pw.raw.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0));
128
+ ball.userData = 201;
129
+ pw.raw.createCollider(RAPIER.ColliderDesc.ball(0.5).setRestitution(0.5), ball);
130
+ pw.registerBody(201, ball.handle);
131
+
132
+ for (let i = 0; i < 180; i++) {
133
+ pw.step(1 / 60);
134
+ }
135
+
136
+ const pos = ball.translation();
137
+ expect(pos.y).toBeLessThan(5);
138
+ });
139
+ });
140
+ });
141
+ }
142
+
143
+ {
144
+ // ─── from despawn-cleanup.test.ts ───
145
+
146
+ describe('despawn-cleanup.test.ts', () => {
147
+ describe('feat-20260528 M2 t14 Rapier3D entity despawn cleanup', () => {
148
+ it('removeEntity reduces body count to zero', async () => {
149
+ const RAPIER = await loadRapier3D();
150
+ if ('code' in RAPIER) {
151
+ expect(RAPIER.code).toBe('wasm-load-failed');
152
+ return;
153
+ }
154
+
155
+ const pw = createRapier3DPhysicsWorld(RAPIER);
156
+
157
+ const body = pw.raw.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0));
158
+ body.userData = 401;
159
+ pw.raw.createCollider(RAPIER.ColliderDesc.ball(0.5), body);
160
+ pw.registerBody(401, body.handle);
161
+
162
+ pw.step(1 / 60);
163
+ expect(pw.getBodyCount()).toBeGreaterThan(0);
164
+
165
+ pw.removeEntity(401);
166
+ expect(pw.getBodyCount()).toBe(0);
167
+ });
168
+
169
+ it('multi-entity: remove one, others remain', async () => {
170
+ const RAPIER = await loadRapier3D();
171
+ if ('code' in RAPIER) {
172
+ expect(RAPIER.code).toBe('wasm-load-failed');
173
+ return;
174
+ }
175
+
176
+ const pw = createRapier3DPhysicsWorld(RAPIER);
177
+
178
+ for (let i = 0; i < 3; i++) {
179
+ const body = pw.raw.createRigidBody(
180
+ RAPIER.RigidBodyDesc.dynamic().setTranslation(i, 5, 0),
181
+ );
182
+ body.userData = 410 + i;
183
+ pw.raw.createCollider(RAPIER.ColliderDesc.ball(0.5), body);
184
+ pw.registerBody(410 + i, body.handle);
185
+ }
186
+
187
+ const countBefore = pw.getBodyCount();
188
+ expect(countBefore).toBe(3);
189
+
190
+ pw.removeEntity(410);
191
+ expect(pw.getBodyCount()).toBe(2);
192
+ });
193
+
194
+ it('removeEntity on unknown entity does not throw', async () => {
195
+ const RAPIER = await loadRapier3D();
196
+ if ('code' in RAPIER) {
197
+ expect(RAPIER.code).toBe('wasm-load-failed');
198
+ return;
199
+ }
200
+
201
+ const pw = createRapier3DPhysicsWorld(RAPIER);
202
+
203
+ pw.raw.createCollider(RAPIER.ColliderDesc.cuboid(1, 1, 1));
204
+
205
+ pw.removeEntity(999);
206
+ expect(pw.getBodyCount()).toBe(0);
207
+ });
208
+ });
209
+ });
210
+ }
211
+
212
+ {
213
+ // ─── from raycast-teleport.test.ts ───
214
+
215
+ describe('raycast-teleport.test.ts', () => {
216
+ describe('feat-20260528 M2 t13b Rapier3D raycast + teleport', () => {
217
+ it('raycast: Rapier castRayAndGetNormal hits static ground', async () => {
218
+ const RAPIER = await loadRapier3D();
219
+ if ('code' in RAPIER) {
220
+ expect(RAPIER.code).toBe('wasm-load-failed');
221
+ return;
222
+ }
223
+
224
+ const rw = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
225
+ const ground = rw.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, -2, 0));
226
+ rw.createCollider(RAPIER.ColliderDesc.cuboid(10, 1, 10), ground);
227
+ rw.step();
228
+
229
+ const ray = new RAPIER.Ray({ x: 0, y: 5, z: 0 }, { x: 0, y: -1, z: 0 });
230
+ const hit = rw.castRayAndGetNormal(ray, 100, true);
231
+
232
+ expect(hit).toBeDefined();
233
+ if (hit !== null) {
234
+ const point = ray.pointAt(hit.timeOfImpact);
235
+ expect(point.y).toBeLessThan(0);
236
+ expect(point.y).toBeGreaterThan(-3);
237
+ expect(hit.normal.y).toBeGreaterThan(0);
238
+ expect(hit.timeOfImpact).toBeGreaterThan(0);
239
+ expect(hit.timeOfImpact).toBeLessThan(100);
240
+ }
241
+ });
242
+
243
+ it('raycast: Rapier castRay pointing away returns null', async () => {
244
+ const RAPIER = await loadRapier3D();
245
+ if ('code' in RAPIER) {
246
+ expect(RAPIER.code).toBe('wasm-load-failed');
247
+ return;
248
+ }
249
+
250
+ const rw = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
251
+ const ground = rw.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, -2, 0));
252
+ rw.createCollider(RAPIER.ColliderDesc.cuboid(10, 1, 10), ground);
253
+ rw.step();
254
+
255
+ const ray = new RAPIER.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: 1, z: 0 });
256
+ const hit = rw.castRayAndGetNormal(ray, 100, true);
257
+
258
+ expect(hit).toBeNull();
259
+ });
260
+
261
+ // bug-20260713 solo round-22: PhysicsWorld.raycast() resolved hit.entity via
262
+ // `bodies.get(hit.collider.parent())`, but `.parent()` already returns the
263
+ // RigidBody OBJECT (compat build), so treating it as a handle returned a
264
+ // DIFFERENT body → the WRONG entity for every hit. This drives the real
265
+ // PhysicsWorld.raycast() wrapper (the prior tests only hit raw rapier and
266
+ // never exercised entity resolution) with TWO distinct entities and asserts
267
+ // the ray reports the one it geometrically struck. Reverting the fix (back to
268
+ // `bodies.get(...)`) reddens the `hit.entity === target` assertions.
269
+ it('raycast: hit.entity is the entity actually struck (not another body)', async () => {
270
+ const RAPIER = await loadRapier3D();
271
+ if ('code' in RAPIER) {
272
+ expect(RAPIER.code).toBe('wasm-load-failed');
273
+ return;
274
+ }
275
+
276
+ const world = prepareWorld();
277
+ const pw = createRapier3DPhysicsWorld(RAPIER as never);
278
+ world.insertResource('PhysicsWorld', pw);
279
+
280
+ // Ground: static cuboid, top at y=0.
281
+ const ground = world
282
+ .spawn(
283
+ { component: Transform as never, data: { pos: [0, -0.5, 0] } },
284
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
285
+ {
286
+ component: Collider as never,
287
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [10, 0.5, 10] },
288
+ },
289
+ )
290
+ .unwrap();
291
+
292
+ // Target: static cuboid centred at x=5 (near face x=4), well above ground.
293
+ const target = world
294
+ .spawn(
295
+ { component: Transform as never, data: { pos: [5, 1, 0] } },
296
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
297
+ {
298
+ component: Collider as never,
299
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
300
+ },
301
+ )
302
+ .unwrap();
303
+
304
+ registerPhysicsSystems(world);
305
+ for (let i = 0; i < 5; i++) {
306
+ world.update(1 / 60).unwrap();
307
+ world.update(1 / 60).unwrap();
308
+ }
309
+
310
+ // Ray at y=1 toward +X can only reach the target (ground tops out at y=0).
311
+ const toTarget = pw.raycast(
312
+ Float32Array.of(0, 1, 0) as never,
313
+ Float32Array.of(1, 0, 0) as never,
314
+ 20,
315
+ );
316
+ expect(toTarget).toBeDefined();
317
+ expect(toTarget?.entity).toBe(target);
318
+ expect(toTarget?.timeOfImpact).toBeCloseTo(4, 1); // near face at x=4
319
+ expect(toTarget?.normal[0]).toBeCloseTo(-1, 1); // facing -X
320
+
321
+ // Ray straight down from above the origin hits the ground, not the target.
322
+ const toGround = pw.raycast(
323
+ Float32Array.of(0, 5, 0) as never,
324
+ Float32Array.of(0, -1, 0) as never,
325
+ 20,
326
+ );
327
+ expect(toGround).toBeDefined();
328
+ expect(toGround?.entity).toBe(ground);
329
+ expect(toGround?.normal[1]).toBeCloseTo(1, 1); // facing +Y
330
+
331
+ // A ray past all geometry misses.
332
+ const miss = pw.raycast(
333
+ Float32Array.of(0, 1, 0) as never,
334
+ Float32Array.of(0, 1, 0) as never,
335
+ 20,
336
+ );
337
+ expect(miss).toBeUndefined();
338
+ });
339
+
340
+ it('teleport: Rapier setTranslation + zero velocity', async () => {
341
+ const RAPIER = await loadRapier3D();
342
+ if ('code' in RAPIER) {
343
+ expect(RAPIER.code).toBe('wasm-load-failed');
344
+ return;
345
+ }
346
+
347
+ const rw = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
348
+ const body = rw.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 10, 0));
349
+ rw.createCollider(RAPIER.ColliderDesc.ball(0.5), body);
350
+
351
+ body.setTranslation({ x: 100, y: 100, z: 100 }, true);
352
+ body.setLinvel({ x: 0, y: 0, z: 0 }, false);
353
+ body.setAngvel({ x: 0, y: 0, z: 0 }, false);
354
+
355
+ const pos = body.translation();
356
+ expect(pos.x).toBeCloseTo(100, 0);
357
+ expect(pos.y).toBeCloseTo(100, 0);
358
+ expect(pos.z).toBeCloseTo(100, 0);
359
+ });
360
+ });
361
+ });
362
+ }
363
+
364
+ {
365
+ // ─── from tick-pipeline.test.ts ───
366
+
367
+ describe('tick-pipeline.test.ts', () => {
368
+ describe('feat-20260528 M2 t12 Rapier3D low-level primitives (kinematic teleport, despawn)', () => {
369
+ it('kinematic body: position follows setNextKinematicTranslation', async () => {
370
+ const RAPIER = await loadRapier3D();
371
+ if ('code' in RAPIER) {
372
+ expect(RAPIER.code).toBe('wasm-load-failed');
373
+ return;
374
+ }
375
+
376
+ const pw = createRapier3DPhysicsWorld(RAPIER);
377
+
378
+ const body = pw.raw.createRigidBody(
379
+ RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 3, 0),
380
+ );
381
+ body.userData = 3;
382
+ pw.raw.createCollider(RAPIER.ColliderDesc.cuboid(1, 1, 1), body);
383
+ pw.registerBody(3, body.handle);
384
+
385
+ pw.setKinematicPosition(3, { x: 10, y: 3, z: 0 });
386
+
387
+ for (let i = 0; i < 60; i++) {
388
+ pw.step(1 / 60);
389
+ }
390
+
391
+ const posAfter = body.translation();
392
+ expect(posAfter.x).toBeCloseTo(10, 0);
393
+ });
394
+
395
+ it('kinematic body honors ccdEnabled through the ECS bridge', async () => {
396
+ // Regression: the kinematic arm of ensureBody omitted setCcdEnabled, so
397
+ // a fast kinematic mover (bullet) tunneled through dynamics. A kinematic
398
+ // body spawned with ccdEnabled:true must carry CCD on the Rapier body.
399
+ const RAPIER = await loadRapier3D();
400
+ if ('code' in RAPIER) {
401
+ expect(RAPIER.code).toBe('wasm-load-failed');
402
+ return;
403
+ }
404
+
405
+ const world = prepareWorld();
406
+ const pw = createRapier3DPhysicsWorld(RAPIER);
407
+ world.insertResource('PhysicsWorld', pw);
408
+
409
+ world
410
+ .spawn(
411
+ { component: Transform as never, data: { pos: [0, 1, 0] } },
412
+ {
413
+ component: RigidBody as never,
414
+ data: { type: RigidBodyTypeValue.kinematic, ccdEnabled: true },
415
+ },
416
+ {
417
+ component: Collider as never,
418
+ data: { shape: ColliderShapeValue.sphere, radius: 0.2 },
419
+ },
420
+ )
421
+ .unwrap();
422
+
423
+ registerPhysicsSystems(world);
424
+ world.update(1 / 60).unwrap();
425
+ world.update(1 / 60).unwrap();
426
+
427
+ let ccdBodies = 0;
428
+ pw.raw.forEachRigidBody((b: { isCcdEnabled(): boolean }) => {
429
+ if (b.isCcdEnabled()) ccdBodies++;
430
+ });
431
+ expect(ccdBodies).toBe(1);
432
+ });
433
+
434
+ it('despawn: removeEntity reduces body count', async () => {
435
+ const RAPIER = await loadRapier3D();
436
+ if ('code' in RAPIER) {
437
+ expect(RAPIER.code).toBe('wasm-load-failed');
438
+ return;
439
+ }
440
+
441
+ const pw = createRapier3DPhysicsWorld(RAPIER);
442
+
443
+ const body = pw.raw.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0));
444
+ body.userData = 4;
445
+ pw.raw.createCollider(RAPIER.ColliderDesc.ball(0.5), body);
446
+ pw.registerBody(4, body.handle);
447
+
448
+ pw.step(1 / 60);
449
+ expect(pw.getBodyCount()).toBeGreaterThan(0);
450
+
451
+ pw.removeEntity(4);
452
+ expect(pw.getBodyCount()).toBe(0);
453
+ });
454
+ });
455
+
456
+ describe('bug-20260529 M1 real ECS bridge (regression)', () => {
457
+ it('dynamic ball falls + static ground unchanged through registerPhysicsSystems', async () => {
458
+ const RAPIER = await loadRapier3D();
459
+ if ('code' in RAPIER) {
460
+ expect(RAPIER.code).toBe('wasm-load-failed');
461
+ return;
462
+ }
463
+
464
+ const world = prepareWorld();
465
+ const pw = createRapier3DPhysicsWorld(RAPIER);
466
+ world.insertResource('PhysicsWorld', pw);
467
+
468
+ const dynamicEntity = world
469
+ .spawn(
470
+ { component: Transform as never, data: { pos: [0, 5, 0] } },
471
+ {
472
+ component: RigidBody as never,
473
+ data: {
474
+ type: RigidBodyTypeValue.dynamic,
475
+ mass: 1,
476
+ linearDamping: 0,
477
+ angularDamping: 0,
478
+ gravityScale: 1,
479
+ },
480
+ },
481
+ {
482
+ component: Collider as never,
483
+ data: { shape: 1, radius: 0.5, friction: 0.5, restitution: 0 },
484
+ },
485
+ )
486
+ .unwrap();
487
+
488
+ const staticEntity = world
489
+ .spawn(
490
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
491
+ {
492
+ component: RigidBody as never,
493
+ data: { type: RigidBodyTypeValue.static },
494
+ },
495
+ {
496
+ component: Collider as never,
497
+ data: {
498
+ shape: 0,
499
+ halfExtents: [10, 1, 10],
500
+ friction: 0.5,
501
+ restitution: 0,
502
+ },
503
+ },
504
+ )
505
+ .unwrap();
506
+
507
+ const initDynamic = world.get(dynamicEntity, Transform as never);
508
+ const initStatic = world.get(staticEntity, Transform as never);
509
+ expect(initDynamic.ok).toBe(true);
510
+ expect(initStatic.ok).toBe(true);
511
+ if (!initDynamic.ok || !initStatic.ok) return;
512
+ const dynPosYBefore = (initDynamic.value as { pos: Float32Array }).pos[1] as number;
513
+ expect(dynPosYBefore).toBeCloseTo(5, 1);
514
+
515
+ registerPhysicsSystems(world);
516
+
517
+ for (let i = 0; i < 60; i++) {
518
+ world.update(1 / 60).unwrap();
519
+ world.update(1 / 60).unwrap();
520
+ }
521
+
522
+ const finalDynamic = world.get(dynamicEntity, Transform as never);
523
+ if (!finalDynamic.ok) {
524
+ expect(finalDynamic.ok).toBe(true);
525
+ return;
526
+ }
527
+ const dynPosYAfter = (finalDynamic.value as { pos: Float32Array }).pos[1] as number;
528
+ expect(dynPosYAfter).toBeLessThan(4.5);
529
+
530
+ const finalStatic = world.get(staticEntity, Transform as never);
531
+ if (!finalStatic.ok) {
532
+ expect(finalStatic.ok).toBe(true);
533
+ return;
534
+ }
535
+ const staticPosYAfter = (finalStatic.value as { pos: Float32Array }).pos[1] as number;
536
+ expect(staticPosYAfter).toBeCloseTo(0, 1);
537
+
538
+ const bodyCount = pw.getBodyCount();
539
+ expect(bodyCount).toBe(2);
540
+ });
541
+
542
+ // solo-round26 (P7 residue): a BARE Collider (no RigidBody) is the natural
543
+ // way to author static level geometry (floors, walls). The Collider
544
+ // component docstring promises "Entities with Collider but no RigidBody are
545
+ // treated as static colliders" — but physicsSyncBackend used to gate on a
546
+ // RigidBody column, so a bare-Collider floor was NEVER simulated and a
547
+ // dynamic ball fell straight through it. This locks the fix: the floor is
548
+ // synthesized as an implicit static body and the ball settles on it.
549
+ it('bare Collider (no RigidBody) acts as a static floor — dynamic ball settles, not falls through', async () => {
550
+ const RAPIER = await loadRapier3D();
551
+ if ('code' in RAPIER) {
552
+ expect(RAPIER.code).toBe('wasm-load-failed');
553
+ return;
554
+ }
555
+
556
+ const world = prepareWorld();
557
+ const pw = createRapier3DPhysicsWorld(RAPIER);
558
+ world.insertResource('PhysicsWorld', pw);
559
+
560
+ // Ball: dynamic body + sphere collider dropped from y=5.
561
+ const ball = world
562
+ .spawn(
563
+ { component: Transform as never, data: { pos: [0, 5, 0] } },
564
+ {
565
+ component: RigidBody as never,
566
+ data: {
567
+ type: RigidBodyTypeValue.dynamic,
568
+ mass: 1,
569
+ linearDamping: 0,
570
+ angularDamping: 0,
571
+ gravityScale: 1,
572
+ },
573
+ },
574
+ {
575
+ component: Collider as never,
576
+ data: {
577
+ shape: ColliderShapeValue.sphere,
578
+ radius: 0.5,
579
+ friction: 0.5,
580
+ restitution: 0,
581
+ },
582
+ },
583
+ )
584
+ .unwrap();
585
+
586
+ // Floor: a BARE Collider — NO RigidBody. Cuboid top at y = 0 + 0.5 = 0.5.
587
+ const floor = world
588
+ .spawn(
589
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
590
+ {
591
+ component: Collider as never,
592
+ data: {
593
+ shape: ColliderShapeValue.cuboid,
594
+ halfExtents: [10, 0.5, 10],
595
+ friction: 0.5,
596
+ restitution: 0,
597
+ },
598
+ },
599
+ )
600
+ .unwrap();
601
+
602
+ // Sanity: the floor archetype genuinely has no RigidBody column.
603
+ expect(world.get(floor, RigidBody as never).ok).toBe(false);
604
+
605
+ registerPhysicsSystems(world);
606
+
607
+ for (let i = 0; i < 90; i++) {
608
+ world.update(1 / 60).unwrap();
609
+ world.update(1 / 60).unwrap();
610
+ }
611
+
612
+ const finalBall = world.get(ball, Transform as never);
613
+ expect(finalBall.ok).toBe(true);
614
+ if (!finalBall.ok) return;
615
+ const ballY = (finalBall.value as { pos: Float32Array }).pos[1] as number;
616
+ // Rests at floorTop (0.5) + radius (0.5) = ~1.0 — NOT fallen through to
617
+ // large-negative y (the pre-fix behavior was y ≈ -20 and still falling).
618
+ expect(ballY).toBeGreaterThan(0.6);
619
+ expect(ballY).toBeLessThan(1.4);
620
+
621
+ // Both bodies exist in the sim (the bare-Collider floor now gets a body).
622
+ expect(pw.getBodyCount()).toBe(2);
623
+ });
624
+ });
625
+
626
+ describe('rapier-authored-transform-pose.test.ts', () => {
627
+ it('applies authored rotation and scale to a static cuboid collider', async () => {
628
+ const RAPIER = await loadRapier3D();
629
+ if ('code' in RAPIER) {
630
+ expect(RAPIER.code).toBe('wasm-load-failed');
631
+ return;
632
+ }
633
+
634
+ const world = prepareWorld();
635
+ const pw = createRapier3DPhysicsWorld(RAPIER);
636
+ world.insertResource('PhysicsWorld', pw);
637
+ const obstacle = world
638
+ .spawn(
639
+ {
640
+ component: Transform as never,
641
+ data: {
642
+ quat: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
643
+ scale: [2, 1, 1],
644
+ },
645
+ },
646
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
647
+ {
648
+ component: Collider as never,
649
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 0.25, 0.25] },
650
+ },
651
+ )
652
+ .unwrap();
653
+ registerPropagateTransforms(world);
654
+ registerPhysicsSystems(world);
655
+ world.update(1 / 60).unwrap();
656
+ world.update(1 / 60).unwrap();
657
+
658
+ const alongX = pw.raycast(
659
+ Float32Array.of(-5, 0, 0) as never,
660
+ Float32Array.of(1, 0, 0) as never,
661
+ 10,
662
+ );
663
+ const alongY = pw.raycast(
664
+ Float32Array.of(0, -5, 0) as never,
665
+ Float32Array.of(0, 1, 0) as never,
666
+ 10,
667
+ );
668
+ expect(alongX?.entity).toBe(obstacle);
669
+ expect(alongY?.entity).toBe(obstacle);
670
+ // Rotation swaps the local X/Y spans. Scale doubles the local-X span,
671
+ // so the world Y ray hits its near face at -2, not the unscaled -1.
672
+ expect(alongX?.timeOfImpact).toBeCloseTo(4.75, 1);
673
+ expect(alongY?.timeOfImpact).toBeCloseTo(3, 1);
674
+ });
675
+
676
+ it('updates a static collider when its authored Transform pose changes', async () => {
677
+ const RAPIER = await loadRapier3D();
678
+ if ('code' in RAPIER) {
679
+ expect(RAPIER.code).toBe('wasm-load-failed');
680
+ return;
681
+ }
682
+
683
+ const world = prepareWorld();
684
+ const pw = createRapier3DPhysicsWorld(RAPIER);
685
+ world.insertResource('PhysicsWorld', pw);
686
+ const obstacle = world
687
+ .spawn(
688
+ { component: Transform as never, data: { scale: [1, 1, 1] } },
689
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
690
+ {
691
+ component: Collider as never,
692
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 0.25, 0.25] },
693
+ },
694
+ )
695
+ .unwrap();
696
+ registerPropagateTransforms(world);
697
+ registerPhysicsSystems(world);
698
+ world.update(1 / 60).unwrap();
699
+ world.update(1 / 60).unwrap();
700
+ world.set(obstacle, Transform as never, {
701
+ quat: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
702
+ scale: [2, 1, 1],
703
+ });
704
+ world.update(1 / 60).unwrap();
705
+ world.update(1 / 60).unwrap();
706
+
707
+ const alongY = pw.raycast(
708
+ Float32Array.of(0, -5, 0) as never,
709
+ Float32Array.of(0, 1, 0) as never,
710
+ 10,
711
+ );
712
+ expect(alongY?.entity).toBe(obstacle);
713
+ expect(alongY?.timeOfImpact).toBeCloseTo(3, 1);
714
+ });
715
+ });
716
+
717
+ describe('feat-20260709 M4 / w18 -- cuboid halfExtents array passes through the 3D bridge', () => {
718
+ it('per-axis resting heights track halfExtents[1] (axis-order regression)', async () => {
719
+ const RAPIER = await loadRapier3D();
720
+ if ('code' in RAPIER) {
721
+ expect(RAPIER.code).toBe('wasm-load-failed');
722
+ return;
723
+ }
724
+
725
+ // Two grounds with distinct halfExtents[1] (Y half-height) at the same
726
+ // base Y. A ball dropped on each rests at base + halfY + radius. If the
727
+ // array collapse swizzled axes (e.g. read [2] where [1] was meant) or
728
+ // dropped an element, the two resting heights would not differ by the
729
+ // Y-half delta -- a dimension- AND axis-order-sensitive regression.
730
+ async function restHeight(halfExtents: readonly [number, number, number]): Promise<number> {
731
+ const world = prepareWorld();
732
+ const pw = createRapier3DPhysicsWorld(RAPIER as never);
733
+ world.insertResource('PhysicsWorld', pw);
734
+ const ball = world
735
+ .spawn(
736
+ { component: Transform as never, data: { pos: [0, 8, 0] } },
737
+ {
738
+ component: RigidBody as never,
739
+ data: { type: RigidBodyTypeValue.dynamic, mass: 1, gravityScale: 1 },
740
+ },
741
+ {
742
+ component: Collider as never,
743
+ data: { shape: 1, radius: 0.5, friction: 0.5, restitution: 0 },
744
+ },
745
+ )
746
+ .unwrap();
747
+ world
748
+ .spawn(
749
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
750
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
751
+ {
752
+ component: Collider as never,
753
+ data: { shape: 0, halfExtents, friction: 0.5, restitution: 0 },
754
+ },
755
+ )
756
+ .unwrap();
757
+ registerPhysicsSystems(world);
758
+ for (let i = 0; i < 300; i++) {
759
+ world.update(1 / 60).unwrap();
760
+ world.update(1 / 60).unwrap();
761
+ }
762
+ const r = world.get(ball, Transform as never);
763
+ expect(r.ok).toBe(true);
764
+ return r.ok ? ((r.value as { pos: Float32Array }).pos[1] as number) : Number.NaN;
765
+ }
766
+
767
+ // Thin ground: top at 0.5, ball rests ~1.0.
768
+ const thin = await restHeight([10, 0.5, 10]);
769
+ // Thick ground: top at 3.0, ball rests ~3.5.
770
+ const thick = await restHeight([10, 3, 10]);
771
+ expect(thin).toBeGreaterThan(0.7);
772
+ expect(thin).toBeLessThan(1.3);
773
+ expect(thick).toBeGreaterThan(3.2);
774
+ expect(thick).toBeLessThan(3.8);
775
+ // Delta must track the Y-half difference (3 - 0.5 = 2.5).
776
+ expect(thick - thin).toBeGreaterThan(2.0);
777
+ });
778
+ });
779
+
780
+ describe('childof-kinematic-world-mirror.test.ts (regression)', () => {
781
+ // A ChildOf kinematic collider (e.g. a Guardian attack sensor parented to
782
+ // its body) must have its Rapier collider follow the parent in WORLD space.
783
+ // The bug: physicsSyncBackend's kinematic mirror fed LOCAL posX/Y/Z to
784
+ // setKinematicPosition, so a child with local pos (0,0,0) had its collider
785
+ // pinned at the world origin forever while only its ECS Transform followed
786
+ // the parent (via propagateTransforms). Symptom in the collectathon: the
787
+ // player at spawn (origin) overlapped ALL guardian attack sensors at once,
788
+ // and once the player roamed off-origin no sensor could ever reach it.
789
+ it('a ChildOf kinematic sensor overlaps a probe at the parent world pos, not the origin', async () => {
790
+ const RAPIER = await loadRapier3D();
791
+ if ('code' in RAPIER) {
792
+ expect(RAPIER.code).toBe('wasm-load-failed');
793
+ return;
794
+ }
795
+
796
+ const world = prepareWorld();
797
+ const pw = createRapier3DPhysicsWorld(RAPIER);
798
+ world.insertResource('PhysicsWorld', pw);
799
+
800
+ // Parent: a kinematic body (NO CharacterController, so the kinematic
801
+ // mirror -- not moveAndSlide -- drives it) placed far from the origin.
802
+ const PARENT_X = 8;
803
+ const parent = world
804
+ .spawn(
805
+ { component: Transform as never, data: { pos: [PARENT_X, 0, 0] } },
806
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
807
+ {
808
+ component: Collider as never,
809
+ data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
810
+ },
811
+ )
812
+ .unwrap();
813
+
814
+ // Child sensor: ChildOf the parent with LOCAL pos (0,0,0), so its world
815
+ // pos equals the parent's. This is the shape that regressed.
816
+ const sensor = world
817
+ .spawn(
818
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
819
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
820
+ {
821
+ component: Collider as never,
822
+ data: { shape: ColliderShapeValue.sphere, radius: 1, isSensor: true },
823
+ },
824
+ { component: ChildOf as never, data: { parent } },
825
+ { component: CollidingEntities as never, data: { entities: [] } },
826
+ )
827
+ .unwrap();
828
+
829
+ // Probe at the ORIGIN: if the sensor collider were (wrongly) pinned at
830
+ // the origin, it would overlap this probe.
831
+ const originProbe = world
832
+ .spawn(
833
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
834
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
835
+ {
836
+ component: Collider as never,
837
+ data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
838
+ },
839
+ )
840
+ .unwrap();
841
+
842
+ // Probe at the PARENT world pos: the sensor must overlap THIS one once it
843
+ // correctly follows the parent.
844
+ const farProbe = world
845
+ .spawn(
846
+ { component: Transform as never, data: { pos: [PARENT_X, 0, 0] } },
847
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
848
+ {
849
+ component: Collider as never,
850
+ data: { shape: ColliderShapeValue.sphere, radius: 0.3 },
851
+ },
852
+ )
853
+ .unwrap();
854
+
855
+ registerPropagateTransforms(world);
856
+ registerPhysicsSystems(world);
857
+ for (let i = 0; i < 5; i++) {
858
+ world.update(1 / 60).unwrap();
859
+ world.update(1 / 60).unwrap();
860
+ }
861
+
862
+ const colliding = world.get(sensor, CollidingEntities as never);
863
+ expect(colliding.ok).toBe(true);
864
+ if (!colliding.ok) return;
865
+ const overlaps = Array.from(
866
+ (colliding.value as unknown as { entities: ArrayLike<number> }).entities,
867
+ );
868
+
869
+ // The sensor follows the parent to world x=8: it overlaps the far probe
870
+ // and NOT the origin probe.
871
+ expect(overlaps).toContain(farProbe as unknown as number);
872
+ expect(overlaps).not.toContain(originProbe as unknown as number);
873
+ });
874
+ });
875
+ });
876
+ }
877
+
878
+ {
879
+ // ─── from wasm-loader.test.ts ───
880
+
881
+ describe('wasm-loader.test.ts', () => {
882
+ describe('feat-20260528 M2 t10 Rapier3D WASM loader', () => {
883
+ it('loadRapier3D should import and init rapier3d-compat returning a RAPIER instance', async () => {
884
+ const result = await loadRapier3D();
885
+
886
+ if ('code' in result) {
887
+ expect(result.code).toBe('wasm-load-failed');
888
+ return;
889
+ }
890
+
891
+ expect(result).toBeDefined();
892
+ expect(typeof result.version).toBe('function');
893
+ });
894
+
895
+ it('loadRapier3D RAPIER instance should support World + RigidBody creation', async () => {
896
+ const rapier = await loadRapier3D();
897
+
898
+ if ('code' in rapier) {
899
+ expect(rapier.code).toBe('wasm-load-failed');
900
+ return;
901
+ }
902
+
903
+ const world2 = new rapier.World({ x: 0, y: -9.81, z: 0 });
904
+ expect(world2).toBeDefined();
905
+
906
+ const bodyDesc = rapier.RigidBodyDesc.dynamic()
907
+ .setTranslation(0, 5, 0)
908
+ .setLinearDamping(0.1)
909
+ .setAngularDamping(0.1);
910
+ const body = world2.createRigidBody(bodyDesc);
911
+ expect(body).toBeDefined();
912
+ expect(typeof body.handle).toBe('number');
913
+ expect(body.handle).toBeGreaterThanOrEqual(0);
914
+
915
+ const colliderDesc = rapier.ColliderDesc.ball(0.5).setFriction(0.5).setRestitution(0.3);
916
+ const collider = world2.createCollider(colliderDesc, body);
917
+ expect(collider).toBeDefined();
918
+ expect(typeof collider.handle).toBe('number');
919
+ });
920
+
921
+ it('loadRapier3D should step simulation without errors', async () => {
922
+ const rapier = await loadRapier3D();
923
+
924
+ if ('code' in rapier) {
925
+ expect(rapier.code).toBe('wasm-load-failed');
926
+ return;
927
+ }
928
+
929
+ const world3 = new rapier.World({ x: 0, y: -9.81, z: 0 });
930
+ const body = world3.createRigidBody(
931
+ rapier.RigidBodyDesc.dynamic().setTranslation(0, 10, 0),
932
+ );
933
+ world3.createCollider(rapier.ColliderDesc.ball(0.5), body);
934
+
935
+ for (let i = 0; i < 60; i++) {
936
+ world3.step();
937
+ }
938
+
939
+ const pos = body.translation();
940
+ expect(pos.y).toBeLessThan(10);
941
+ });
942
+ });
943
+ });
944
+ }
945
+
946
+ describe('incremental physics ECS reconciliation', () => {
947
+ async function loadOrSkip() {
948
+ const RAPIER = await loadRapier3D();
949
+ if ('code' in RAPIER) {
950
+ expect(RAPIER.code).toBe('wasm-load-failed');
951
+ return undefined;
952
+ }
953
+ return RAPIER;
954
+ }
955
+
956
+ function spawnBox(
957
+ world: World,
958
+ options: {
959
+ readonly pos?: readonly [number, number, number];
960
+ readonly bodyType?: number | 'implicit';
961
+ readonly halfExtents?: readonly [number, number, number];
962
+ } = {},
963
+ ): number {
964
+ const transform = {
965
+ component: Transform as never,
966
+ data: { pos: options.pos ?? [0, 0, 0] },
967
+ };
968
+ const collider = {
969
+ component: Collider as never,
970
+ data: {
971
+ shape: ColliderShapeValue.cuboid,
972
+ halfExtents: options.halfExtents ?? [1, 1, 1],
973
+ },
974
+ };
975
+ const result =
976
+ options.bodyType === 'implicit'
977
+ ? world.spawn(transform, collider)
978
+ : world.spawn(
979
+ transform,
980
+ {
981
+ component: RigidBody as never,
982
+ data: { type: options.bodyType ?? RigidBodyTypeValue.static },
983
+ },
984
+ collider,
985
+ );
986
+ return result.unwrap() as unknown as number;
987
+ }
988
+
989
+ it('does not mark initialization complete before PhysicsWorld becomes ready', async () => {
990
+ const RAPIER = await loadOrSkip();
991
+ if (!RAPIER) return;
992
+ const world = prepareWorld();
993
+ const entity = spawnBox(world);
994
+ registerPhysicsSystems(world);
995
+
996
+ runPhysicsTicks(world);
997
+
998
+ const pw = createRapier3DPhysicsWorld(RAPIER);
999
+ world.insertResource('PhysicsWorld', pw);
1000
+ runPhysicsTicks(world);
1001
+ expect(pw.hasBody(entity)).toBe(true);
1002
+ expect(pw.getBodyCount()).toBe(1);
1003
+ });
1004
+
1005
+ it('releases ECS projection contexts on teardown and rejects reuse after dispose', async () => {
1006
+ const RAPIER = await loadOrSkip();
1007
+ if (!RAPIER) return;
1008
+ const world = prepareWorld();
1009
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1010
+ world.insertResource('PhysicsWorld', pw);
1011
+ spawnBox(world);
1012
+ const unregister = registerPhysicsSystems(world);
1013
+ runPhysicsTicks(world);
1014
+
1015
+ // biome-ignore lint/suspicious/noExplicitAny: test-only lifecycle inspection.
1016
+ const internals = pw as any;
1017
+ expect(internals.syncState?.world).toBe(world);
1018
+ expect(internals.moveContext?.world).toBe(world);
1019
+
1020
+ unregister();
1021
+ expect(internals.syncState).toBeUndefined();
1022
+ expect(internals.moveContext).toBeUndefined();
1023
+
1024
+ const unregisterAgain = registerPhysicsSystems(world);
1025
+ runPhysicsTicks(world);
1026
+ expect(internals.syncState?.world).toBe(world);
1027
+ expect(internals.moveContext?.world).toBe(world);
1028
+ unregisterAgain();
1029
+
1030
+ pw.dispose();
1031
+ expect(internals.syncState).toBeUndefined();
1032
+ expect(internals.moveContext).toBeUndefined();
1033
+ expect(pw.getBodyCount()).toBe(0);
1034
+ expect(pw.getKinematicControllerStates()).toEqual([]);
1035
+ expect(() => pw._syncFromEcs(world, Transform)).toThrow(/disposed/);
1036
+ expect(() => pw.setMoveContext(world, Transform, CharacterController)).toThrow(/disposed/);
1037
+ });
1038
+
1039
+ it('bootstraps once, then performs no query, materialization, or Rapier setters on warm static ticks', async () => {
1040
+ const RAPIER = await loadOrSkip();
1041
+ if (!RAPIER) return;
1042
+ const world = prepareWorld();
1043
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1044
+ world.insertResource('PhysicsWorld', pw);
1045
+ const entity = spawnBox(world);
1046
+ registerPropagateTransforms(world);
1047
+ registerPhysicsSystems(world);
1048
+
1049
+ const ensureSpy = vi.spyOn(pw, 'ensureBody');
1050
+ const syncSpy = vi.spyOn(pw, 'syncAuthoredPose');
1051
+ runPhysicsTicks(world);
1052
+ expect(ensureSpy).toHaveBeenCalledTimes(1);
1053
+ expect(syncSpy).not.toHaveBeenCalled();
1054
+
1055
+ const body = rapierBodyFor(pw, entity);
1056
+ expect(body).toBeDefined();
1057
+ const collider = body?.collider(0);
1058
+ expect(collider).toBeDefined();
1059
+ const translationSpy = vi.spyOn(body, 'setTranslation');
1060
+ const rotationSpy = vi.spyOn(body, 'setRotation');
1061
+ const shapeSpy = vi.spyOn(collider, 'setHalfExtents');
1062
+ const querySpy = vi.spyOn(world, 'query');
1063
+ const pruneSpy = vi.spyOn(pw, 'pruneMissingEntities');
1064
+ ensureSpy.mockClear();
1065
+ syncSpy.mockClear();
1066
+
1067
+ runPhysicsTicks(world, 2);
1068
+
1069
+ expect(querySpy).not.toHaveBeenCalled();
1070
+ expect(ensureSpy).not.toHaveBeenCalled();
1071
+ expect(syncSpy).not.toHaveBeenCalled();
1072
+ expect(pruneSpy).not.toHaveBeenCalled();
1073
+ expect(translationSpy).not.toHaveBeenCalled();
1074
+ expect(rotationSpy).not.toHaveBeenCalled();
1075
+ expect(shapeSpy).not.toHaveBeenCalled();
1076
+ });
1077
+
1078
+ it('coalesces authored Transform writes and consumes the final resolved pose once', async () => {
1079
+ const RAPIER = await loadOrSkip();
1080
+ if (!RAPIER) return;
1081
+ const world = prepareWorld();
1082
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1083
+ world.insertResource('PhysicsWorld', pw);
1084
+ const entity = spawnBox(world);
1085
+ registerPropagateTransforms(world);
1086
+ registerPhysicsSystems(world);
1087
+ runPhysicsTicks(world);
1088
+
1089
+ const ensureSpy = vi.spyOn(pw, 'ensureBody');
1090
+ const syncSpy = vi.spyOn(pw, 'syncAuthoredPose');
1091
+ world.set(entity as never, Transform as never, { pos: [2, 0, 0] }).unwrap();
1092
+ world.set(entity as never, Transform as never, { pos: [4, 0, 0] }).unwrap();
1093
+ world.set(entity as never, Transform as never, { pos: [7, 0, 0] }).unwrap();
1094
+ runPhysicsTicks(world);
1095
+
1096
+ expect(ensureSpy).not.toHaveBeenCalled();
1097
+ expect(syncSpy).toHaveBeenCalledTimes(1);
1098
+ expect(rapierBodyFor(pw, entity)?.translation().x).toBeCloseTo(7, 5);
1099
+ });
1100
+
1101
+ it('uses derived Transform evidence to update only collider descendants after a parent move', async () => {
1102
+ const RAPIER = await loadOrSkip();
1103
+ if (!RAPIER) return;
1104
+ const world = prepareWorld();
1105
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1106
+ world.insertResource('PhysicsWorld', pw);
1107
+ const parent = world
1108
+ .spawn({ component: Transform as never, data: { pos: [1, 0, 0] } })
1109
+ .unwrap();
1110
+ const child = world
1111
+ .spawn(
1112
+ { component: Transform as never, data: { pos: [2, 0, 0] } },
1113
+ { component: ChildOf as never, data: { parent } },
1114
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
1115
+ {
1116
+ component: Collider as never,
1117
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1118
+ },
1119
+ )
1120
+ .unwrap();
1121
+ const unrelated = spawnBox(world, { pos: [20, 0, 0] });
1122
+ registerPropagateTransforms(world);
1123
+ registerPhysicsSystems(world);
1124
+ runPhysicsTicks(world);
1125
+
1126
+ const syncSpy = vi.spyOn(pw, 'syncAuthoredPose');
1127
+ world.set(parent, Transform as never, { pos: [5, 0, 0] }).unwrap();
1128
+ runPhysicsTicks(world);
1129
+
1130
+ expect(syncSpy).toHaveBeenCalledTimes(1);
1131
+ expect(syncSpy).toHaveBeenCalledWith(
1132
+ child,
1133
+ expect.objectContaining({ position: expect.objectContaining({ x: 7 }) }),
1134
+ expect.anything(),
1135
+ 'static',
1136
+ );
1137
+ expect(rapierBodyFor(pw, child)?.translation().x).toBeCloseTo(7, 5);
1138
+ expect(rapierBodyFor(pw, unrelated)?.translation().x).toBeCloseTo(20, 5);
1139
+ });
1140
+
1141
+ it.each([
1142
+ ['static', RigidBodyTypeValue.static],
1143
+ ['non-controller-owned kinematic', RigidBodyTypeValue.kinematic],
1144
+ ] as const)('uses a readable identity world pose for root and parent-cancelled %s bodies', async (_label, bodyType) => {
1145
+ const RAPIER = await loadOrSkip();
1146
+ if (!RAPIER) return;
1147
+ const world = prepareWorld();
1148
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1149
+ world.insertResource('PhysicsWorld', pw);
1150
+ const root = spawnBox(world, { bodyType });
1151
+ const parent = world
1152
+ .spawn({ component: Transform as never, data: { pos: [1, 0, 0] } })
1153
+ .unwrap();
1154
+ const child = world
1155
+ .spawn(
1156
+ { component: Transform as never, data: { pos: [-1, 0, 0] } },
1157
+ { component: ChildOf as never, data: { parent } },
1158
+ { component: RigidBody as never, data: { type: bodyType } },
1159
+ {
1160
+ component: Collider as never,
1161
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1162
+ },
1163
+ )
1164
+ .unwrap();
1165
+ registerPropagateTransforms(world);
1166
+ registerPhysicsSystems(world);
1167
+ runPhysicsTicks(world);
1168
+
1169
+ const rootWorld = world.get(root as never, Transform as never).unwrap().world as Float32Array;
1170
+ const childWorld = world.get(child, Transform as never).unwrap().world as Float32Array;
1171
+ expect(Array.from(rootWorld)).toEqual([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
1172
+ expect(Array.from(childWorld)).toEqual([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
1173
+ expect(rapierBodyFor(pw, root)?.translation().x).toBeCloseTo(0, 5);
1174
+ expect(rapierBodyFor(pw, child)?.translation().x).toBeCloseTo(0, 5);
1175
+
1176
+ world.set(parent, Transform as never, { pos: [3, 0, 0] }).unwrap();
1177
+ world.set(child, Transform as never, { pos: [-3, 0, 0] }).unwrap();
1178
+ runPhysicsTicks(world);
1179
+
1180
+ expect(rapierBodyFor(pw, child)?.translation().x).toBeCloseTo(0, 5);
1181
+ });
1182
+
1183
+ it('does not treat a dynamic body Transform write as an authored Rapier pose', async () => {
1184
+ const RAPIER = await loadOrSkip();
1185
+ if (!RAPIER) return;
1186
+ const world = prepareWorld();
1187
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1188
+ world.insertResource('PhysicsWorld', pw);
1189
+ const entity = spawnBox(world, { bodyType: RigidBodyTypeValue.dynamic });
1190
+ world.set(entity as never, RigidBody as never, { gravityScale: 0 }).unwrap();
1191
+ registerPropagateTransforms(world);
1192
+ registerPhysicsSystems(world);
1193
+ runPhysicsTicks(world);
1194
+
1195
+ const ensureSpy = vi.spyOn(pw, 'ensureBody');
1196
+ const syncSpy = vi.spyOn(pw, 'syncAuthoredPose');
1197
+ world.set(entity as never, Transform as never, { pos: [50, 0, 0] }).unwrap();
1198
+ runPhysicsTicks(world);
1199
+
1200
+ expect(ensureSpy).not.toHaveBeenCalled();
1201
+ expect(syncSpy).not.toHaveBeenCalled();
1202
+ expect(rapierBodyFor(pw, entity)?.translation().x).toBeCloseTo(0, 5);
1203
+ });
1204
+
1205
+ it('reconciles Collider and RigidBody add, remove, and change from the final ECS combination', async () => {
1206
+ const RAPIER = await loadOrSkip();
1207
+ if (!RAPIER) return;
1208
+ const world = prepareWorld();
1209
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1210
+ world.insertResource('PhysicsWorld', pw);
1211
+ const entity = spawnBox(world);
1212
+ registerPhysicsSystems(world);
1213
+ runPhysicsTicks(world);
1214
+
1215
+ const removeSpy = vi.spyOn(pw, 'removeEntity');
1216
+ const ensureSpy = vi.spyOn(pw, 'ensureBody');
1217
+ world.set(entity as never, Collider as never, { halfExtents: [3, 1, 1] }).unwrap();
1218
+ runPhysicsTicks(world);
1219
+ expect(removeSpy).toHaveBeenCalledTimes(1);
1220
+ expect(ensureSpy).toHaveBeenCalledTimes(1);
1221
+ expect(rapierBodyFor(pw, entity)?.collider(0).halfExtents().x).toBeCloseTo(3, 5);
1222
+
1223
+ removeSpy.mockClear();
1224
+ ensureSpy.mockClear();
1225
+ world.removeComponent(entity as never, Collider as never).unwrap();
1226
+ runPhysicsTicks(world);
1227
+ expect(pw.hasBody(entity)).toBe(false);
1228
+ expect(removeSpy).toHaveBeenCalledTimes(1);
1229
+
1230
+ world
1231
+ .addComponent(entity as never, {
1232
+ component: Collider as never,
1233
+ data: { shape: ColliderShapeValue.sphere, radius: 2 },
1234
+ })
1235
+ .unwrap();
1236
+ runPhysicsTicks(world);
1237
+ expect(pw.hasBody(entity)).toBe(true);
1238
+ expect(rapierBodyFor(pw, entity)?.collider(0).radius()).toBeCloseTo(2, 5);
1239
+
1240
+ world.removeComponent(entity as never, RigidBody as never).unwrap();
1241
+ runPhysicsTicks(world);
1242
+ expect(rapierBodyFor(pw, entity)?.bodyType()).toBe(RAPIER.RigidBodyType.Fixed);
1243
+
1244
+ world
1245
+ .addComponent(entity as never, {
1246
+ component: RigidBody as never,
1247
+ data: { type: RigidBodyTypeValue.dynamic, gravityScale: 0 },
1248
+ })
1249
+ .unwrap();
1250
+ runPhysicsTicks(world);
1251
+ expect(rapierBodyFor(pw, entity)?.bodyType()).toBe(RAPIER.RigidBodyType.Dynamic);
1252
+ expect(pw.getBodyCount()).toBe(1);
1253
+
1254
+ world.set(entity as never, RigidBody as never, { type: RigidBodyTypeValue.static }).unwrap();
1255
+ world.set(entity as never, RigidBody as never, { type: RigidBodyTypeValue.kinematic }).unwrap();
1256
+ runPhysicsTicks(world);
1257
+ expect(rapierBodyFor(pw, entity)?.bodyType()).toBe(RAPIER.RigidBodyType.KinematicPositionBased);
1258
+ expect(pw.getBodyCount()).toBe(1);
1259
+ });
1260
+
1261
+ it('switches kinematic pose ownership when CharacterController is added or removed', async () => {
1262
+ const RAPIER = await loadOrSkip();
1263
+ if (!RAPIER) return;
1264
+ const world = prepareWorld();
1265
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1266
+ world.insertResource('PhysicsWorld', pw);
1267
+ const entity = spawnBox(world, { bodyType: RigidBodyTypeValue.kinematic });
1268
+ registerPropagateTransforms(world);
1269
+ registerPhysicsSystems(world);
1270
+ runPhysicsTicks(world);
1271
+
1272
+ const syncSpy = vi.spyOn(pw, 'syncAuthoredPose');
1273
+ world.set(entity as never, Transform as never, { pos: [9, 0, 0] }).unwrap();
1274
+ world
1275
+ .addComponent(entity as never, { component: CharacterController as never, data: {} })
1276
+ .unwrap();
1277
+ runPhysicsTicks(world);
1278
+ expect(syncSpy).not.toHaveBeenCalled();
1279
+ expect(rapierBodyFor(pw, entity)?.translation().x).toBeCloseTo(0, 5);
1280
+
1281
+ world.removeComponent(entity as never, CharacterController as never).unwrap();
1282
+ runPhysicsTicks(world);
1283
+ expect(syncSpy).toHaveBeenCalledTimes(1);
1284
+ expect(rapierBodyFor(pw, entity)?.translation().x).toBeCloseTo(9, 5);
1285
+ });
1286
+
1287
+ it('rebuilds only the cached character controller receipt when offset changes', async () => {
1288
+ const RAPIER = await loadOrSkip();
1289
+ if (!RAPIER) return;
1290
+ const world = prepareWorld();
1291
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1292
+ world.insertResource('PhysicsWorld', pw);
1293
+ const entity = spawnBox(world, { bodyType: RigidBodyTypeValue.kinematic });
1294
+ world
1295
+ .addComponent(entity as never, {
1296
+ component: CharacterController as never,
1297
+ data: { offset: 0.02 },
1298
+ })
1299
+ .unwrap();
1300
+ registerPhysicsSystems(world);
1301
+ runPhysicsTicks(world);
1302
+ pw.moveAndSlide(entity, Float32Array.of(0, 0, 0) as never);
1303
+
1304
+ const body = rapierBodyFor(pw, entity);
1305
+ // biome-ignore lint/suspicious/noExplicitAny: test-only inspection of the backend receipt cache.
1306
+ const receipts = (pw as any).kccCache as Map<number, unknown>;
1307
+ // biome-ignore lint/suspicious/noExplicitAny: test-only inspection of the backend receipt cache.
1308
+ const offsets = (pw as any).kccOffsets as Map<number, number>;
1309
+ const receipt = receipts.get(entity);
1310
+ const removeEntitySpy = vi.spyOn(pw, 'removeEntity');
1311
+ const ensureBodySpy = vi.spyOn(pw, 'ensureBody');
1312
+
1313
+ world.set(entity as never, CharacterController as never, { offset: 0.5 }).unwrap();
1314
+ runPhysicsTicks(world);
1315
+
1316
+ expect(rapierBodyFor(pw, entity)).toBe(body);
1317
+ expect(removeEntitySpy).not.toHaveBeenCalled();
1318
+ expect(ensureBodySpy).not.toHaveBeenCalled();
1319
+ expect(receipts.get(entity)).not.toBe(receipt);
1320
+ expect(offsets.get(entity)).toBeCloseTo(0.5, 5);
1321
+ });
1322
+
1323
+ it('rebuilds the cached character controller receipt from a same-tick remove and add', async () => {
1324
+ const RAPIER = await loadOrSkip();
1325
+ if (!RAPIER) return;
1326
+ const world = prepareWorld();
1327
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1328
+ world.insertResource('PhysicsWorld', pw);
1329
+ const entity = spawnBox(world, { bodyType: RigidBodyTypeValue.kinematic });
1330
+ world
1331
+ .addComponent(entity as never, {
1332
+ component: CharacterController as never,
1333
+ data: { offset: 0.02 },
1334
+ })
1335
+ .unwrap();
1336
+ registerPhysicsSystems(world);
1337
+ runPhysicsTicks(world);
1338
+ pw.moveAndSlide(entity, Float32Array.of(0, 0, 0) as never);
1339
+
1340
+ const body = rapierBodyFor(pw, entity);
1341
+ // biome-ignore lint/suspicious/noExplicitAny: test-only inspection of the backend receipt cache.
1342
+ const receipts = (pw as any).kccCache as Map<number, unknown>;
1343
+ // biome-ignore lint/suspicious/noExplicitAny: test-only inspection of the backend receipt cache.
1344
+ const offsets = (pw as any).kccOffsets as Map<number, number>;
1345
+ const receipt = receipts.get(entity);
1346
+ const removeEntitySpy = vi.spyOn(pw, 'removeEntity');
1347
+ const ensureBodySpy = vi.spyOn(pw, 'ensureBody');
1348
+
1349
+ world.removeComponent(entity as never, CharacterController as never).unwrap();
1350
+ world
1351
+ .addComponent(entity as never, {
1352
+ component: CharacterController as never,
1353
+ data: { offset: 0.4 },
1354
+ })
1355
+ .unwrap();
1356
+ runPhysicsTicks(world);
1357
+
1358
+ expect(rapierBodyFor(pw, entity)).toBe(body);
1359
+ expect(removeEntitySpy).not.toHaveBeenCalled();
1360
+ expect(ensureBodySpy).not.toHaveBeenCalled();
1361
+ expect(receipts.get(entity)).not.toBe(receipt);
1362
+ expect(offsets.get(entity)).toBeCloseTo(0.4, 5);
1363
+ });
1364
+
1365
+ it('keeps the cached character controller receipt for grounded-only writes', async () => {
1366
+ const RAPIER = await loadOrSkip();
1367
+ if (!RAPIER) return;
1368
+ const world = prepareWorld();
1369
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1370
+ world.insertResource('PhysicsWorld', pw);
1371
+ const entity = spawnBox(world, { bodyType: RigidBodyTypeValue.kinematic });
1372
+ world
1373
+ .addComponent(entity as never, {
1374
+ component: CharacterController as never,
1375
+ data: { offset: 0.02 },
1376
+ })
1377
+ .unwrap();
1378
+ registerPhysicsSystems(world);
1379
+ runPhysicsTicks(world);
1380
+ pw.moveAndSlide(entity, Float32Array.of(0, 0, 0) as never);
1381
+ runPhysicsTicks(world);
1382
+
1383
+ // biome-ignore lint/suspicious/noExplicitAny: test-only inspection of the backend receipt cache.
1384
+ const receipts = (pw as any).kccCache as Map<number, unknown>;
1385
+ const receipt = receipts.get(entity);
1386
+ const body = rapierBodyFor(pw, entity);
1387
+ const removeEntitySpy = vi.spyOn(pw, 'removeEntity');
1388
+ const ensureBodySpy = vi.spyOn(pw, 'ensureBody');
1389
+
1390
+ world.set(entity as never, CharacterController as never, { grounded: true }).unwrap();
1391
+ runPhysicsTicks(world);
1392
+
1393
+ expect(rapierBodyFor(pw, entity)).toBe(body);
1394
+ expect(removeEntitySpy).not.toHaveBeenCalled();
1395
+ expect(ensureBodySpy).not.toHaveBeenCalled();
1396
+ expect(receipts.get(entity)).toBe(receipt);
1397
+ });
1398
+
1399
+ it('removes the old generation and creates the final entity after same-slot reuse', async () => {
1400
+ const RAPIER = await loadOrSkip();
1401
+ if (!RAPIER) return;
1402
+ const world = prepareWorld();
1403
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1404
+ world.insertResource('PhysicsWorld', pw);
1405
+ const previous = spawnBox(world, { pos: [1, 0, 0] });
1406
+ registerPhysicsSystems(world);
1407
+ runPhysicsTicks(world);
1408
+
1409
+ world.despawn(previous as never).unwrap();
1410
+ const replacement = spawnBox(world, { pos: [11, 0, 0] });
1411
+ expect(replacement).not.toBe(previous);
1412
+ expect(replacement & 0x00ffffff).toBe(previous & 0x00ffffff);
1413
+ runPhysicsTicks(world);
1414
+
1415
+ expect(pw.hasBody(previous)).toBe(false);
1416
+ expect(pw.hasBody(replacement)).toBe(true);
1417
+ expect(pw.getBodyCount()).toBe(1);
1418
+ expect(rapierBodyFor(pw, replacement)?.translation().x).toBeCloseTo(11, 5);
1419
+ });
1420
+
1421
+ it('falls back to a full reconcile after projection journal overflow', async () => {
1422
+ const RAPIER = await loadOrSkip();
1423
+ if (!RAPIER) return;
1424
+ const world = prepareWorld();
1425
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1426
+ world.insertResource('PhysicsWorld', pw);
1427
+ const entity = spawnBox(world);
1428
+ registerPropagateTransforms(world);
1429
+ registerPhysicsSystems(world);
1430
+ runPhysicsTicks(world);
1431
+
1432
+ const removeSpy = vi.spyOn(pw, 'removeEntity');
1433
+ const ensureSpy = vi.spyOn(pw, 'ensureBody');
1434
+ const syncSpy = vi.spyOn(pw, 'syncAuthoredPose');
1435
+ let finalX = 0;
1436
+ for (let index = 0; index <= 65_536; index += 1) {
1437
+ finalX = index % 19;
1438
+ world.set(entity as never, Transform as never, { pos: [finalX, 0, 0] }).unwrap();
1439
+ }
1440
+ runPhysicsTicks(world);
1441
+
1442
+ expect(removeSpy).toHaveBeenCalledTimes(1);
1443
+ expect(ensureSpy).toHaveBeenCalledTimes(1);
1444
+ expect(syncSpy).not.toHaveBeenCalled();
1445
+ expect(pw.getBodyCount()).toBe(1);
1446
+ expect(rapierBodyFor(pw, entity)?.translation().x).toBeCloseTo(finalX, 5);
1447
+ });
1448
+
1449
+ it('retains only an existing fixed Transform-less body and never creates one without Transform', async () => {
1450
+ const RAPIER = await loadOrSkip();
1451
+ if (!RAPIER) return;
1452
+ const world = prepareWorld();
1453
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1454
+ world.insertResource('PhysicsWorld', pw);
1455
+ const fixed = spawnBox(world);
1456
+ const dynamic = spawnBox(world, { bodyType: RigidBodyTypeValue.dynamic });
1457
+ registerPhysicsSystems(world);
1458
+ runPhysicsTicks(world);
1459
+
1460
+ world.removeComponent(fixed as never, Transform as never).unwrap();
1461
+ world.removeComponent(dynamic as never, Transform as never).unwrap();
1462
+ const neverMaterialized = world
1463
+ .spawn({
1464
+ component: Collider as never,
1465
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1] },
1466
+ })
1467
+ .unwrap() as unknown as number;
1468
+ runPhysicsTicks(world);
1469
+
1470
+ expect(pw.hasBody(fixed)).toBe(true);
1471
+ expect(pw.hasBody(dynamic)).toBe(false);
1472
+ expect(pw.hasBody(neverMaterialized)).toBe(false);
1473
+ expect(pw.getBodyCount()).toBe(1);
1474
+ });
1475
+ });
1476
+
1477
+ // ─── feat-20260617 M2 moveAndSlide (kinematic character controller) ───
1478
+ //
1479
+ // Shared scene builder: spawns a kinematic capsule character (RigidBody +
1480
+ // Collider + CharacterController) plus optional static geometry, drives one
1481
+ // world.update(1 / 60).unwrap() to push the bodies into the Rapier world, then returns the
1482
+ // handles so each test can call pw.moveAndSlide(entity, delta) directly.
1483
+
1484
+ {
1485
+ describe('moveAndSlide.test.ts', () => {
1486
+ type Vec3Tuple = readonly [number, number, number];
1487
+
1488
+ interface StaticBox {
1489
+ readonly pos: Vec3Tuple;
1490
+ readonly halfExtents: Vec3Tuple;
1491
+ readonly rotXDeg?: number;
1492
+ }
1493
+
1494
+ async function loadOrNull() {
1495
+ const RAPIER = await loadRapier3D();
1496
+ if ('code' in RAPIER) {
1497
+ expect(RAPIER.code).toBe('wasm-load-failed');
1498
+ return undefined;
1499
+ }
1500
+ return RAPIER;
1501
+ }
1502
+
1503
+ function spawnCharacter(
1504
+ world: World,
1505
+ pos: Vec3Tuple,
1506
+ cc?: Record<string, number>,
1507
+ bodyType: number = RigidBodyTypeValue.kinematic,
1508
+ ): number {
1509
+ const entity = world
1510
+ .spawn(
1511
+ { component: Transform as never, data: { pos: [pos[0], pos[1], pos[2]] } },
1512
+ { component: RigidBody as never, data: { type: bodyType } },
1513
+ {
1514
+ component: Collider as never,
1515
+ data: { shape: 2, radius: 0.3, halfHeight: 0.5, friction: 0.5, restitution: 0 },
1516
+ },
1517
+ { component: CharacterController as never, data: cc ?? {} },
1518
+ )
1519
+ .unwrap();
1520
+ return entity as unknown as number;
1521
+ }
1522
+
1523
+ function spawnStaticBox(world: World, box: StaticBox): number {
1524
+ const data: Record<string, number | readonly number[]> = {
1525
+ shape: 0,
1526
+ halfExtents: [box.halfExtents[0], box.halfExtents[1], box.halfExtents[2]],
1527
+ friction: 0.5,
1528
+ restitution: 0,
1529
+ };
1530
+ const entity = world
1531
+ .spawn(
1532
+ {
1533
+ component: Transform as never,
1534
+ data: { pos: [box.pos[0], box.pos[1], box.pos[2]] },
1535
+ },
1536
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.static } },
1537
+ { component: Collider as never, data },
1538
+ )
1539
+ .unwrap();
1540
+ return entity as unknown as number;
1541
+ }
1542
+
1543
+ function tfPos(world: World, entity: number): { x: number; y: number; z: number } {
1544
+ const r = world.get(entity as never, Transform as never);
1545
+ if (!r.ok) throw new Error('transform missing');
1546
+ const v = r.value as { pos: Float32Array };
1547
+ return { x: v.pos[0] as number, y: v.pos[1] as number, z: v.pos[2] as number };
1548
+ }
1549
+
1550
+ function ccGrounded(world: World, entity: number): boolean {
1551
+ const r = world.get(entity as never, CharacterController as never);
1552
+ if (!r.ok) throw new Error('CharacterController missing');
1553
+ // `grounded` is a `bool` schema field; world.get materializes it as a JS
1554
+ // boolean (not a 0/1 number), so compare against `true` directly. A prior
1555
+ // `!== 0` check compared a boolean to a number and was always truthy.
1556
+ return (r.value as Record<string, boolean>).grounded === true;
1557
+ }
1558
+
1559
+ describe('moveAndSlide basic motion (AC-01/02/03)', () => {
1560
+ it('AC-01 flat walk: actualDelta tracks desiredDelta and grounded=true', async () => {
1561
+ const RAPIER = await loadOrNull();
1562
+ if (!RAPIER) return;
1563
+ const world = prepareWorld();
1564
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1565
+ world.insertResource('PhysicsWorld', pw);
1566
+ registerPhysicsSystems(world);
1567
+
1568
+ // Ground under the character so it is grounded.
1569
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
1570
+ const char = spawnCharacter(world, [0, 0, 0]);
1571
+
1572
+ world.update(1 / 60).unwrap();
1573
+ world.update(1 / 60).unwrap();
1574
+
1575
+ const actual = pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
1576
+ expect(actual[0]).toBeCloseTo(1, 1);
1577
+ expect(Math.abs(actual[2] ?? 0)).toBeLessThan(0.05);
1578
+ expect(ccGrounded(world, char)).toBe(true);
1579
+ expect(tfPos(world, char).x).toBeCloseTo(1, 1);
1580
+ });
1581
+
1582
+ it('AC-02 wall ahead: actualDelta.x clamped below requested and no clip-through', async () => {
1583
+ const RAPIER = await loadOrNull();
1584
+ if (!RAPIER) return;
1585
+ const world = prepareWorld();
1586
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1587
+ world.insertResource('PhysicsWorld', pw);
1588
+ registerPhysicsSystems(world);
1589
+
1590
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
1591
+ // Wall 0.5m ahead (character radius 0.3 → contact well before x=1).
1592
+ spawnStaticBox(world, { pos: [0.8, 0.5, 0], halfExtents: [0.1, 1, 2] });
1593
+ const char = spawnCharacter(world, [0, 0, 0]);
1594
+
1595
+ world.update(1 / 60).unwrap();
1596
+ world.update(1 / 60).unwrap();
1597
+
1598
+ const actual = pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
1599
+ expect(actual[0]).toBeLessThan(1);
1600
+ // Character right edge must not pass the wall left face (~x=0.7).
1601
+ expect(tfPos(world, char).x).toBeLessThan(0.45);
1602
+ });
1603
+
1604
+ it('AC-03 angled into wall: tangential motion survives, normal is eaten', async () => {
1605
+ const RAPIER = await loadOrNull();
1606
+ if (!RAPIER) return;
1607
+ const world = prepareWorld();
1608
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1609
+ world.insertResource('PhysicsWorld', pw);
1610
+ registerPhysicsSystems(world);
1611
+
1612
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
1613
+ // Wall facing -x at x≈0.8.
1614
+ spawnStaticBox(world, { pos: [0.8, 0.5, 0], halfExtents: [0.1, 1, 4] });
1615
+ const char = spawnCharacter(world, [0, 0, 0]);
1616
+
1617
+ world.update(1 / 60).unwrap();
1618
+ world.update(1 / 60).unwrap();
1619
+
1620
+ // Push diagonally into the wall: +x (blocked) and +z (tangential, free).
1621
+ const actual = pw.moveAndSlide(char, Float32Array.of(1, 0, 1) as never);
1622
+ expect(actual[2]).toBeGreaterThan(0.3); // tangential z preserved
1623
+ expect(actual[0]).toBeLessThan(1); // normal x absorbed
1624
+ });
1625
+ });
1626
+
1627
+ describe('moveAndSlide multi-character (regression: two KCCs coexist)', () => {
1628
+ // A second KinematicCharacterController in the same world must not freeze
1629
+ // the first. Before the fix, the per-move propagateModifiedBodyPositionsToColliders
1630
+ // refresh of the shared query pipeline was skipped on the SECOND-and-later
1631
+ // call of a frame, so once a guardian KCC also moved, the player's
1632
+ // computeColliderMovement returned zero from the next frame onward (the
1633
+ // collectathon "character stuck, can't move while a guardian exists" bug).
1634
+ it('two characters far apart both keep moving across frames', async () => {
1635
+ const RAPIER = await loadOrNull();
1636
+ if (!RAPIER) return;
1637
+ const world = prepareWorld();
1638
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1639
+ world.insertResource('PhysicsWorld', pw);
1640
+ registerPhysicsSystems(world);
1641
+
1642
+ // Shared ground; two characters 10m apart so they never interact.
1643
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [30, 0.5, 30] });
1644
+ const a = spawnCharacter(world, [0, 0, 0]);
1645
+ const b = spawnCharacter(world, [10, 0, 0]);
1646
+
1647
+ world.update(1 / 60).unwrap();
1648
+ world.update(1 / 60).unwrap();
1649
+
1650
+ // Drive both KCCs every frame, mirroring player-move + guardian-ai.
1651
+ const startA = tfPos(world, a).x;
1652
+ for (let i = 0; i < 5; i++) {
1653
+ pw.moveAndSlide(a, Float32Array.of(0.1, -0.01, 0) as never);
1654
+ pw.moveAndSlide(b, Float32Array.of(-0.1, -0.01, 0) as never);
1655
+ world.update(1 / 60).unwrap();
1656
+ world.update(1 / 60).unwrap();
1657
+ }
1658
+
1659
+ // Character A must have advanced well past a single frame's step
1660
+ // (~0.5 over 5 frames); the bug froze it at ~0.1 (one frame only).
1661
+ expect(tfPos(world, a).x - startA).toBeGreaterThan(0.3);
1662
+ // Character B moved the other way by a similar magnitude.
1663
+ expect(tfPos(world, b).x).toBeLessThan(10 - 0.3);
1664
+ });
1665
+
1666
+ it('overlapping SENSOR does not jam a grounded character (sensors are not obstacles)', async () => {
1667
+ const RAPIER = await loadOrNull();
1668
+ if (!RAPIER) return;
1669
+ const world = prepareWorld();
1670
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1671
+ world.insertResource('PhysicsWorld', pw);
1672
+ registerPhysicsSystems(world);
1673
+
1674
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [30, 0.5, 30] });
1675
+ const char = spawnCharacter(world, [0, 0, 0]);
1676
+ // A sensor sphere sitting exactly on the character spawn (mirrors the
1677
+ // collectathon guardian attack-sensor whose physics body stayed at world
1678
+ // origin). A sensor reports overlaps but must NEVER act as a solid wall
1679
+ // for the KCC -- before the fix this froze the character in place.
1680
+ world
1681
+ .spawn(
1682
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
1683
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
1684
+ {
1685
+ component: Collider as never,
1686
+ data: { shape: ColliderShapeValue.sphere, radius: 1.5, isSensor: 1 },
1687
+ },
1688
+ )
1689
+ .unwrap();
1690
+
1691
+ world.update(1 / 60).unwrap();
1692
+ world.update(1 / 60).unwrap();
1693
+
1694
+ const start = tfPos(world, char).x;
1695
+ for (let i = 0; i < 5; i++) {
1696
+ pw.moveAndSlide(char, Float32Array.of(0.1, -0.01, 0) as never);
1697
+ world.update(1 / 60).unwrap();
1698
+ world.update(1 / 60).unwrap();
1699
+ }
1700
+ // The character must slide through the sensor, not be walled by it.
1701
+ expect(tfPos(world, char).x - start).toBeGreaterThan(0.3);
1702
+ });
1703
+ });
1704
+
1705
+ // Slope geometry must be rotated, but the ECS bridge (ensureBody) only
1706
+ // applies translation. Build tilted ramps directly on the Rapier world;
1707
+ // the character still goes through the ECS path so moveAndSlide resolves it.
1708
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamic module
1709
+ function spawnRawRamp(pw: any, RAPIER: any, pos: Vec3Tuple, slopeDeg: number): void {
1710
+ const rad = (slopeDeg * Math.PI) / 180;
1711
+ // Rotation about z tilts the top face of a wide thin box around the x-axis
1712
+ // of travel; quaternion from axis-angle about z.
1713
+ const half = rad / 2;
1714
+ const body = pw.raw.createRigidBody(
1715
+ RAPIER.RigidBodyDesc.fixed()
1716
+ .setTranslation(pos[0], pos[1], pos[2])
1717
+ .setRotation({ x: 0, y: 0, z: Math.sin(half), w: Math.cos(half) }),
1718
+ );
1719
+ pw.raw.createCollider(RAPIER.ColliderDesc.cuboid(8, 0.5, 8).setFriction(0.5), body);
1720
+ }
1721
+
1722
+ describe('moveAndSlide slope (AC-04/05)', () => {
1723
+ it('AC-04 gentle slope (< maxSlopeClimbDeg=45): y rises, not blocked', async () => {
1724
+ const RAPIER = await loadOrNull();
1725
+ if (!RAPIER) return;
1726
+ const world = prepareWorld();
1727
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1728
+ world.insertResource('PhysicsWorld', pw);
1729
+ registerPhysicsSystems(world);
1730
+
1731
+ // 30deg ramp centered at x=2; the ramp surface at x≈0.8 is near y=0.
1732
+ spawnRawRamp(pw, RAPIER, [2, -0.85, 0], 30);
1733
+ const char = spawnCharacter(world, [0.7, 0.05, 0]);
1734
+
1735
+ world.update(1 / 60).unwrap();
1736
+ world.update(1 / 60).unwrap();
1737
+ // gravity pulse to settle onto the ramp surface.
1738
+ pw.moveAndSlide(char, Float32Array.of(0, -0.15, 0) as never);
1739
+
1740
+ const before = tfPos(world, char).y;
1741
+ // Walk into the ramp repeatedly; a climbable slope lets the character ascend.
1742
+ for (let i = 0; i < 30; i++) {
1743
+ pw.moveAndSlide(char, Float32Array.of(0.12, -0.01, 0) as never);
1744
+ }
1745
+ const after = tfPos(world, char).y;
1746
+ expect(after).toBeGreaterThan(before);
1747
+ expect(tfPos(world, char).x).toBeGreaterThan(0.5);
1748
+ });
1749
+
1750
+ it('AC-05 steep slope (> maxSlopeClimbDeg=45): horizontal travel is blocked', async () => {
1751
+ const RAPIER = await loadOrNull();
1752
+ if (!RAPIER) return;
1753
+ const world = prepareWorld();
1754
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1755
+ world.insertResource('PhysicsWorld', pw);
1756
+ registerPhysicsSystems(world);
1757
+
1758
+ // 60deg ramp centered at x=1.5 — steeper than default maxSlopeClimbDeg=45.
1759
+ spawnRawRamp(pw, RAPIER, [1.5, -0.85, 0], 60);
1760
+ const char = spawnCharacter(world, [0.7, 0.05, 0]);
1761
+
1762
+ world.update(1 / 60).unwrap();
1763
+ world.update(1 / 60).unwrap();
1764
+ pw.moveAndSlide(char, Float32Array.of(0, -0.15, 0) as never);
1765
+
1766
+ let totalX = 0;
1767
+ for (let i = 0; i < 30; i++) {
1768
+ const a = pw.moveAndSlide(char, Float32Array.of(0.1, -0.02, 0) as never);
1769
+ totalX += a[0] ?? 0;
1770
+ }
1771
+ // The character cannot climb a too-steep slope: forward progress stalls
1772
+ // well short of the unobstructed 30 * 0.1 = 3.0.
1773
+ expect(totalX).toBeLessThan(1.5);
1774
+ });
1775
+ });
1776
+
1777
+ describe('moveAndSlide autostep (AC-06)', () => {
1778
+ it('AC-06a low step (0.2 < autoStepMaxHeight=0.3): character climbs it', async () => {
1779
+ const RAPIER = await loadOrNull();
1780
+ if (!RAPIER) return;
1781
+ const world = prepareWorld();
1782
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1783
+ world.insertResource('PhysicsWorld', pw);
1784
+ registerPhysicsSystems(world);
1785
+
1786
+ // Ground box top at y=-0.35; the capsule (radius 0.3 + halfHeight 0.5)
1787
+ // has a half-total of 0.8, so it rests with its center at y=0.45.
1788
+ // Spawning at the resting height (not buried at y=0) is what lets KCC
1789
+ // autostep — a capsule penetrating the floor has a degenerate contact.
1790
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 4] });
1791
+ // A 0.2m-tall step ledge at x=1..3 (top at y=-0.25).
1792
+ spawnStaticBox(world, { pos: [2, -0.45, 0], halfExtents: [1, 0.2, 4] });
1793
+ const char = spawnCharacter(world, [0, 0.45, 0]);
1794
+
1795
+ world.update(1 / 60).unwrap();
1796
+ world.update(1 / 60).unwrap();
1797
+ pw.moveAndSlide(char, Float32Array.of(0, -0.15, 0) as never);
1798
+
1799
+ const before = tfPos(world, char).y;
1800
+ // Walk toward and across the 0.2m ledge (x in [1,3]). Track the peak y
1801
+ // reached while on the ledge — the character steps up onto the ledge
1802
+ // top, traverses it, then steps back down off the far edge, so asserting
1803
+ // y at a fixed final iteration would read the post-ledge ground. The
1804
+ // peak captures the autostep climb regardless of where traversal ends.
1805
+ let peakY = before;
1806
+ let reachedLedge = false;
1807
+ for (let i = 0; i < 40; i++) {
1808
+ pw.moveAndSlide(char, Float32Array.of(0.1, -0.02, 0) as never);
1809
+ const p = tfPos(world, char);
1810
+ if (p.y > peakY) peakY = p.y;
1811
+ if (p.x > 1.5 && p.x < 2.5) reachedLedge = true;
1812
+ }
1813
+ // Auto-step lifted the character onto the 0.2m ledge top mid-traversal.
1814
+ expect(peakY).toBeGreaterThan(before + 0.05);
1815
+ expect(reachedLedge).toBe(true);
1816
+ });
1817
+
1818
+ it('AC-06b high step (0.5 > autoStepMaxHeight=0.3): character is blocked', async () => {
1819
+ const RAPIER = await loadOrNull();
1820
+ if (!RAPIER) return;
1821
+ const world = prepareWorld();
1822
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1823
+ world.insertResource('PhysicsWorld', pw);
1824
+ registerPhysicsSystems(world);
1825
+
1826
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [4, 0.5, 4] });
1827
+ // A 0.5m-tall step ledge — too tall to auto-step.
1828
+ spawnStaticBox(world, { pos: [2, -0.05, 0], halfExtents: [2, 0.5, 4] });
1829
+ const char = spawnCharacter(world, [0, 0, 0]);
1830
+
1831
+ world.update(1 / 60).unwrap();
1832
+ world.update(1 / 60).unwrap();
1833
+
1834
+ for (let i = 0; i < 25; i++) {
1835
+ pw.moveAndSlide(char, Float32Array.of(0.1, -0.05, 0) as never);
1836
+ }
1837
+ // Could not step up: stays low, blocked before the ledge top.
1838
+ expect(tfPos(world, char).y).toBeLessThan(0.2);
1839
+ });
1840
+
1841
+ it('AC-06c autoStepMaxHeight=0 disables auto-step (low step now blocks)', async () => {
1842
+ const RAPIER = await loadOrNull();
1843
+ if (!RAPIER) return;
1844
+ const world = prepareWorld();
1845
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1846
+ world.insertResource('PhysicsWorld', pw);
1847
+ registerPhysicsSystems(world);
1848
+
1849
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [4, 0.5, 4] });
1850
+ spawnStaticBox(world, { pos: [2, -0.45, 0], halfExtents: [2, 0.2, 4] });
1851
+ const char = spawnCharacter(world, [0, 0, 0], { autoStepMaxHeight: 0 });
1852
+
1853
+ world.update(1 / 60).unwrap();
1854
+ world.update(1 / 60).unwrap();
1855
+
1856
+ for (let i = 0; i < 25; i++) {
1857
+ pw.moveAndSlide(char, Float32Array.of(0.1, -0.05, 0) as never);
1858
+ }
1859
+ // Auto-step off: the 0.2m ledge is no longer climbed.
1860
+ expect(tfPos(world, char).y).toBeLessThan(0.1);
1861
+ });
1862
+ });
1863
+
1864
+ describe('moveAndSlide snap-to-ground (AC-07)', () => {
1865
+ it('AC-07a snap-to-ground keeps the character on a descending slope (pure horizontal move pulls y down)', async () => {
1866
+ const RAPIER = await loadOrNull();
1867
+ if (!RAPIER) return;
1868
+ const world = prepareWorld();
1869
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1870
+ world.insertResource('PhysicsWorld', pw);
1871
+ registerPhysicsSystems(world);
1872
+
1873
+ // Downhill ramp toward +x (top near origin, descending).
1874
+ spawnRawRamp(pw, RAPIER, [4, -1.0, 0], -20);
1875
+ const char = spawnCharacter(world, [0, 0.1, 0]);
1876
+
1877
+ world.update(1 / 60).unwrap();
1878
+ world.update(1 / 60).unwrap();
1879
+ // Establish ground contact first.
1880
+ pw.moveAndSlide(char, Float32Array.of(0, -0.1, 0) as never);
1881
+ const startY = tfPos(world, char).y;
1882
+
1883
+ // Walk forward with NO vertical input. Without snap-to-ground the
1884
+ // character would travel level and lift off the descending surface;
1885
+ // snap pulls it back down onto the ramp, so y decreases monotonically
1886
+ // as x advances. (Rapier's computedGrounded() reads false while sliding
1887
+ // a slope in this build — the snap effect shows in the trajectory, not
1888
+ // the flag; the grounded flag itself is asserted on flat ground in
1889
+ // AC-01 and in the void in AC-07b.)
1890
+ for (let i = 0; i < 20; i++) {
1891
+ pw.moveAndSlide(char, Float32Array.of(0.08, 0, 0) as never);
1892
+ }
1893
+ const endPos = tfPos(world, char);
1894
+ // The character followed the ramp down (snap kept it on the surface)
1895
+ // rather than flying off level.
1896
+ expect(endPos.x).toBeGreaterThan(0.5);
1897
+ expect(endPos.y).toBeLessThan(startY - 0.1);
1898
+ });
1899
+
1900
+ it('AC-07b grounded flips false when the character walks off a ledge into open air', async () => {
1901
+ const RAPIER = await loadOrNull();
1902
+ if (!RAPIER) return;
1903
+ const world = prepareWorld();
1904
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1905
+ world.insertResource('PhysicsWorld', pw);
1906
+ registerPhysicsSystems(world);
1907
+
1908
+ // A short platform: top at y=0 (center -0.5, halfExtent 0.5), spanning
1909
+ // x in [-2, 1]. Nothing exists beyond x=1, so walking off the edge with
1910
+ // a downward bias drops the character into open air. The grounded flag
1911
+ // must follow: true on the platform, false once airborne. This is the
1912
+ // falsifiable counterpart to AC-07a (descending slope stays grounded) —
1913
+ // snap-to-ground keeps contact across surfaces, but a true void must
1914
+ // still report not-grounded. The capsule rests at y=0.8 (top 0 + 0.8).
1915
+ spawnStaticBox(world, { pos: [-0.5, -0.5, 0], halfExtents: [1.5, 0.5, 4] });
1916
+ const char = spawnCharacter(world, [-1, 0.8, 0]);
1917
+
1918
+ world.update(1 / 60).unwrap();
1919
+ world.update(1 / 60).unwrap();
1920
+ // Settle on the platform first.
1921
+ pw.moveAndSlide(char, Float32Array.of(0, -0.1, 0) as never);
1922
+ expect(ccGrounded(world, char)).toBe(true);
1923
+
1924
+ // Walk toward +x and off the edge with a small gravity bias.
1925
+ let wentAirborne = false;
1926
+ for (let i = 0; i < 25; i++) {
1927
+ pw.moveAndSlide(char, Float32Array.of(0.15, -0.05, 0) as never);
1928
+ if (!ccGrounded(world, char)) wentAirborne = true;
1929
+ }
1930
+ // The character left the platform and fell, so grounded flipped to false.
1931
+ expect(wentAirborne).toBe(true);
1932
+ expect(ccGrounded(world, char)).toBe(false);
1933
+ expect(tfPos(world, char).x).toBeGreaterThan(1);
1934
+ expect(tfPos(world, char).y).toBeLessThan(0.8);
1935
+ });
1936
+
1937
+ it('AC-07c pure horizontal move on flat ground stays grounded', async () => {
1938
+ const RAPIER = await loadOrNull();
1939
+ if (!RAPIER) return;
1940
+ const world = prepareWorld();
1941
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1942
+ world.insertResource('PhysicsWorld', pw);
1943
+ registerPhysicsSystems(world);
1944
+
1945
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
1946
+ const char = spawnCharacter(world, [0, 0, 0]);
1947
+
1948
+ world.update(1 / 60).unwrap();
1949
+ world.update(1 / 60).unwrap();
1950
+ pw.moveAndSlide(char, Float32Array.of(0, -0.1, 0) as never);
1951
+
1952
+ const actual = pw.moveAndSlide(char, Float32Array.of(0.5, 0, 0) as never);
1953
+ // Flat ground: horizontal travel preserved, still grounded.
1954
+ expect(actual[0]).toBeCloseTo(0.5, 1);
1955
+ expect(ccGrounded(world, char)).toBe(true);
1956
+ });
1957
+ });
1958
+
1959
+ describe('moveAndSlide error codes (AC-08/09)', () => {
1960
+ it('AC-08a dynamic body: throws controller-requires-kinematic with detail', async () => {
1961
+ const RAPIER = await loadOrNull();
1962
+ if (!RAPIER) return;
1963
+ const world = prepareWorld();
1964
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1965
+ world.insertResource('PhysicsWorld', pw);
1966
+ registerPhysicsSystems(world);
1967
+
1968
+ const char = spawnCharacter(world, [0, 0, 0], {}, RigidBodyTypeValue.dynamic);
1969
+ world.update(1 / 60).unwrap();
1970
+ world.update(1 / 60).unwrap();
1971
+
1972
+ let caught: unknown;
1973
+ try {
1974
+ pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
1975
+ } catch (e) {
1976
+ caught = e;
1977
+ }
1978
+ expect(caught).toBeInstanceOf(PhysicsError);
1979
+ const err = caught as PhysicsError;
1980
+ expect(err.code).toBe('controller-requires-kinematic');
1981
+ expect(err.detail?.code).toBe('controller-requires-kinematic');
1982
+ if (err.detail?.code === 'controller-requires-kinematic') {
1983
+ expect(err.detail.entity).toBe(char);
1984
+ expect(err.detail.bodyType).toBe('dynamic');
1985
+ }
1986
+ });
1987
+
1988
+ it('AC-08b static body: throws controller-requires-kinematic', async () => {
1989
+ const RAPIER = await loadOrNull();
1990
+ if (!RAPIER) return;
1991
+ const world = prepareWorld();
1992
+ const pw = createRapier3DPhysicsWorld(RAPIER);
1993
+ world.insertResource('PhysicsWorld', pw);
1994
+ registerPhysicsSystems(world);
1995
+
1996
+ const char = spawnCharacter(world, [0, 0, 0], {}, RigidBodyTypeValue.static);
1997
+ world.update(1 / 60).unwrap();
1998
+ world.update(1 / 60).unwrap();
1999
+
2000
+ let caught: unknown;
2001
+ try {
2002
+ pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
2003
+ } catch (e) {
2004
+ caught = e;
2005
+ }
2006
+ expect((caught as PhysicsError).code).toBe('controller-requires-kinematic');
2007
+ expect((caught as PhysicsError).detail?.code).toBe('controller-requires-kinematic');
2008
+ });
2009
+
2010
+ it('AC-09a unregistered entity: throws body-not-found with detail.entity', async () => {
2011
+ const RAPIER = await loadOrNull();
2012
+ if (!RAPIER) return;
2013
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2014
+
2015
+ let caught: unknown;
2016
+ try {
2017
+ pw.moveAndSlide(12345, Float32Array.of(1, 0, 0) as never);
2018
+ } catch (e) {
2019
+ caught = e;
2020
+ }
2021
+ expect(caught).toBeInstanceOf(PhysicsError);
2022
+ const err = caught as PhysicsError;
2023
+ expect(err.code).toBe('body-not-found');
2024
+ if (err.detail?.code === 'body-not-found') {
2025
+ expect(err.detail.entity).toBe(12345);
2026
+ }
2027
+ });
2028
+
2029
+ it('AC-09b kinematic body with no collider: throws collider-not-found (D-2)', async () => {
2030
+ const RAPIER = await loadOrNull();
2031
+ if (!RAPIER) return;
2032
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2033
+
2034
+ // Register a kinematic body directly with NO collider attached.
2035
+ const body = pw.raw.createRigidBody(
2036
+ RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 0, 0),
2037
+ );
2038
+ body.userData = 7;
2039
+ pw.registerBody(7, body.handle);
2040
+
2041
+ let caught: unknown;
2042
+ try {
2043
+ pw.moveAndSlide(7, Float32Array.of(1, 0, 0) as never);
2044
+ } catch (e) {
2045
+ caught = e;
2046
+ }
2047
+ expect(caught).toBeInstanceOf(PhysicsError);
2048
+ const err = caught as PhysicsError;
2049
+ expect(err.code).toBe('collider-not-found');
2050
+ if (err.detail?.code === 'collider-not-found') {
2051
+ expect(err.detail.entity).toBe(7);
2052
+ }
2053
+ });
2054
+ });
2055
+
2056
+ // Read the Rapier body translation for an entity by scanning userData;
2057
+ // entityMap is private, so this is the test-side reverse lookup.
2058
+ function rapierBodyPos(
2059
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamic module
2060
+ pw: any,
2061
+ entity: number,
2062
+ ): { x: number; y: number; z: number } | undefined {
2063
+ let found: { x: number; y: number; z: number } | undefined;
2064
+ pw.raw.bodies.forEach(
2065
+ (body: { userData: number; translation(): { x: number; y: number; z: number } }) => {
2066
+ if (body.userData === entity) {
2067
+ const t = body.translation();
2068
+ found = { x: t.x, y: t.y, z: t.z };
2069
+ }
2070
+ },
2071
+ );
2072
+ return found;
2073
+ }
2074
+
2075
+ describe('moveAndSlide syncBackend split (AC-10)', () => {
2076
+ it('AC-10a kinematic platform without CharacterController is mirrored', async () => {
2077
+ const RAPIER = await loadOrNull();
2078
+ if (!RAPIER) return;
2079
+ const world = prepareWorld();
2080
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2081
+ world.insertResource('PhysicsWorld', pw);
2082
+ // The kinematic mirror drives the collider from Transform.world (so a
2083
+ // ChildOf collider follows its parent), which propagateTransforms
2084
+ // populates; register it (createApp always does, and physicsSyncBackend
2085
+ // declares `after: propagateTransforms`). For this root platform
2086
+ // world == compose(local), so x ends at 5 either way once propagate runs.
2087
+ registerPropagateTransforms(world);
2088
+ registerPhysicsSystems(world);
2089
+
2090
+ // Platform: kinematic body + collider, NO CharacterController.
2091
+ const platform = world
2092
+ .spawn(
2093
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2094
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2095
+ {
2096
+ component: Collider as never,
2097
+ data: { shape: 0, halfExtents: [1, 0.5, 1] },
2098
+ },
2099
+ )
2100
+ .unwrap() as unknown as number;
2101
+
2102
+ // Move the platform via Transform; syncBackend should mirror it.
2103
+ world.set(platform as never, Transform as never, { pos: [5, 2, 0] });
2104
+ for (let i = 0; i < 30; i++) {
2105
+ world.update(1 / 60).unwrap();
2106
+ world.update(1 / 60).unwrap();
2107
+ }
2108
+
2109
+ const pos = rapierBodyPos(pw, platform);
2110
+ expect(pos).toBeDefined();
2111
+ expect(pos?.x).toBeCloseTo(5, 0);
2112
+ expect(pos?.y).toBeCloseTo(2, 0);
2113
+ });
2114
+
2115
+ it('AC-10b kinematic character with CharacterController is NOT mirrored', async () => {
2116
+ const RAPIER = await loadOrNull();
2117
+ if (!RAPIER) return;
2118
+ const world = prepareWorld();
2119
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2120
+ world.insertResource('PhysicsWorld', pw);
2121
+ registerPhysicsSystems(world);
2122
+
2123
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
2124
+ const char = spawnCharacter(world, [0, 0, 0]);
2125
+
2126
+ // First update creates the body at origin.
2127
+ world.update(1 / 60).unwrap();
2128
+ world.update(1 / 60).unwrap();
2129
+
2130
+ // moveAndSlide drives the character to ~x=1; syncBackend must not then
2131
+ // overwrite the Rapier body back to the (stale) Transform from a prior
2132
+ // frame. Set the ECS Transform to a bogus far value to prove the split:
2133
+ // a mirroring syncBackend would push the kinematic body to x=99.
2134
+ pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
2135
+ const afterMove = tfPos(world, char).x;
2136
+
2137
+ world.set(char as never, Transform as never, { pos: [99, 99, 99] });
2138
+ // Run a tick: if the character row were mirrored, the body would target 99.
2139
+ world.update(1 / 60).unwrap();
2140
+ world.update(1 / 60).unwrap();
2141
+ for (let i = 0; i < 10; i++) pw.step(1 / 60);
2142
+
2143
+ const pos = rapierBodyPos(pw, char);
2144
+ expect(pos).toBeDefined();
2145
+ // Character body must NOT have been mirrored to the bogus 99.
2146
+ expect(pos?.x).toBeLessThan(5);
2147
+ expect(afterMove).toBeCloseTo(1, 1);
2148
+ });
2149
+ });
2150
+
2151
+ describe('moveAndSlide despawn cleanup (AC-11)', () => {
2152
+ it('AC-11a despawn clears KCC and caches on the next tick', async () => {
2153
+ const RAPIER = await loadOrNull();
2154
+ if (!RAPIER) return;
2155
+ const world = prepareWorld();
2156
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2157
+ world.insertResource('PhysicsWorld', pw);
2158
+ registerPhysicsSystems(world);
2159
+
2160
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
2161
+ const char = spawnCharacter(world, [0, 0, 0]);
2162
+
2163
+ world.update(1 / 60).unwrap();
2164
+ world.update(1 / 60).unwrap();
2165
+
2166
+ // moveAndSlide lazily builds + caches a KCC for this character.
2167
+ pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
2168
+ expect(pw.raw.characterControllers.size).toBe(1);
2169
+ expect(pw.kccCache.size).toBe(1);
2170
+
2171
+ // Physics owns cleanup through the next query membership diff; structural
2172
+ // despawn does not execute a user callback inside the commit.
2173
+ world.despawn(char as never);
2174
+ world.update(1 / 60).unwrap();
2175
+ expect(pw.raw.characterControllers.size).toBe(0);
2176
+ expect(pw.kccCache.size).toBe(0);
2177
+ });
2178
+
2179
+ it('AC-11b despawn a character that never moved: removeEntity does not throw', async () => {
2180
+ const RAPIER = await loadOrNull();
2181
+ if (!RAPIER) return;
2182
+ const world = prepareWorld();
2183
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2184
+ world.insertResource('PhysicsWorld', pw);
2185
+ registerPhysicsSystems(world);
2186
+
2187
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
2188
+ const char = spawnCharacter(world, [0, 0, 0]);
2189
+
2190
+ world.update(1 / 60).unwrap();
2191
+ world.update(1 / 60).unwrap();
2192
+
2193
+ // No moveAndSlide -> no cached KCC. Despawn must still clean the body.
2194
+ const before = pw.getBodyCount();
2195
+ expect(before).toBeGreaterThan(0);
2196
+ expect(() => world.despawn(char as never)).not.toThrow();
2197
+ world.update(1 / 60).unwrap();
2198
+ expect(pw.kccCache.size).toBe(0);
2199
+ });
2200
+ });
2201
+
2202
+ describe('moveAndSlide self-exclude (D-1)', () => {
2203
+ it('character does not collide with its own collider', async () => {
2204
+ const RAPIER = await loadOrNull();
2205
+ if (!RAPIER) return;
2206
+
2207
+ // The self-exclude predicate omits the character's own collider, so on
2208
+ // flat ground a full horizontal request is delivered intact (no
2209
+ // self-collision eating the movement).
2210
+ const world = prepareWorld();
2211
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2212
+ world.insertResource('PhysicsWorld', pw);
2213
+ registerPhysicsSystems(world);
2214
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
2215
+ const char = spawnCharacter(world, [0, 0, 0]);
2216
+ world.update(1 / 60).unwrap();
2217
+ world.update(1 / 60).unwrap();
2218
+ const actual = pw.moveAndSlide(char, Float32Array.of(1, 0, 0) as never);
2219
+
2220
+ expect(actual[0]).toBeCloseTo(1, 1);
2221
+ });
2222
+ });
2223
+
2224
+ describe('hasBody readiness query', () => {
2225
+ it('returns false before the body is built, true after', async () => {
2226
+ const RAPIER = await loadOrNull();
2227
+ if (!RAPIER) return;
2228
+ const world = prepareWorld();
2229
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2230
+ world.insertResource('PhysicsWorld', pw);
2231
+ registerPhysicsSystems(world);
2232
+
2233
+ spawnStaticBox(world, { pos: [0, -0.85, 0], halfExtents: [10, 0.5, 10] });
2234
+ const char = spawnCharacter(world, [0, 0, 0]);
2235
+
2236
+ // Before the first physicsSyncBackend tick the body has not been built.
2237
+ expect(pw.hasBody(char)).toBe(false);
2238
+
2239
+ world.update(1 / 60).unwrap();
2240
+ world.update(1 / 60).unwrap();
2241
+
2242
+ // After the tick, ensureBody has run and the body exists.
2243
+ expect(pw.hasBody(char)).toBe(true);
2244
+ });
2245
+ });
2246
+ });
2247
+ }
2248
+
2249
+ {
2250
+ // ─── colliding-entities.test.ts (feat-20260626 M3 engine fix) ───
2251
+ //
2252
+ // The CollidingEntities component is documented as the contact/sensor set-query
2253
+ // path but was never populated: the event queue was constructed + drained on
2254
+ // overflow only, and colliders carried no activeEvents/activeCollisionTypes.
2255
+ // These tests prove the PhysicsCollisionSync system now writes the overlap set
2256
+ // -- specifically the kinematic-sensor vs kinematic-body case the collectathon
2257
+ // Core pickup needs (DEFAULT active-collision-types omits KINEMATIC_KINEMATIC).
2258
+
2259
+ describe('colliding-entities.test.ts', () => {
2260
+ async function loadOrNull() {
2261
+ const RAPIER = await loadRapier3D();
2262
+ if ('code' in RAPIER) {
2263
+ expect(RAPIER.code).toBe('wasm-load-failed');
2264
+ return null;
2265
+ }
2266
+ return RAPIER;
2267
+ }
2268
+
2269
+ it('a kinematic sensor overlapping a kinematic body populates CollidingEntities both ways', async () => {
2270
+ const RAPIER = await loadOrNull();
2271
+ if (!RAPIER) return;
2272
+ const world = prepareWorld();
2273
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2274
+ world.insertResource('PhysicsWorld', pw);
2275
+ registerPhysicsSystems(world);
2276
+
2277
+ // A "player" kinematic body + CollidingEntities, sitting at the origin.
2278
+ const player = world
2279
+ .spawn(
2280
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2281
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2282
+ {
2283
+ component: Collider as never,
2284
+ data: { shape: ColliderShapeValue.capsule, radius: 0.3, halfHeight: 0.5 },
2285
+ },
2286
+ { component: CollidingEntities as never, data: { entities: [] } },
2287
+ )
2288
+ .unwrap();
2289
+
2290
+ // A "Core" kinematic SENSOR overlapping the player.
2291
+ const core = world
2292
+ .spawn(
2293
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2294
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2295
+ {
2296
+ component: Collider as never,
2297
+ data: { shape: ColliderShapeValue.sphere, radius: 0.35, isSensor: true },
2298
+ },
2299
+ { component: CollidingEntities as never, data: { entities: [] } },
2300
+ )
2301
+ .unwrap();
2302
+
2303
+ world.update(1 / 60).unwrap();
2304
+ // A few ticks: tick 1 builds bodies (ensureBody), the next steps + drains.
2305
+ for (let i = 0; i < 4; i++) world.update(1 / 60).unwrap();
2306
+
2307
+ const playerSet = world.get(player, CollidingEntities as never);
2308
+ const coreSet = world.get(core, CollidingEntities as never);
2309
+ expect(playerSet.ok).toBe(true);
2310
+ expect(coreSet.ok).toBe(true);
2311
+ if (!playerSet.ok || !coreSet.ok) return;
2312
+ expect(Array.from(playerSet.value.entities as Uint32Array)).toContain(core as number);
2313
+ expect(Array.from(coreSet.value.entities as Uint32Array)).toContain(player as number);
2314
+ });
2315
+
2316
+ it('despawning a collided sensor clears it from the survivor CollidingEntities', async () => {
2317
+ const RAPIER = await loadOrNull();
2318
+ if (!RAPIER) return;
2319
+ const world = prepareWorld();
2320
+ const pw = createRapier3DPhysicsWorld(RAPIER);
2321
+ world.insertResource('PhysicsWorld', pw);
2322
+ registerPhysicsSystems(world);
2323
+
2324
+ const player = world
2325
+ .spawn(
2326
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2327
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2328
+ {
2329
+ component: Collider as never,
2330
+ data: { shape: ColliderShapeValue.capsule, radius: 0.3, halfHeight: 0.5 },
2331
+ },
2332
+ { component: CollidingEntities as never, data: { entities: [] } },
2333
+ )
2334
+ .unwrap();
2335
+ const core = world
2336
+ .spawn(
2337
+ { component: Transform as never, data: { pos: [0, 0, 0] } },
2338
+ { component: RigidBody as never, data: { type: RigidBodyTypeValue.kinematic } },
2339
+ {
2340
+ component: Collider as never,
2341
+ data: { shape: ColliderShapeValue.sphere, radius: 0.35, isSensor: true },
2342
+ },
2343
+ { component: CollidingEntities as never, data: { entities: [] } },
2344
+ )
2345
+ .unwrap();
2346
+
2347
+ world.update(1 / 60).unwrap();
2348
+ for (let i = 0; i < 4; i++) world.update(1 / 60).unwrap();
2349
+ const before = world.get(player, CollidingEntities as never);
2350
+ expect(before.ok && Array.from(before.value.entities as Uint32Array)).toContain(
2351
+ core as number,
2352
+ );
2353
+
2354
+ world.despawn(core);
2355
+ for (let i = 0; i < 2; i++) world.update(1 / 60).unwrap();
2356
+ const after = world.get(player, CollidingEntities as never);
2357
+ expect(after.ok).toBe(true);
2358
+ if (!after.ok) return;
2359
+ expect(Array.from(after.value.entities as Uint32Array)).not.toContain(core as number);
2360
+ });
2361
+ });
2362
+ }