@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 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.41",
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.41",
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.41",
53
+ "packageVersion": "6.0.0-rc.43",
54
54
  "compilerFlags": [
55
55
  "-std=c++20",
56
56
  "-O3",
@@ -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
  };
@@ -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 && assignedSlots < config.playerCount) {
1416
- return;
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 (!botSlotSet.has(validated.slot)) {
1693
- return { accepted: false, reason: 'slot-out-of-range' };
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
- const lastSeq = lastBotSequenceBySlot.get(validated.slot);
1696
- if (lastSeq !== undefined && validated.sequence <= lastSeq) {
1697
- return { accepted: false, reason: 'invalid-tick' };
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
- lastBotSequenceBySlot.set(validated.slot, validated.sequence);
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 (!botSlotSet.has(slot))
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
- authority.neutralizeSlot(slot);
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,