diff --git a/src/App.ts b/src/App.ts index 3973f86..b5bcf96 100644 --- a/src/App.ts +++ b/src/App.ts @@ -812,6 +812,7 @@ const QUAKE_MULTIPLAYER_HARD_CORRECTION_DISTANCE = 4096 * QUAKE_COLLISION_UNIT_S const QUAKE_MULTIPLAYER_SOFT_CORRECTION_DISTANCE = 2048 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_MULTIPLAYER_MAX_BLEND_CORRECTION_DISTANCE = 64 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_MULTIPLAYER_REMOTE_MODEL_PATHS = ["progs/player.mdl"] as const; +const QUAKE_MULTIPLAYER_DEFAULT_CREATE_MAP = "e1m7"; const QUAKE_MULTIPLAYER_REMOTE_DEFAULT_FRAME = "stand1"; const QUAKE_MULTIPLAYER_REMOTE_RUN_FRAME_PREFIX = "rockrun"; const QUAKE_MULTIPLAYER_REMOTE_PAIN_FRAME_PREFIX = "pain"; @@ -821,6 +822,7 @@ const QUAKE_MULTIPLAYER_REMOTE_PAIN_FPS = 10; const QUAKE_MULTIPLAYER_REMOTE_DEATH_FPS = 10; const QUAKE_MULTIPLAYER_REMOTE_RUN_SPEED_THRESHOLD = QUAKE_PMOVE_FORWARD_SPEED * 0.1; const QUAKE_MULTIPLAYER_REMOTE_PLAYER_EYE_HEIGHT = QUAKE_PLAYER_VIEW_Z - QUAKE_PLAYER_MINS_Z; +const QUAKE_MULTIPLAYER_REMOTE_MODEL_ROT_Y_OFFSET = 0; const QUAKE_MULTIPLAYER_REMOTE_FALLBACK_ROT_Y_OFFSET = 45; const quakeMultiplayerScoreboard = QUAKE_MULTIPLAYER_ENABLED && quakeHud ? mountQuakeMultiplayerScoreboard(quakeHud) @@ -944,11 +946,17 @@ function mountQuakeMultiplayerMapSelector(): void { multiplayerMapSelect.value = quakeAssetCatalog.sceneUrl(selectedMapName) ? selectedMapName : currentMapName; } +function quakeMultiplayerDefaultCreateMapName(): string { + return quakeAssetCatalog.sceneUrl(QUAKE_MULTIPLAYER_DEFAULT_CREATE_MAP) + ? QUAKE_MULTIPLAYER_DEFAULT_CREATE_MAP + : currentMapName; +} + function syncQuakeMultiplayerMenu(): void { mountQuakeMultiplayerMapSelector(); if (multiplayerNameInput) multiplayerNameInput.value = QUAKE_MULTIPLAYER_LOCAL_DISPLAY_NAME; if (multiplayerColorInput) multiplayerColorInput.value = QUAKE_MULTIPLAYER_LOCAL_COLOR; - if (multiplayerMapSelect) multiplayerMapSelect.value = currentMapName; + if (multiplayerMapSelect) multiplayerMapSelect.value = quakeMultiplayerDefaultCreateMapName(); if (multiplayerFragLimitInput) multiplayerFragLimitInput.value = String(QUAKE_MULTIPLAYER_FRAG_LIMIT); if (multiplayerMaxPlayersInput) multiplayerMaxPlayersInput.value = String(QUAKE_MULTIPLAYER_MAX_PLAYERS); syncQuakeMultiplayerControlGlyphs(); @@ -3010,7 +3018,7 @@ function quakeRemotePlayerHorizontalSpeed(state: QuakeMultiplayerRemoteInterpola function quakeRemotePlayerVisualRotYOffset(element: HTMLElement): number { return element.classList.contains("remote-player-fallback") ? QUAKE_MULTIPLAYER_REMOTE_FALLBACK_ROT_Y_OFFSET - : QUAKE_ALIAS_MODEL_RENDER_YAW_OFFSET; + : QUAKE_MULTIPLAYER_REMOTE_MODEL_ROT_Y_OFFSET; } function addQuakeProceduralRemotePlayerMesh(): PolyMeshHandle | null { @@ -3989,6 +3997,7 @@ function sendQuakeMultiplayerPresence(status: QuakeMultiplayerPlayerPresenceStat if ( !QUAKE_MULTIPLAYER_ENABLED || quakeMultiplayerSpectating || + !quakeMultiplayerHelloAccepted || quakeMultiplayerSession.status().state !== "connected" ) { return false; diff --git a/src/runtime/multiplayer/partyRoom.ts b/src/runtime/multiplayer/partyRoom.ts index aa9e0f5..76be31b 100644 --- a/src/runtime/multiplayer/partyRoom.ts +++ b/src/runtime/multiplayer/partyRoom.ts @@ -266,6 +266,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.clearConnectionRejects(sender); if (!this.roomKey) this.roomKey = roomKey; if (validation.envelope.type === "client.hello") { + this.seedPendingHelloAuthority(sender, validation.envelope, authority.state, receivedAt); const trustedDefinitionsReady = this.ensureTrustedGameplayDefinitions(validation.envelope, sender, roomKey); if (isPromiseLike(trustedDefinitionsReady)) { return trustedDefinitionsReady.then((ok) => { @@ -458,8 +459,9 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { lastAcceptedInputSequence: nextPlayer.lastInputSequence, })); } + const latestAuthority = this.latestConnectionAuthority(sender, message.payload.clientId, authority); const state = { - authority, + authority: latestAuthority, clientId: message.payload.clientId, displayName: message.payload.displayName, lastSeenAt: receivedAt, @@ -498,8 +500,9 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { authority: QuakeMultiplayerClientAuthorityState, receivedAt: number, ): void { + const latestAuthority = this.latestConnectionAuthority(sender, message.payload.clientId, authority); const state = { - authority, + authority: latestAuthority, clientId: message.payload.clientId, displayName: message.payload.displayName, lastSeenAt: receivedAt, @@ -1997,6 +2000,29 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { return this.connectionPlayers.get(connection.id) ?? (connection.state as CssQuakeConnectionState | null); } + private seedPendingHelloAuthority( + connection: Party.Connection, + message: Extract, + authority: QuakeMultiplayerClientAuthorityState, + lastSeenAt: number, + ): void { + const state = this.connectionState(connection); + if (state) { + this.updateConnectionAuthority(connection, authority, lastSeenAt); + return; + } + const next = { + authority, + clientId: message.payload.clientId, + displayName: message.payload.displayName, + lastSeenAt, + presenceStatus: "active" as const, + role: "player" as const, + }; + this.connectionPlayers.set(connection.id, next); + connection.setState(next); + } + private updateConnectionAuthority( connection: Party.Connection, authority: QuakeMultiplayerClientAuthorityState, @@ -2009,6 +2035,21 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { connection.setState(next); } + private latestConnectionAuthority( + connection: Party.Connection, + clientId: string, + fallback: QuakeMultiplayerClientAuthorityState, + ): QuakeMultiplayerClientAuthorityState { + const current = this.connectionState(connection)?.authority; + if ( + current?.clientId === clientId && + (current.lastEnvelopeSequence ?? -1) >= (fallback.lastEnvelopeSequence ?? -1) + ) { + return current; + } + return fallback; + } + private handleClientPong( message: Extract, sender: Party.Connection, diff --git a/test/multiplayer/protocol.test.mjs b/test/multiplayer/protocol.test.mjs index df112a8..e10ee09 100644 --- a/test/multiplayer/protocol.test.mjs +++ b/test/multiplayer/protocol.test.mjs @@ -304,6 +304,53 @@ test("client authority accepts immediate presence transitions", () => { assert.equal(activeResult.ok, true); }); +test("party room keeps hello authority while trusted gameplay definitions are pending", async () => { + const { room, createConnection } = createFakePartyRoom(); + const RoomClass = partyRoomModule.default; + let resolveTrustedDefinitions; + const trustedDefinitions = new Promise((resolve) => { + resolveTrustedDefinitions = resolve; + }); + const partyRoom = new RoomClass(room, { + trustedGameplayDefinitionsFetcher: () => trustedDefinitions, + }); + const connection = createConnection("pending-hello-connection"); + + partyRoom.onConnect(connection); + const helloResult = partyRoom.onMessage(JSON.stringify(helloEnvelope({ + messageId: "pending-hello", + sequence: 1, + sentAt: Date.now(), + })), connection); + partyRoom.onMessage(JSON.stringify(presenceEnvelope("active", { + messageId: "presence-while-hello-pending", + sequence: 2, + sentAt: Date.now(), + })), connection); + + assert.equal(connection.closed.length, 0); + assert.equal(connection.messages.some((message) => + message.type === "room.reject" && + message.payload.code === "not-authorized" + ), false); + assert.equal(connection.state.authority.lastEnvelopeSequence, 2); + + resolveTrustedDefinitions({ + gameplayFacts: { + factsVersion: 1, + factsHash: "0000000000000000", + deathmatchSpawnCount: 0, + pickupCount: 0, + }, + deathmatchSpawns: [], + pickupDefinitions: [], + }); + await Promise.resolve(helloResult); + + assert.equal(connection.state.playerId, "party:client-a"); + assert.equal(connection.state.authority.lastEnvelopeSequence, 2); +}); + test("room wrong-map rejects validate even when their room key differs", () => { const reject = protocol.createQuakeMultiplayerEnvelope({ direction: "room",