Skip to content

Commit b3c5c91

Browse files
authored
Merge pull request #29 from LayoutitStudio/multiplayer-e1m7-room-fixes
Harden multiplayer room startup and defaults
2 parents 56bb43c + 483ab45 commit b3c5c91

3 files changed

Lines changed: 101 additions & 4 deletions

File tree

src/App.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,7 @@ const QUAKE_MULTIPLAYER_HARD_CORRECTION_DISTANCE = 4096 * QUAKE_COLLISION_UNIT_S
812812
const QUAKE_MULTIPLAYER_SOFT_CORRECTION_DISTANCE = 2048 * QUAKE_COLLISION_UNIT_SCALE;
813813
const QUAKE_MULTIPLAYER_MAX_BLEND_CORRECTION_DISTANCE = 64 * QUAKE_COLLISION_UNIT_SCALE;
814814
const QUAKE_MULTIPLAYER_REMOTE_MODEL_PATHS = ["progs/player.mdl"] as const;
815+
const QUAKE_MULTIPLAYER_DEFAULT_CREATE_MAP = "e1m7";
815816
const QUAKE_MULTIPLAYER_REMOTE_DEFAULT_FRAME = "stand1";
816817
const QUAKE_MULTIPLAYER_REMOTE_RUN_FRAME_PREFIX = "rockrun";
817818
const QUAKE_MULTIPLAYER_REMOTE_PAIN_FRAME_PREFIX = "pain";
@@ -821,6 +822,7 @@ const QUAKE_MULTIPLAYER_REMOTE_PAIN_FPS = 10;
821822
const QUAKE_MULTIPLAYER_REMOTE_DEATH_FPS = 10;
822823
const QUAKE_MULTIPLAYER_REMOTE_RUN_SPEED_THRESHOLD = QUAKE_PMOVE_FORWARD_SPEED * 0.1;
823824
const QUAKE_MULTIPLAYER_REMOTE_PLAYER_EYE_HEIGHT = QUAKE_PLAYER_VIEW_Z - QUAKE_PLAYER_MINS_Z;
825+
const QUAKE_MULTIPLAYER_REMOTE_MODEL_ROT_Y_OFFSET = 0;
824826
const QUAKE_MULTIPLAYER_REMOTE_FALLBACK_ROT_Y_OFFSET = 45;
825827
const quakeMultiplayerScoreboard = QUAKE_MULTIPLAYER_ENABLED && quakeHud
826828
? mountQuakeMultiplayerScoreboard(quakeHud)
@@ -944,11 +946,17 @@ function mountQuakeMultiplayerMapSelector(): void {
944946
multiplayerMapSelect.value = quakeAssetCatalog.sceneUrl(selectedMapName) ? selectedMapName : currentMapName;
945947
}
946948

