@series-inc/rundot-syncplay 6.0.0-rc.41 → 6.0.0-rc.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/core/src/physics/physics-profile-wasm-module.generated.mjs +1 -1
- package/core/src/physics/physics-wasm-module.generated.mjs +1 -1
- package/core/src/physics2d/physics2d-wasm-module.generated.mjs +1 -1
- package/dist/authority-room.d.ts +8 -3
- package/dist/authority-room.js +447 -11
- package/dist/dev-authority-room.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/input-authority.d.ts +2 -1
- package/dist/input-authority.js +44 -5
- package/dist/match-log.d.ts +20 -1
- package/dist/modules/physical-assembly.d.ts +154 -0
- package/dist/modules/physical-assembly.js +897 -0
- package/dist/networked-client.js +17 -1
- package/dist/node.d.ts +1 -0
- package/dist/node.js +1 -0
- package/dist/physics/3d/assembly-bridge.d.ts +19 -0
- package/dist/physics/3d/assembly-bridge.js +238 -0
- package/dist/physics/3d.d.ts +92 -0
- package/dist/physics/3d.js +346 -0
- package/dist/rooms-config-keys.d.ts +1 -1
- package/dist/rooms-config-keys.js +1 -0
- package/dist/seat-control.d.ts +148 -0
- package/dist/seat-control.js +543 -0
- package/dist/secret-authority.d.ts +19 -4
- package/dist/secret-authority.js +290 -36
- package/dist/secret-client.d.ts +1 -0
- package/dist/secret-client.js +72 -25
- package/dist/secret-config.d.ts +1 -1
- package/dist/secret-config.js +41 -36
- package/dist/secret-protocol.d.ts +3 -0
- package/dist/secret-protocol.js +14 -7
- package/dist/session-wire.d.ts +8 -0
- package/dist/session-wire.js +30 -5
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -31,6 +31,16 @@ The profiling entry keeps the named WASM file in the game build. Keep it out of
|
|
|
31
31
|
release imports to exclude that file. The npm package still includes both
|
|
32
32
|
artifacts. Profiling uses the same simulation source and digest rules.
|
|
33
33
|
|
|
34
|
+
## Dynamic seat control & player-keyed secrets
|
|
35
|
+
|
|
36
|
+
Syncplay supports deterministic dynamic seating across any valid simulation capacity X via `deterministic.seatPolicy`:
|
|
37
|
+
- Transitions S1 through S6 govern empty, bot, and human controller states.
|
|
38
|
+
- Policy fields include `recycleVacatedSeats`, `allowHumanClaimBot` (enabling human takeover of bot seats for S3), `onHumanLeave` (`'empty' | 'bot'`), and `initialBotSlots`. Retained owners can always reclaim their own seats without `allowHumanClaimBot`.
|
|
39
|
+
- Controller leases are authority-issued and room-scoped (`claimId`, `botLeaseId`, `controlEpoch`).
|
|
40
|
+
- Private secret holdings (cards, items, secret roles, and choices) are strictly bound to authenticated `playerId` and `claimId`.
|
|
41
|
+
- Unconsumed holdings are permanently revoked when a human releases a recyclable seat (`recycleVacatedSeats: true`).
|
|
42
|
+
- A successor player occupying a vacated seat never inherits prior items, secret values, or roles.
|
|
43
|
+
|
|
34
44
|
## Installation
|
|
35
45
|
|
|
36
46
|
Install the RUN SDK and Syncplay in the game project. Syncplay uses the
|
|
@@ -23,7 +23,7 @@ export const physicsProfileWasmMetadata = Object.freeze({
|
|
|
23
23
|
"sourceHash": "095061366ce3734601d06d48fa61d88a8538e1ba01e9c47663a7303ddb18fee7",
|
|
24
24
|
"wasmSha256": "4b2ac3ff7d0f7a15eb31f5742a90f16f458a906d5d41bdc5fbde03e5589f94bf",
|
|
25
25
|
"artifact": "profile",
|
|
26
|
-
"packageVersion": "6.0.0-rc.
|
|
26
|
+
"packageVersion": "6.0.0-rc.43",
|
|
27
27
|
"compilerFlags": [
|
|
28
28
|
"-std=c++20",
|
|
29
29
|
"-O3",
|
|
@@ -23,7 +23,7 @@ export const physicsWasmMetadata = Object.freeze({
|
|
|
23
23
|
"sourceHash": "38d5f2cde9e8d448f029fd2921a743e4a40e19dc84c03abf7bed829003e2469d",
|
|
24
24
|
"wasmSha256": "5f6a3d861cf3eb79a2f8060d6c4b5512673a943044a52a9d20b39f0f2fef985f",
|
|
25
25
|
"artifact": "production",
|
|
26
|
-
"packageVersion": "6.0.0-rc.
|
|
26
|
+
"packageVersion": "6.0.0-rc.43",
|
|
27
27
|
"compilerFlags": [
|
|
28
28
|
"-std=c++20",
|
|
29
29
|
"-O3",
|
|
@@ -50,7 +50,7 @@ export const physics2dWasmMetadata = Object.freeze({
|
|
|
50
50
|
"sourceHash": "faa090a03a6fddaf79c704fb1d76f810955ff35597449c8cec9541a75905a37e",
|
|
51
51
|
"wasmSha256": "49cb90541deed40f5fff1015bb578e13ec9a793d51931c1227e3e5a11bbfa3f7",
|
|
52
52
|
"artifact": "production",
|
|
53
|
-
"packageVersion": "6.0.0-rc.
|
|
53
|
+
"packageVersion": "6.0.0-rc.43",
|
|
54
54
|
"compilerFlags": [
|
|
55
55
|
"-std=c++20",
|
|
56
56
|
"-O3",
|
package/dist/authority-room.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { SyncplayMatchLog } from './match-log.js';
|
|
|
5
5
|
import { type SyncplaySecretAuthoritySnapshot } from './secret-authority.js';
|
|
6
6
|
import type { SyncplaySecretAuthorityCrypto } from './secret-crypto.js';
|
|
7
7
|
import type { SyncplaySecretSystemsConfig } from './secret-config.js';
|
|
8
|
+
import { type SeatPolicy, type BotLease, type SeatCheckpoint } from './seat-control.js';
|
|
9
|
+
export type { SeatPolicy, BotLease, SeatCheckpoint };
|
|
8
10
|
/**
|
|
9
11
|
* Transport-agnostic, game-agnostic deterministic authority room (§6 M2).
|
|
10
12
|
*
|
|
@@ -122,6 +124,7 @@ export interface DeterministicAuthorityRoomConfig {
|
|
|
122
124
|
readonly creatorSlot?: number;
|
|
123
125
|
readonly eventKeyId?: string;
|
|
124
126
|
readonly slotPolicies?: readonly AuthoritySlotPolicy[];
|
|
127
|
+
readonly seatPolicy?: SeatPolicy;
|
|
125
128
|
}
|
|
126
129
|
export interface DeterministicAuthorityRoomReconfiguration {
|
|
127
130
|
readonly sessionConfigBytes: Uint8Array;
|
|
@@ -273,6 +276,7 @@ export interface DeterministicAuthorityRoomRestoreState {
|
|
|
273
276
|
readonly sessionConfigBytes: Uint8Array;
|
|
274
277
|
readonly sessionConfigDigest: string;
|
|
275
278
|
readonly secretState?: SyncplaySecretAuthoritySnapshot;
|
|
279
|
+
readonly seatCheckpoint?: SeatCheckpoint;
|
|
276
280
|
/** Session generation at persist time; a restored room re-greets with it. Default 0. */
|
|
277
281
|
readonly generation?: number;
|
|
278
282
|
readonly membershipEpoch?: number;
|
|
@@ -352,7 +356,7 @@ export interface AuthorityRoomNetcodeStats {
|
|
|
352
356
|
export interface AuthorityRoomJoinResult {
|
|
353
357
|
readonly accepted: boolean;
|
|
354
358
|
readonly slot?: number;
|
|
355
|
-
readonly reason?: 'room-full' | 'already-joined' | 'history-unavailable' | 'incompatible-protocol';
|
|
359
|
+
readonly reason?: 'room-full' | 'already-joined' | 'history-unavailable' | 'incompatible-protocol' | 'seat-reassigned' | 'seat-unavailable';
|
|
356
360
|
/** True when this join re-attached a previously-seen playerId to its original slot. */
|
|
357
361
|
readonly rejoined?: boolean;
|
|
358
362
|
}
|
|
@@ -379,6 +383,7 @@ export interface AuthorityRoomSnapshot {
|
|
|
379
383
|
readonly latestSnapshot?: AuthorityRoomStoredSnapshotMeta;
|
|
380
384
|
/** Most recent snapshot rejection reason, if any snapshot attempt was rejected. */
|
|
381
385
|
readonly lastSnapshotRejection?: string;
|
|
386
|
+
readonly seatCheckpoint?: SeatCheckpoint;
|
|
382
387
|
/** Details of the pending snapshot transfer currently in-flight, if any. */
|
|
383
388
|
readonly membershipEpoch?: number;
|
|
384
389
|
readonly memberships?: readonly TickMembership[];
|
|
@@ -498,8 +503,8 @@ export interface DeterministicAuthorityRoom {
|
|
|
498
503
|
readonly keyId?: string;
|
|
499
504
|
}): EventQueueResult;
|
|
500
505
|
receiveVirtualInput(validated: BotInputSubmission): InputSubmissionResult;
|
|
501
|
-
activateBotSlot(slot: number, botId?: string): void;
|
|
502
|
-
deactivateBotSlot(slot: number): void;
|
|
506
|
+
activateBotSlot(slot: number, botId?: string): BotLease | void;
|
|
507
|
+
deactivateBotSlot(slot: number, leaseId?: string): void;
|
|
503
508
|
evacuateParticipant(slot: number, scheduledTick?: number): {
|
|
504
509
|
scheduledTick: number;
|
|
505
510
|
};
|
package/dist/authority-room.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createDeterministicLocalAuthoritySnapshotChunkEnvelope, createDetermini
|
|
|
6
6
|
import { DEFAULT_SNAPSHOT_CHUNK_SIZE, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, encodeDeterministicStateSnapshot, deterministicRoomDescriptorHash, } from './session-snapshot.js';
|
|
7
7
|
import { MAX_KINETIX_SESSION_CONFIG_BYTES, MAX_SYNCPLAY_COMMAND_PAYLOAD_BYTES, MAX_SYNCPLAY_EPHEMERAL_PAYLOAD_BYTES, decodeDeterministicSessionMessage, encodeDeterministicSessionMessage, } from './session-wire.js';
|
|
8
8
|
import { createSyncplaySecretAuthority, } from './secret-authority.js';
|
|
9
|
+
import { DEFAULT_SEAT_POLICY, validateSeatPolicy, createInitialSeatControlState, allocatable, findAvailableSeat, applyTransition, createSeatCheckpoint, restoreSeatControlState, } from './seat-control.js';
|
|
9
10
|
const DEFAULT_SESSION_MSG_TYPE = 'rdm';
|
|
10
11
|
const DEFAULT_SECRET_MSG_TYPE = 'rdm-secret';
|
|
11
12
|
/** One hour at 60 Hz — see DeterministicAuthorityRoomConfig.historyRetentionTicks. */
|
|
@@ -83,6 +84,50 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
83
84
|
const botSlotSet = new Set(config.slotPolicies?.filter((p) => p.control.kind === 'bot').map((p) => p.slot) ?? []);
|
|
84
85
|
const activeBotSlots = new Set();
|
|
85
86
|
const lastBotSequenceBySlot = new Map();
|
|
87
|
+
const seatPolicy = config.seatPolicy !== undefined
|
|
88
|
+
? validateSeatPolicy(config.seatPolicy, config.playerCount)
|
|
89
|
+
: undefined;
|
|
90
|
+
const activeBotLeaseBySlot = new Map();
|
|
91
|
+
const lastBotSequenceByLease = new Map();
|
|
92
|
+
const claimAttributions = [];
|
|
93
|
+
let pendingTransitions = [];
|
|
94
|
+
let seatControlState = config.restoreFrom?.seatCheckpoint !== undefined
|
|
95
|
+
? restoreSeatControlState(config.restoreFrom.seatCheckpoint)
|
|
96
|
+
: createInitialSeatControlState(config.playerCount, seatPolicy ?? DEFAULT_SEAT_POLICY, config.seatPolicy === undefined ? { legacyBotSlots: [...botSlotSet] } : undefined);
|
|
97
|
+
let hasStartedLobby = false;
|
|
98
|
+
if (config.restoreFrom?.seatCheckpoint !== undefined) {
|
|
99
|
+
for (let slot = 0; slot < config.playerCount; slot++) {
|
|
100
|
+
const seat = seatControlState.seats[slot];
|
|
101
|
+
if (seat.control.kind === "bot") {
|
|
102
|
+
activeBotSlots.add(slot);
|
|
103
|
+
const lease = {
|
|
104
|
+
roomId: config.authorityId ?? "room",
|
|
105
|
+
authorityEpoch: config.authorityEpoch ?? 0,
|
|
106
|
+
slot,
|
|
107
|
+
controlEpoch: seat.control.controlEpoch,
|
|
108
|
+
leaseId: seat.control.botLeaseId,
|
|
109
|
+
controllerId: seat.control.controllerId,
|
|
110
|
+
};
|
|
111
|
+
activeBotLeaseBySlot.set(slot, lease);
|
|
112
|
+
lastBotSequenceByLease.set(lease.leaseId, 0);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (seatPolicy !== undefined) {
|
|
117
|
+
for (const s of seatPolicy.initialBotSlots) {
|
|
118
|
+
activeBotSlots.add(s);
|
|
119
|
+
const initialLease = {
|
|
120
|
+
roomId: config.authorityId ?? "room",
|
|
121
|
+
authorityEpoch: config.authorityEpoch ?? 0,
|
|
122
|
+
slot: s,
|
|
123
|
+
controlEpoch: 0,
|
|
124
|
+
leaseId: `lease-${s}-0`,
|
|
125
|
+
controllerId: `bot-${s}`,
|
|
126
|
+
};
|
|
127
|
+
activeBotLeaseBySlot.set(s, initialLease);
|
|
128
|
+
lastBotSequenceByLease.set(initialLease.leaseId, 0);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
86
131
|
let nextAuthorityEventSequence = config.restoreFrom?.nextAuthorityEventSequence ?? 1;
|
|
87
132
|
const enqueuedEventsById = new Map();
|
|
88
133
|
if (config.restoreFrom?.enqueuedEvents) {
|
|
@@ -187,6 +232,9 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
187
232
|
}
|
|
188
233
|
}
|
|
189
234
|
function isSlotReserved(slot, playerId) {
|
|
235
|
+
if (config.seatPolicy !== undefined) {
|
|
236
|
+
return !allocatable(seatControlState, playerId, slot);
|
|
237
|
+
}
|
|
190
238
|
for (const [pId, s] of everSlotByPlayer.entries()) {
|
|
191
239
|
if (s === slot && pId !== playerId)
|
|
192
240
|
return true;
|
|
@@ -371,6 +419,12 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
371
419
|
// Omitted at generation 0 so a room that never reconfigures encodes
|
|
372
420
|
// byte-identically to what pre-generation peers expect.
|
|
373
421
|
...(generation === 0 ? {} : { generation }),
|
|
422
|
+
...(config.seatPolicy !== undefined && slot >= 0 && slot < config.playerCount && seatControlState.seats[slot].control.kind === "human"
|
|
423
|
+
? {
|
|
424
|
+
claimId: seatControlState.seats[slot].control.claimId,
|
|
425
|
+
controlEpoch: seatControlState.seats[slot].control.controlEpoch,
|
|
426
|
+
}
|
|
427
|
+
: {}),
|
|
374
428
|
}));
|
|
375
429
|
}
|
|
376
430
|
/**
|
|
@@ -478,6 +532,79 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
478
532
|
return { accepted: false, reason: 'incompatible-protocol' };
|
|
479
533
|
}
|
|
480
534
|
}
|
|
535
|
+
if (config.seatPolicy !== undefined) {
|
|
536
|
+
if (seatControlState.rejoinRecords.some((r) => r.playerId === playerId && r.replacedByHuman)) {
|
|
537
|
+
transport.sendTo(playerId, sessionMsgType, encodeDeterministicSessionMessage({
|
|
538
|
+
kind: "error",
|
|
539
|
+
code: "seat-reassigned",
|
|
540
|
+
message: "Seat has been reassigned to another participant",
|
|
541
|
+
}));
|
|
542
|
+
return { accepted: false, reason: "seat-reassigned" };
|
|
543
|
+
}
|
|
544
|
+
if (slotByPlayer.has(playerId)) {
|
|
545
|
+
if (options?.isPending !== undefined) {
|
|
546
|
+
setPlayerPending(playerId, options.isPending);
|
|
547
|
+
}
|
|
548
|
+
return { accepted: false, reason: "already-joined" };
|
|
549
|
+
}
|
|
550
|
+
const alloc = findAvailableSeat(seatControlState, playerId);
|
|
551
|
+
if (!alloc.ok) {
|
|
552
|
+
return { accepted: false, reason: alloc.reason === "seat-reassigned" ? "seat-reassigned" : "room-full" };
|
|
553
|
+
}
|
|
554
|
+
const plan = catchUpPlan();
|
|
555
|
+
if (plan === "unavailable") {
|
|
556
|
+
return { accepted: false, reason: "history-unavailable" };
|
|
557
|
+
}
|
|
558
|
+
const slot = alloc.slot;
|
|
559
|
+
const currentSeat = seatControlState.seats[slot];
|
|
560
|
+
const kind = currentSeat.control.kind === "bot" ? "S3_bot_to_human" : "S2_empty_to_human";
|
|
561
|
+
const claimId = alloc.resumedClaim && currentSeat.retainedClaim
|
|
562
|
+
? currentSeat.retainedClaim.claimId
|
|
563
|
+
: `claim-${playerId}-${now + 1}-${membershipEpoch}`;
|
|
564
|
+
const intent = {
|
|
565
|
+
eventId: `join-${playerId}-${now + 1}`,
|
|
566
|
+
kind,
|
|
567
|
+
slot,
|
|
568
|
+
effectiveTick: Math.max(0, now + 1),
|
|
569
|
+
expectedEpoch: currentSeat.control.controlEpoch,
|
|
570
|
+
playerId,
|
|
571
|
+
claimId,
|
|
572
|
+
};
|
|
573
|
+
const res = applyTransition(seatControlState, intent);
|
|
574
|
+
seatControlState = res.nextState;
|
|
575
|
+
if (res.effects.revokedBotLeaseId) {
|
|
576
|
+
activeBotLeaseBySlot.delete(slot);
|
|
577
|
+
activeBotSlots.delete(slot);
|
|
578
|
+
}
|
|
579
|
+
slotByPlayer.set(playerId, slot);
|
|
580
|
+
everSlotByPlayer.set(playerId, slot);
|
|
581
|
+
assignedSlots = Math.max(assignedSlots, slot + 1);
|
|
582
|
+
membershipEpoch += 1;
|
|
583
|
+
disconnectedPlayers.delete(playerId);
|
|
584
|
+
if (options?.isPending === true) {
|
|
585
|
+
pendingPlayers.add(playerId);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
pendingPlayers.delete(playerId);
|
|
589
|
+
}
|
|
590
|
+
claimAttributions.push({
|
|
591
|
+
slot,
|
|
592
|
+
playerId,
|
|
593
|
+
claimId,
|
|
594
|
+
startTick: Math.max(0, authority.confirmedThrough + 1),
|
|
595
|
+
});
|
|
596
|
+
sendSessionStart(playerId, slot);
|
|
597
|
+
if (alloc.resumedClaim) {
|
|
598
|
+
secretAuthority?.onPlayerReconnected(playerId, slot, claimId);
|
|
599
|
+
sendCatchUpTo(playerId, plan, "rejoin");
|
|
600
|
+
return { accepted: true, slot, rejoined: true };
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
secretAuthority?.onPlayerJoined(playerId, slot, claimId);
|
|
604
|
+
sendCatchUpTo(playerId, plan, "join");
|
|
605
|
+
return { accepted: true, slot };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
481
608
|
const existing = slotByPlayer.get(playerId);
|
|
482
609
|
if (existing !== undefined) {
|
|
483
610
|
if (options?.isPending !== undefined) {
|
|
@@ -783,6 +910,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
783
910
|
for (const seat of admission.seats) {
|
|
784
911
|
pendingPlayers.delete(seat.playerId);
|
|
785
912
|
if (slotByPlayer.get(seat.playerId) === seat.slot) {
|
|
913
|
+
secretAuthority?.onPlayerLeft(seat.playerId, seat.slot, { releaseMode: "retain" });
|
|
786
914
|
slotByPlayer.delete(seat.playerId);
|
|
787
915
|
}
|
|
788
916
|
// Note: do not delete from everSlotByPlayer so the fixed roster preserves seat ownership
|
|
@@ -1046,6 +1174,14 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1046
1174
|
});
|
|
1047
1175
|
}
|
|
1048
1176
|
function reconfigureSession(next) {
|
|
1177
|
+
const hasPendingTransitions = pendingTransitions.length > 0;
|
|
1178
|
+
const hasUnappliedStaged = [...stagedAdmissions.values()].some((a) => !appliedAdmissionIds.has(a.admissionId));
|
|
1179
|
+
const hasScheduledEvacuations = scheduledEvacuations.size > 0;
|
|
1180
|
+
const hasScheduledAdmissions = scheduledAdmissions.size > 0;
|
|
1181
|
+
const isSecretBusy = secretAuthority?.isBusy ? secretAuthority.isBusy() : false;
|
|
1182
|
+
if (hasPendingTransitions || hasUnappliedStaged || hasScheduledEvacuations || hasScheduledAdmissions || isSecretBusy) {
|
|
1183
|
+
throw new Error('seat-reset-busy');
|
|
1184
|
+
}
|
|
1049
1185
|
if (next.runtimeIdentity !== undefined && !equalRuntimeIdentity(next.runtimeIdentity, config.runtimeIdentity)) {
|
|
1050
1186
|
throw new Error('SYNCPLAY_RECONFIGURE_IDENTITY_MISMATCH');
|
|
1051
1187
|
}
|
|
@@ -1087,6 +1223,52 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1087
1223
|
if (!slotByPlayer.has(id))
|
|
1088
1224
|
disconnectedPlayers.delete(id);
|
|
1089
1225
|
}
|
|
1226
|
+
if (config.seatPolicy !== undefined) {
|
|
1227
|
+
lastBotSequenceByLease.clear();
|
|
1228
|
+
for (let slot = 0; slot < config.playerCount; slot++) {
|
|
1229
|
+
const seat = seatControlState.seats[slot];
|
|
1230
|
+
if (seat.control.kind === "human") {
|
|
1231
|
+
const nextEpoch = seat.control.controlEpoch + 1;
|
|
1232
|
+
const updatedSeat = {
|
|
1233
|
+
...seat,
|
|
1234
|
+
control: {
|
|
1235
|
+
...seat.control,
|
|
1236
|
+
controlEpoch: nextEpoch,
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
seatControlState = {
|
|
1240
|
+
...seatControlState,
|
|
1241
|
+
seats: seatControlState.seats.map((s, idx) => (idx === slot ? updatedSeat : s)),
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
else if (seat.control.kind === "bot") {
|
|
1245
|
+
const nextEpoch = seat.control.controlEpoch + 1;
|
|
1246
|
+
const botLeaseId = `lease-${slot}-${nextEpoch}`;
|
|
1247
|
+
const updatedSeat = {
|
|
1248
|
+
...seat,
|
|
1249
|
+
control: {
|
|
1250
|
+
...seat.control,
|
|
1251
|
+
controlEpoch: nextEpoch,
|
|
1252
|
+
botLeaseId,
|
|
1253
|
+
},
|
|
1254
|
+
};
|
|
1255
|
+
seatControlState = {
|
|
1256
|
+
...seatControlState,
|
|
1257
|
+
seats: seatControlState.seats.map((s, idx) => (idx === slot ? updatedSeat : s)),
|
|
1258
|
+
};
|
|
1259
|
+
const newLease = {
|
|
1260
|
+
roomId: config.authorityId ?? "room",
|
|
1261
|
+
authorityEpoch: config.authorityEpoch ?? 0,
|
|
1262
|
+
slot,
|
|
1263
|
+
controlEpoch: nextEpoch,
|
|
1264
|
+
leaseId: botLeaseId,
|
|
1265
|
+
controllerId: seat.control.controllerId,
|
|
1266
|
+
};
|
|
1267
|
+
activeBotLeaseBySlot.set(slot, newLease);
|
|
1268
|
+
lastBotSequenceByLease.set(botLeaseId, 0);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1090
1272
|
membershipEpoch += 1;
|
|
1091
1273
|
reportsDirty = false;
|
|
1092
1274
|
substitutedTicksBySlot.clear();
|
|
@@ -1391,6 +1573,69 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1391
1573
|
pendingPlayers.delete(playerId);
|
|
1392
1574
|
}
|
|
1393
1575
|
membershipEpoch += 1;
|
|
1576
|
+
if (config.seatPolicy !== undefined) {
|
|
1577
|
+
const slot = slotByPlayer.get(playerId);
|
|
1578
|
+
if (slot === undefined)
|
|
1579
|
+
return;
|
|
1580
|
+
const currentSeat = seatControlState.seats[slot];
|
|
1581
|
+
if (currentSeat.control.kind !== "human")
|
|
1582
|
+
return;
|
|
1583
|
+
const kind = seatPolicy.onHumanLeave === "bot" ? "S5_human_to_bot" : "S6_human_to_empty";
|
|
1584
|
+
const effectiveTick = Math.max(0, now + 1);
|
|
1585
|
+
const nextEpoch = currentSeat.control.controlEpoch + 1;
|
|
1586
|
+
const botLeaseId = `lease-${slot}-${nextEpoch}`;
|
|
1587
|
+
const intent = {
|
|
1588
|
+
eventId: `leave-${playerId}-${effectiveTick}`,
|
|
1589
|
+
kind,
|
|
1590
|
+
slot,
|
|
1591
|
+
effectiveTick,
|
|
1592
|
+
expectedEpoch: currentSeat.control.controlEpoch,
|
|
1593
|
+
playerId,
|
|
1594
|
+
controllerId: `bot-${slot}`,
|
|
1595
|
+
botLeaseId,
|
|
1596
|
+
reason: "final-leave",
|
|
1597
|
+
};
|
|
1598
|
+
const res = applyTransition(seatControlState, intent);
|
|
1599
|
+
seatControlState = res.nextState;
|
|
1600
|
+
if (res.effects.secretRelease) {
|
|
1601
|
+
secretAuthority?.onPlayerLeft(res.effects.secretRelease.playerId, res.effects.secretRelease.slot, {
|
|
1602
|
+
releaseMode: res.effects.secretRelease.releaseMode,
|
|
1603
|
+
reason: "leave",
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
for (let i = claimAttributions.length - 1; i >= 0; i--) {
|
|
1607
|
+
const attr = claimAttributions[i];
|
|
1608
|
+
if (attr.playerId === playerId && attr.endTick === undefined) {
|
|
1609
|
+
claimAttributions[i] = {
|
|
1610
|
+
...attr,
|
|
1611
|
+
endTick: effectiveTick,
|
|
1612
|
+
};
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
authority.neutralizeSlotFrom(slot, effectiveTick);
|
|
1617
|
+
slotByPlayer.delete(playerId);
|
|
1618
|
+
if (seatPolicy.recycleVacatedSeats) {
|
|
1619
|
+
everSlotByPlayer.delete(playerId);
|
|
1620
|
+
}
|
|
1621
|
+
if (seatPolicy.onHumanLeave === "bot") {
|
|
1622
|
+
activeBotSlots.add(slot);
|
|
1623
|
+
const newLease = {
|
|
1624
|
+
roomId: config.authorityId ?? "room",
|
|
1625
|
+
authorityEpoch: config.authorityEpoch ?? 0,
|
|
1626
|
+
slot,
|
|
1627
|
+
controlEpoch: nextEpoch,
|
|
1628
|
+
leaseId: botLeaseId,
|
|
1629
|
+
controllerId: `bot-${slot}`,
|
|
1630
|
+
};
|
|
1631
|
+
activeBotLeaseBySlot.set(slot, newLease);
|
|
1632
|
+
lastBotSequenceByLease.set(botLeaseId, 0);
|
|
1633
|
+
}
|
|
1634
|
+
if (pendingTransfer?.donorId === playerId) {
|
|
1635
|
+
failPendingTransfer("snapshot-donor-left");
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1394
1639
|
// Fixed roster for v1 (late-join/reconnect is M5). Dropping the mapping stops accepting the
|
|
1395
1640
|
// departed player's input; their slot stays reserved because `assignedSlots` never decreases,
|
|
1396
1641
|
// so the authority substitutes for it rather than handing the number to a new joiner.
|
|
@@ -1412,8 +1657,18 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1412
1657
|
// drop-in: the clock runs from the first join and later players catch up
|
|
1413
1658
|
// via the history replay in onPlayerJoin. `assignedSlots` is monotonic, so
|
|
1414
1659
|
// a mid-match leave never re-pauses a started room.
|
|
1415
|
-
if (config.waitForFullRoom === true &&
|
|
1416
|
-
|
|
1660
|
+
if (config.waitForFullRoom === true && !hasStartedLobby) {
|
|
1661
|
+
if (config.seatPolicy !== undefined) {
|
|
1662
|
+
const activeCount = seatControlState.seats.filter((s) => s.control.kind === "human" || s.control.kind === "bot").length;
|
|
1663
|
+
if (activeCount < config.playerCount)
|
|
1664
|
+
return;
|
|
1665
|
+
hasStartedLobby = true;
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
if (assignedSlots < config.playerCount)
|
|
1669
|
+
return;
|
|
1670
|
+
hasStartedLobby = true;
|
|
1671
|
+
}
|
|
1417
1672
|
}
|
|
1418
1673
|
now += 1;
|
|
1419
1674
|
for (const [schedTick, slots] of scheduledEvacuations.entries()) {
|
|
@@ -1427,8 +1682,89 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1427
1682
|
break;
|
|
1428
1683
|
}
|
|
1429
1684
|
}
|
|
1685
|
+
if (config.seatPolicy !== undefined) {
|
|
1686
|
+
const currentSeat = seatControlState.seats[slot];
|
|
1687
|
+
if (currentSeat && currentSeat.control.kind === "human") {
|
|
1688
|
+
const departingPlayerId = currentSeat.control.playerId;
|
|
1689
|
+
const kind = "S6_human_to_empty";
|
|
1690
|
+
const nextEpoch = currentSeat.control.controlEpoch + 1;
|
|
1691
|
+
const intent = {
|
|
1692
|
+
eventId: `evacuate-${departingPlayerId}-${now}`,
|
|
1693
|
+
kind,
|
|
1694
|
+
slot,
|
|
1695
|
+
effectiveTick: now,
|
|
1696
|
+
expectedEpoch: currentSeat.control.controlEpoch,
|
|
1697
|
+
playerId: departingPlayerId,
|
|
1698
|
+
reason: "evacuation",
|
|
1699
|
+
};
|
|
1700
|
+
const res = applyTransition(seatControlState, intent);
|
|
1701
|
+
seatControlState = res.nextState;
|
|
1702
|
+
if (res.effects.secretRelease) {
|
|
1703
|
+
secretAuthority?.onPlayerLeft(res.effects.secretRelease.playerId, res.effects.secretRelease.slot, {
|
|
1704
|
+
releaseMode: res.effects.secretRelease.releaseMode,
|
|
1705
|
+
reason: "evacuation",
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
for (let i = claimAttributions.length - 1; i >= 0; i--) {
|
|
1709
|
+
const attr = claimAttributions[i];
|
|
1710
|
+
if (attr.playerId === departingPlayerId && attr.endTick === undefined) {
|
|
1711
|
+
claimAttributions[i] = {
|
|
1712
|
+
...attr,
|
|
1713
|
+
endTick: now,
|
|
1714
|
+
};
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
authority.neutralizeSlotFrom(slot, now);
|
|
1719
|
+
}
|
|
1720
|
+
else if (currentSeat && currentSeat.control.kind === "bot") {
|
|
1721
|
+
const intent = {
|
|
1722
|
+
eventId: `evacuate-bot-${slot}-${now}`,
|
|
1723
|
+
kind: "S4_bot_to_empty",
|
|
1724
|
+
slot,
|
|
1725
|
+
effectiveTick: now,
|
|
1726
|
+
expectedEpoch: currentSeat.control.controlEpoch,
|
|
1727
|
+
botLeaseId: currentSeat.control.botLeaseId,
|
|
1728
|
+
reason: "evacuation",
|
|
1729
|
+
};
|
|
1730
|
+
const res = applyTransition(seatControlState, intent);
|
|
1731
|
+
seatControlState = res.nextState;
|
|
1732
|
+
if (res.effects.revokedBotLeaseId) {
|
|
1733
|
+
activeBotLeaseBySlot.delete(slot);
|
|
1734
|
+
activeBotSlots.delete(slot);
|
|
1735
|
+
}
|
|
1736
|
+
if (res.effects.secretRelease) {
|
|
1737
|
+
secretAuthority?.onPlayerLeft(res.effects.secretRelease.playerId, res.effects.secretRelease.slot, {
|
|
1738
|
+
releaseMode: res.effects.secretRelease.releaseMode,
|
|
1739
|
+
reason: "evacuation",
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
authority.neutralizeSlotFrom(slot, now);
|
|
1743
|
+
}
|
|
1744
|
+
else if (currentSeat) {
|
|
1745
|
+
const nextEvac = new Set(seatControlState.evacuatedSlots);
|
|
1746
|
+
nextEvac.add(slot);
|
|
1747
|
+
const nextRejoins = seatControlState.rejoinRecords.map((r) => {
|
|
1748
|
+
if (r.formerSlot === slot) {
|
|
1749
|
+
return { ...r, replacedByHuman: true };
|
|
1750
|
+
}
|
|
1751
|
+
return r;
|
|
1752
|
+
});
|
|
1753
|
+
seatControlState = {
|
|
1754
|
+
...seatControlState,
|
|
1755
|
+
evacuatedSlots: nextEvac,
|
|
1756
|
+
rejoinRecords: nextRejoins,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1430
1760
|
if (seatedPlayerId) {
|
|
1431
1761
|
evacuatedPlayerIds.add(seatedPlayerId);
|
|
1762
|
+
if (config.seatPolicy === undefined) {
|
|
1763
|
+
secretAuthority?.onPlayerLeft(seatedPlayerId, slot, {
|
|
1764
|
+
releaseMode: "retire",
|
|
1765
|
+
reason: "evacuation",
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1432
1768
|
slotByPlayer.delete(seatedPlayerId);
|
|
1433
1769
|
membershipEpoch += 1;
|
|
1434
1770
|
transport.broadcast(sessionMsgType, encodeDeterministicSessionMessage({
|
|
@@ -1689,12 +2025,24 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1689
2025
|
if (config.authorityEpoch !== undefined && validated.authorityEpoch !== config.authorityEpoch) {
|
|
1690
2026
|
return { accepted: false, reason: 'invalid-tick' };
|
|
1691
2027
|
}
|
|
1692
|
-
if (
|
|
1693
|
-
|
|
2028
|
+
if (config.seatPolicy !== undefined) {
|
|
2029
|
+
const currentLease = activeBotLeaseBySlot.get(validated.slot);
|
|
2030
|
+
if (!currentLease || validated.botLeaseId !== currentLease.leaseId) {
|
|
2031
|
+
return { accepted: false, reason: 'slot-out-of-range' };
|
|
2032
|
+
}
|
|
2033
|
+
const lastSeq = lastBotSequenceByLease.get(currentLease.leaseId) ?? 0;
|
|
2034
|
+
if (validated.sequence <= lastSeq) {
|
|
2035
|
+
return { accepted: false, reason: 'invalid-tick' };
|
|
2036
|
+
}
|
|
1694
2037
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
2038
|
+
else {
|
|
2039
|
+
if (!botSlotSet.has(validated.slot)) {
|
|
2040
|
+
return { accepted: false, reason: 'slot-out-of-range' };
|
|
2041
|
+
}
|
|
2042
|
+
const lastSeq = lastBotSequenceBySlot.get(validated.slot);
|
|
2043
|
+
if (lastSeq !== undefined && validated.sequence <= lastSeq) {
|
|
2044
|
+
return { accepted: false, reason: 'invalid-tick' };
|
|
2045
|
+
}
|
|
1698
2046
|
}
|
|
1699
2047
|
if (validated.tick <= authority.confirmedThrough) {
|
|
1700
2048
|
return { accepted: false, reason: 'tick-already-confirmed' };
|
|
@@ -1704,20 +2052,94 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1704
2052
|
}
|
|
1705
2053
|
const result = authority.receiveInput(validated.slot, validated.tick, validated.input);
|
|
1706
2054
|
if (result.accepted) {
|
|
1707
|
-
|
|
2055
|
+
if (config.seatPolicy !== undefined) {
|
|
2056
|
+
const currentLease = activeBotLeaseBySlot.get(validated.slot);
|
|
2057
|
+
if (currentLease) {
|
|
2058
|
+
lastBotSequenceByLease.set(currentLease.leaseId, validated.sequence);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
else {
|
|
2062
|
+
lastBotSequenceBySlot.set(validated.slot, validated.sequence);
|
|
2063
|
+
}
|
|
1708
2064
|
}
|
|
1709
2065
|
return result;
|
|
1710
2066
|
}
|
|
1711
2067
|
function activateBotSlot(slot, botId) {
|
|
1712
|
-
if (
|
|
2068
|
+
if (slot < 0 || slot >= config.playerCount)
|
|
2069
|
+
return;
|
|
2070
|
+
if (config.seatPolicy === undefined) {
|
|
2071
|
+
if (!botSlotSet.has(slot))
|
|
2072
|
+
return;
|
|
2073
|
+
activeBotSlots.add(slot);
|
|
2074
|
+
if (botId)
|
|
2075
|
+
bots.add(botId);
|
|
1713
2076
|
return;
|
|
2077
|
+
}
|
|
2078
|
+
const seat = seatControlState.seats[slot];
|
|
2079
|
+
if (seat.control.kind === "bot") {
|
|
2080
|
+
const lease = activeBotLeaseBySlot.get(slot);
|
|
2081
|
+
if (lease)
|
|
2082
|
+
return lease;
|
|
2083
|
+
}
|
|
2084
|
+
const controllerId = botId ?? `bot-${slot}`;
|
|
2085
|
+
const nextEpoch = seat.control.controlEpoch + 1;
|
|
2086
|
+
const botLeaseId = `lease-${slot}-${nextEpoch}`;
|
|
2087
|
+
const lease = {
|
|
2088
|
+
roomId: config.authorityId ?? "room",
|
|
2089
|
+
authorityEpoch: config.authorityEpoch ?? 0,
|
|
2090
|
+
slot,
|
|
2091
|
+
controlEpoch: nextEpoch,
|
|
2092
|
+
leaseId: botLeaseId,
|
|
2093
|
+
controllerId,
|
|
2094
|
+
};
|
|
2095
|
+
const intent = {
|
|
2096
|
+
eventId: `act-bot-${slot}-${nextEpoch}`,
|
|
2097
|
+
kind: "S1_empty_to_bot",
|
|
2098
|
+
slot,
|
|
2099
|
+
effectiveTick: Math.max(0, now + 1),
|
|
2100
|
+
expectedEpoch: seat.control.controlEpoch,
|
|
2101
|
+
controllerId,
|
|
2102
|
+
botLeaseId,
|
|
2103
|
+
};
|
|
2104
|
+
const res = applyTransition(seatControlState, intent);
|
|
2105
|
+
seatControlState = res.nextState;
|
|
1714
2106
|
activeBotSlots.add(slot);
|
|
2107
|
+
activeBotLeaseBySlot.set(slot, lease);
|
|
2108
|
+
lastBotSequenceByLease.set(botLeaseId, 0);
|
|
1715
2109
|
if (botId)
|
|
1716
2110
|
bots.add(botId);
|
|
2111
|
+
return lease;
|
|
1717
2112
|
}
|
|
1718
|
-
function deactivateBotSlot(slot) {
|
|
2113
|
+
function deactivateBotSlot(slot, leaseId) {
|
|
2114
|
+
if (slot < 0 || slot >= config.playerCount)
|
|
2115
|
+
return;
|
|
2116
|
+
if (config.seatPolicy === undefined) {
|
|
2117
|
+
activeBotSlots.delete(slot);
|
|
2118
|
+
authority.neutralizeSlot(slot);
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
const currentLease = activeBotLeaseBySlot.get(slot);
|
|
2122
|
+
if (leaseId !== undefined && currentLease && currentLease.leaseId !== leaseId) {
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
const seat = seatControlState.seats[slot];
|
|
2126
|
+
if (seat.control.kind !== "bot")
|
|
2127
|
+
return;
|
|
2128
|
+
const nextEpoch = seat.control.controlEpoch + 1;
|
|
2129
|
+
const effectiveTick = Math.max(0, now + 1);
|
|
2130
|
+
const intent = {
|
|
2131
|
+
eventId: `deact-bot-${slot}-${nextEpoch}`,
|
|
2132
|
+
kind: "S4_bot_to_empty",
|
|
2133
|
+
slot,
|
|
2134
|
+
effectiveTick,
|
|
2135
|
+
expectedEpoch: seat.control.controlEpoch,
|
|
2136
|
+
botLeaseId: seat.control.botLeaseId,
|
|
2137
|
+
};
|
|
2138
|
+
const res = applyTransition(seatControlState, intent);
|
|
2139
|
+
seatControlState = res.nextState;
|
|
2140
|
+
authority.neutralizeSlotFrom(slot, effectiveTick);
|
|
1719
2141
|
activeBotSlots.delete(slot);
|
|
1720
|
-
|
|
2142
|
+
activeBotLeaseBySlot.delete(slot);
|
|
1721
2143
|
}
|
|
1722
2144
|
return {
|
|
1723
2145
|
get confirmedThrough() {
|
|
@@ -1783,6 +2205,7 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1783
2205
|
sessionConfigDigest,
|
|
1784
2206
|
generation,
|
|
1785
2207
|
...(lastSnapshotRejection === undefined ? {} : { lastSnapshotRejection }),
|
|
2208
|
+
...(seatPolicy !== undefined ? { seatCheckpoint: createSeatCheckpoint(seatControlState) } : {}),
|
|
1786
2209
|
...(pendingTransfer === undefined
|
|
1787
2210
|
? {}
|
|
1788
2211
|
: {
|
|
@@ -1841,6 +2264,19 @@ export function createDeterministicAuthorityRoom(transport, config) {
|
|
|
1841
2264
|
if (authority.confirmedThrough < 0 || !historyReachesTickZero()) {
|
|
1842
2265
|
return undefined;
|
|
1843
2266
|
}
|
|
2267
|
+
if (config.seatPolicy !== undefined) {
|
|
2268
|
+
return {
|
|
2269
|
+
v: 2,
|
|
2270
|
+
seed,
|
|
2271
|
+
tickRateHz: config.tickRateHz,
|
|
2272
|
+
playerCount: config.playerCount,
|
|
2273
|
+
hardToleranceTicks: config.hardToleranceTicks,
|
|
2274
|
+
confirmedThrough: authority.confirmedThrough,
|
|
2275
|
+
everSlots: Object.fromEntries(everSlotByPlayer),
|
|
2276
|
+
claimAttributions: claimAttributions.map((ca) => ({ ...ca })),
|
|
2277
|
+
frames: authority.confirmedFrameRange(0, authority.confirmedThrough),
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
1844
2280
|
return {
|
|
1845
2281
|
v: 1,
|
|
1846
2282
|
seed,
|