-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathVehicleSessionController.seatLifecycle.test.ts
More file actions
211 lines (179 loc) · 7.39 KB
/
Copy pathVehicleSessionController.seatLifecycle.test.ts
File metadata and controls
211 lines (179 loc) · 7.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025-2026 Matthew Kissinger
import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
import { VehicleSessionController, type VehicleSeatBinder } from './VehicleSessionController';
import { VehicleManager } from './VehicleManager';
import { GroundVehicle } from './GroundVehicle';
import { HelicopterVehicleAdapter } from './HelicopterVehicleAdapter';
import type { PlayerVehicleAdapter, VehicleTransitionContext } from './PlayerVehicleAdapter';
import type { PlayerState } from '../../types';
import { Faction } from '../combat/types';
vi.mock('../../utils/Logger', () => ({
Logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
/**
* Behavior tests for the centralized seat lifecycle the session controller
* owns. The session controller is the single chokepoint every player
* enter/exit path funnels through (F, Escape, requestVehicleExit, heli /
* fixed-wing), so binding the IVehicle seat here is what guarantees no path
* leaves a seat ghost. These assert observable seat truth (`getPilotId()`,
* occupant counts) — not the controller's internal state shape.
*/
function makeInput() {
return {
setInHelicopter: vi.fn(),
setFlightVehicleMode: vi.fn(),
setInputContext: vi.fn(),
clearTransientInputState: vi.fn(),
} as any;
}
function makePlayerState(): PlayerState {
return {
position: new THREE.Vector3(),
velocity: new THREE.Vector3(),
speed: 10,
runSpeed: 20,
isRunning: false,
isGrounded: true,
isJumping: false,
jumpForce: 12,
gravity: -25,
isCrouching: false,
isInHelicopter: false,
helicopterId: null,
isInFixedWing: false,
fixedWingId: null,
};
}
function makeCtx(playerState: PlayerState, vehicleId: string): VehicleTransitionContext {
return {
playerState,
vehicleId,
position: playerState.position.clone(),
setPosition: (p) => playerState.position.copy(p),
input: makeInput(),
cameraController: {} as any,
};
}
/** Minimal adapter that records nothing beyond satisfying the interface. */
function makeAdapter(vehicleType: string): PlayerVehicleAdapter {
return {
vehicleType,
inputContext: 'vehicle' as any,
onEnter: vi.fn(),
onExit: vi.fn(),
update: vi.fn(),
resetControlState: vi.fn(),
};
}
function makeJeep(id: string): GroundVehicle {
const obj = new THREE.Group();
obj.position.set(0, 0, 0);
return new GroundVehicle(id, obj, Faction.US);
}
function makeHeli(id: string): HelicopterVehicleAdapter {
const heliPos = new THREE.Vector3(0, 5, 0);
const modelStub = {
getHelicopterPositionTo: (_id: string, target: THREE.Vector3) => {
target.copy(heliPos);
return true;
},
getHelicopterQuaternionTo: (_id: string, target: THREE.Quaternion) => {
target.identity();
return true;
},
getFlightData: () => null,
isHelicopterDestroyed: () => false,
getHealthPercent: () => 1,
} as any;
return new HelicopterVehicleAdapter(id, 'UH1_HUEY', Faction.US, modelStub);
}
describe('VehicleSessionController seat lifecycle', () => {
it('locks the pilot seat on enter and releases it on exit', () => {
const jeep = makeJeep('jeep_1');
const manager = new VehicleManager();
manager.register(jeep);
const session = new VehicleSessionController();
session.setSeatBinder(manager);
session.registerAdapter(makeAdapter('ground'));
const playerState = makePlayerState();
expect(jeep.getPilotId()).toBeNull();
expect(session.enterVehicle('ground', 'jeep_1', makeCtx(playerState, 'jeep_1'))).toBe(true);
expect(jeep.getPilotId()).toBe('player');
const result = session.exitVehicle(makeCtx(playerState, 'jeep_1'), { reason: 'escape' });
expect(result.exited).toBe(true);
expect(jeep.getPilotId()).toBeNull();
});
it('releases the helicopter pilot seat on exit (heli Escape path, no seat ghost)', () => {
const heli = makeHeli('heli_1');
const manager = new VehicleManager();
manager.register(heli);
const session = new VehicleSessionController();
session.setSeatBinder(manager);
session.registerAdapter(makeAdapter('helicopter'));
const playerState = makePlayerState();
session.enterVehicle('helicopter', 'heli_1', makeCtx(playerState, 'heli_1'));
expect(heli.getPilotId()).toBe('player');
session.exitVehicle(makeCtx(playerState, 'heli_1'), { reason: 'escape' });
expect(heli.getPilotId()).toBeNull();
});
it('does not double-lock when the seat was pre-locked (boarding factory path)', () => {
const jeep = makeJeep('jeep_1');
const manager = new VehicleManager();
manager.register(jeep);
const session = new VehicleSessionController();
session.setSeatBinder(manager);
session.registerAdapter(makeAdapter('ground'));
const playerState = makePlayerState();
// The boarding factory pre-locks the seat to compute the seat world pose,
// then calls session.enterVehicle. The session lock must be idempotent so
// the player never ends up in two seats.
jeep.enterVehicle('player', 'pilot');
session.enterVehicle('ground', 'jeep_1', makeCtx(playerState, 'jeep_1'));
const playerSeats = jeep.getSeats().filter((s) => s.occupantId === 'player');
expect(playerSeats).toHaveLength(1);
});
it('releases the seat exactly once (a redundant external release is a harmless no-op)', () => {
const jeep = makeJeep('jeep_1');
const manager = new VehicleManager();
manager.register(jeep);
const session = new VehicleSessionController();
session.setSeatBinder(manager);
session.registerAdapter(makeAdapter('ground'));
const playerState = makePlayerState();
session.enterVehicle('ground', 'jeep_1', makeCtx(playerState, 'jeep_1'));
// An NPC takes a passenger seat alongside the player.
jeep.enterVehicle('npc', 'passenger');
session.exitVehicle(makeCtx(playerState, 'jeep_1'), { reason: 'escape' });
// A second (stale) external release must not disturb the NPC's seat.
jeep.exitVehicle('player');
expect(jeep.getPilotId()).toBeNull();
expect(jeep.getSeats().find((s) => s.occupantId === 'npc')).toBeTruthy();
});
it('is a graceful no-op when no seat binder is wired (unit-test fallback)', () => {
const session = new VehicleSessionController();
session.registerAdapter(makeAdapter('ground'));
const playerState = makePlayerState();
// No binder: enter/exit must still succeed at the session level.
expect(session.enterVehicle('ground', 'jeep_1', makeCtx(playerState, 'jeep_1'))).toBe(true);
expect(session.isInVehicle()).toBe(true);
const result = session.exitVehicle(makeCtx(playerState, 'jeep_1'), { reason: 'escape' });
expect(result.exited).toBe(true);
expect(session.isInVehicle()).toBe(false);
});
it('accepts a plain VehicleSeatBinder shape (not just VehicleManager)', () => {
const jeep = makeJeep('jeep_1');
const binder: VehicleSeatBinder = {
getVehicle: (id) => (id === 'jeep_1' ? jeep : null),
};
const session = new VehicleSessionController();
session.setSeatBinder(binder);
session.registerAdapter(makeAdapter('ground'));
const playerState = makePlayerState();
session.enterVehicle('ground', 'jeep_1', makeCtx(playerState, 'jeep_1'));
expect(jeep.getPilotId()).toBe('player');
session.exitVehicle(makeCtx(playerState, 'jeep_1'), { reason: 'escape' });
expect(jeep.getPilotId()).toBeNull();
});
});