949+
function quakeMultiplayerDefaultCreateMapName(): string {
950+
return quakeAssetCatalog.sceneUrl(QUAKE_MULTIPLAYER_DEFAULT_CREATE_MAP)
951+
? QUAKE_MULTIPLAYER_DEFAULT_CREATE_MAP
952+
: currentMapName;
953+
}
954+
947955
function syncQuakeMultiplayerMenu(): void {
948956
mountQuakeMultiplayerMapSelector();
949957
if (multiplayerNameInput) multiplayerNameInput.value = QUAKE_MULTIPLAYER_LOCAL_DISPLAY_NAME;
950958
if (multiplayerColorInput) multiplayerColorInput.value = QUAKE_MULTIPLAYER_LOCAL_COLOR;
951-
if (multiplayerMapSelect) multiplayerMapSelect.value = currentMapName;
959+
if (multiplayerMapSelect) multiplayerMapSelect.value = quakeMultiplayerDefaultCreateMapName();
952960
if (multiplayerFragLimitInput) multiplayerFragLimitInput.value = String(QUAKE_MULTIPLAYER_FRAG_LIMIT);
953961
if (multiplayerMaxPlayersInput) multiplayerMaxPlayersInput.value = String(QUAKE_MULTIPLAYER_MAX_PLAYERS);
954962
syncQuakeMultiplayerControlGlyphs();
@@ -3010,7 +3018,7 @@ function quakeRemotePlayerHorizontalSpeed(state: QuakeMultiplayerRemoteInterpola
30103018
function quakeRemotePlayerVisualRotYOffset(element: HTMLElement): number {
30113019
return element.classList.contains("remote-player-fallback")
30123020
? QUAKE_MULTIPLAYER_REMOTE_FALLBACK_ROT_Y_OFFSET
3013-
: QUAKE_ALIAS_MODEL_RENDER_YAW_OFFSET;
3021+
: QUAKE_MULTIPLAYER_REMOTE_MODEL_ROT_Y_OFFSET;
30143022
}
30153023

30163024
function addQuakeProceduralRemotePlayerMesh(): PolyMeshHandle | null {
@@ -3989,6 +3997,7 @@ function sendQuakeMultiplayerPresence(status: QuakeMultiplayerPlayerPresenceStat
39893997
if (
39903998
!QUAKE_MULTIPLAYER_ENABLED ||
39913999
quakeMultiplayerSpectating ||
4000+
!quakeMultiplayerHelloAccepted ||
39924001
quakeMultiplayerSession.status().state !== "connected"
39934002
) {
39944003
return false;

src/runtime/multiplayer/partyRoom.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
266266
this.clearConnectionRejects(sender);
267267
if (!this.roomKey) this.roomKey = roomKey;
268268
if (validation.envelope.type === "client.hello") {
269+
this.seedPendingHelloAuthority(sender, validation.envelope, authority.state, receivedAt);
269270
const trustedDefinitionsReady = this.ensureTrustedGameplayDefinitions(validation.envelope, sender, roomKey);
270271
if (isPromiseLike(trustedDefinitionsReady)) {
271272
return trustedDefinitionsReady.then((ok) => {
@@ -458,8 +459,9 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
458459
lastAcceptedInputSequence: nextPlayer.lastInputSequence,
459460
}));
460461
}
462+
const latestAuthority = this.latestConnectionAuthority(sender, message.payload.clientId, authority);
461463
const state = {
462-
authority,
464+
authority: latestAuthority,
463465
clientId: message.payload.clientId,
464466
displayName: message.payload.displayName,
465467
lastSeenAt: receivedAt,
@@ -498,8 +500,9 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
498500
authority: QuakeMultiplayerClientAuthorityState,
499501
receivedAt: number,
500502
): void {
503+
const latestAuthority = this.latestConnectionAuthority(sender, message.payload.clientId, authority);
501504
const state = {
502-
authority,
505+
authority: latestAuthority,
503506
clientId: message.payload.clientId,
504507
displayName: message.payload.displayName,
505508
lastSeenAt: receivedAt,
@@ -1997,6 +2000,29 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
19972000
return this.connectionPlayers.get(connection.id) ?? (connection.state as CssQuakeConnectionState | null);
19982001
}
19992002

2003+
private seedPendingHelloAuthority(
2004+
connection: Party.Connection,
2005+
message: Extract<QuakeMultiplayerClientEnvelope, { type: "client.hello" }>,
2006+
authority: QuakeMultiplayerClientAuthorityState,
2007+
lastSeenAt: number,
2008+
): void {
2009+
const state = this.connectionState(connection);
2010+
if (state) {
2011+
this.updateConnectionAuthority(connection, authority, lastSeenAt);
2012+
return;
2013+
}
2014+
const next = {
2015+
authority,
2016+
clientId: message.payload.clientId,
2017+
displayName: message.payload.displayName,
2018+
lastSeenAt,
2019+
presenceStatus: "active" as const,
2020+
role: "player" as const,
2021+
};
2022+
this.connectionPlayers.set(connection.id, next);
2023+
connection.setState(next);
2024+
}
2025+
20002026
private updateConnectionAuthority(
20012027
connection: Party.Connection,
20022028
authority: QuakeMultiplayerClientAuthorityState,
@@ -2009,6 +2035,21 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
20092035
connection.setState(next);
20102036
}
20112037

2038+
private latestConnectionAuthority(
2039+
connection: Party.Connection,
2040+
clientId: string,
2041+
fallback: QuakeMultiplayerClientAuthorityState,
2042+
): QuakeMultiplayerClientAuthorityState {
2043+
const current = this.connectionState(connection)?.authority;
2044+
if (
2045+
current?.clientId === clientId &&
2046+
(current.lastEnvelopeSequence ?? -1) >= (fallback.lastEnvelopeSequence ?? -1)
2047+
) {
2048+
return current;
2049+
}
2050+
return fallback;
2051+
}
2052+
20122053
private handleClientPong(
20132054
message: Extract<QuakeMultiplayerClientEnvelope, { type: "client.pong" }>,
20142055
sender: Party.Connection,

test/multiplayer/protocol.test.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,53 @@ test("client authority accepts immediate presence transitions", () => {
304304
assert.equal(activeResult.ok, true);
305305
});
306306

307+
test("party room keeps hello authority while trusted gameplay definitions are pending", async () => {
308+
const { room, createConnection } = createFakePartyRoom();
309+
const RoomClass = partyRoomModule.default;
310+
let resolveTrustedDefinitions;
311+
const trustedDefinitions = new Promise((resolve) => {
312+
resolveTrustedDefinitions = resolve;
313+
});
314+
const partyRoom = new RoomClass(room, {
315+
trustedGameplayDefinitionsFetcher: () => trustedDefinitions,
316+
});
317+
const connection = createConnection("pending-hello-connection");
318+
319+
partyRoom.onConnect(connection);
320+
const helloResult = partyRoom.onMessage(JSON.stringify(helloEnvelope({
321+
messageId: "pending-hello",
322+
sequence: 1,
323+
sentAt: Date.now(),
324+
})), connection);
325+
partyRoom.onMessage(JSON.stringify(presenceEnvelope("active", {
326+
messageId: "presence-while-hello-pending",
327+
sequence: 2,
328+
sentAt: Date.now(),
329+
})), connection);
330+
331+
assert.equal(connection.closed.length, 0);
332+
assert.equal(connection.messages.some((message) =>
333+
message.type === "room.reject" &&
334+
message.payload.code === "not-authorized"
335+
), false);
336+
assert.equal(connection.state.authority.lastEnvelopeSequence, 2);
337+
338+
resolveTrustedDefinitions({
339+
gameplayFacts: {
340+
factsVersion: 1,
341+
factsHash: "0000000000000000",
342+
deathmatchSpawnCount: 0,
343+
pickupCount: 0,
344+
},
345+
deathmatchSpawns: [],
346+
pickupDefinitions: [],
347+
});
348+
await Promise.resolve(helloResult);
349+
350+
assert.equal(connection.state.playerId, "party:client-a");
351+
assert.equal(connection.state.authority.lastEnvelopeSequence, 2);
352+
});
353+
307354
test("room wrong-map rejects validate even when their room key differs", () => {
308355
const reject = protocol.createQuakeMultiplayerEnvelope({
309356
direction: "room",

0 commit comments

Comments
 (0)