Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3989,6 +3997,7 @@ function sendQuakeMultiplayerPresence(status: QuakeMultiplayerPlayerPresenceStat
if (
!QUAKE_MULTIPLAYER_ENABLED ||
quakeMultiplayerSpectating ||
!quakeMultiplayerHelloAccepted ||
quakeMultiplayerSession.status().state !== "connected"
) {
return false;
Expand Down
45 changes: 43 additions & 2 deletions src/runtime/multiplayer/partyRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<QuakeMultiplayerClientEnvelope, { type: "client.hello" }>,
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,
Expand All @@ -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<QuakeMultiplayerClientEnvelope, { type: "client.pong" }>,
sender: Party.Connection,
Expand Down
47 changes: 47 additions & 0 deletions test/multiplayer/protocol.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading