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
35 changes: 28 additions & 7 deletions src/runtime/multiplayer/partyRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ interface CssQuakeTargetDispatchSource {
soundPath?: string;
}

interface CssQuakeTrustedGameplayDefinitionsLoad {
promise: Promise<QuakeMultiplayerGameplayDefinitions | null>;
required: boolean;
}

type CssQuakeMoverState = "bottom" | "moving-up" | "top" | "moving-down";
type CssQuakeMoverMotionState = Extract<QuakeMultiplayerMoverState, "moving-up" | "moving-down">;

Expand Down Expand Up @@ -241,6 +246,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
private fetchedTrustedGameplayDefinitions: QuakeMultiplayerGameplayDefinitions | null = null;
private fetchedTrustedWorldDefinitions: QuakeMultiplayerWorldDefinition[] | null = null;
private trustedGameplayDefinitionsPromise: Promise<QuakeMultiplayerGameplayDefinitions | null> | null = null;
private trustedGameplayDefinitionsRequired = false;
private trustedSceneMovement: {
collisionWorld: QuakeCollisionWorld;
playerEyeHeight: number;
Expand Down Expand Up @@ -2169,13 +2175,14 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
roomKey: QuakeMultiplayerRoomCompatibilityKey,
): boolean | Promise<boolean> {
if (this.trustedGameplayDefinitions()) return true;
const pending = this.loadTrustedGameplayDefinitions(roomKey);
if (!pending) return true;
return pending.then((definitions) => {
const load = this.loadTrustedGameplayDefinitions(roomKey);
if (!load) return true;
return load.promise.then((definitions) => {
if (definitions) {
this.fetchedTrustedGameplayDefinitions = definitions;
return true;
}
if (!load.required) return true;
this.reject(sender, {
code: "wrong-map",
message: "Could not load trusted multiplayer gameplay facts for this room.",
Expand All @@ -2188,19 +2195,29 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {

private loadTrustedGameplayDefinitions(
roomKey: QuakeMultiplayerRoomCompatibilityKey,
): Promise<QuakeMultiplayerGameplayDefinitions | null> | null {
if (this.trustedGameplayDefinitionsPromise) return this.trustedGameplayDefinitionsPromise;
): CssQuakeTrustedGameplayDefinitionsLoad | null {
if (this.trustedGameplayDefinitionsPromise) {
return {
promise: this.trustedGameplayDefinitionsPromise,
required: this.trustedGameplayDefinitionsRequired,
};
}
const customFetcher = this.options.trustedGameplayDefinitionsFetcher;
if (customFetcher) {
this.trustedGameplayDefinitionsRequired = true;
this.trustedGameplayDefinitionsPromise = Promise.resolve(customFetcher(roomKey))
.then((definitions) => definitions ?? null)
.catch(() => null);
return this.trustedGameplayDefinitionsPromise;
return {
promise: this.trustedGameplayDefinitionsPromise,
required: this.trustedGameplayDefinitionsRequired,
};
}
const assetFetcher = this.room.context?.assets?.fetch;
if (typeof assetFetcher !== "function") return null;
const sceneAssetPath = trustedQuakeMultiplayerSceneAssetPath(roomKey.sceneUrl);
if (!sceneAssetPath) return null;
this.trustedGameplayDefinitionsRequired = false;
this.trustedGameplayDefinitionsPromise = assetFetcher.call(this.room.context.assets, sceneAssetPath)
.then(async (response) => {
if (!response?.ok) return null;
Expand All @@ -2211,7 +2228,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
return quakeMultiplayerGameplayDefinitionsFromScene(scene, {});
})
.catch(() => null);
return this.trustedGameplayDefinitionsPromise;
return {
promise: this.trustedGameplayDefinitionsPromise,
required: this.trustedGameplayDefinitionsRequired,
};
}

private removeConnection(connection: Party.Connection, reason: string): void {
Expand Down Expand Up @@ -2336,6 +2356,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server {
this.fetchedTrustedGameplayDefinitions = null;
this.fetchedTrustedWorldDefinitions = null;
this.trustedGameplayDefinitionsPromise = null;
this.trustedGameplayDefinitionsRequired = false;
this.trustedSceneMovement = this.options.trustedSceneMovement ?? null;
this.snapshotHistory = [];
this.dynamicPickupSequence = 1_000_000;
Expand Down
12 changes: 10 additions & 2 deletions src/runtime/weapons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1038,8 +1038,7 @@ export function createQuakeWeaponsController({
function quakeAimTrace(ray: QuakeViewRay): QuakeUseTrace | null {
const collisionWorld = getCollisionWorld();
let best: { score: number; trace: QuakeUseTrace } | null = null;
for (const shootable of getShootables()) {
if (shootable.dead) continue;
for (const shootable of aimAssistTargets()) {
const target = shootableAimPoint(shootable);
const targetDirection = normalizeVec3([
target[0] - ray.origin[0],
Expand All @@ -1060,6 +1059,15 @@ export function createQuakeWeaponsController({
return best?.trace ?? null;
}

function* aimAssistTargets(): Iterable<QuakeWeaponShootableTarget> {
for (const shootable of getShootables()) {
if (!shootable.dead) yield shootable;
}
for (const target of getDamageableBrushTargets?.() ?? []) {
if (!target.dead && isWeaponTraceDamageableBrushTarget(target.entity)) yield target;
}
}

function fireWeaponProfile(profile: QuakeRuntimeWeaponFireProfile, now: number): boolean {
if (profile.kind === "hitscan-pellets") return fireShotgunPellets(profile);
if (profile.kind === "projectile") {
Expand Down
54 changes: 43 additions & 11 deletions test/gameplay/weaponDamageableBrushTargets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ test("projectiles can damage health trigger brush targets over earlier world tra
assert.deepEqual(damagedBrushes, [{ amount: 1, entityIndex: 138 }]);
});

test("health trigger brush targets can use weapon source aim correction", () => {
const controller = createWeaponsController({
damageableBrushTargets: [
damageableBrushTarget({
bounds: {
min: [1, -0.1, -0.02],
max: [2, 0.1, 0.02],
},
classname: "trigger_multiple",
health: 1,
index: 270,
}),
],
entities: [
quakeEntity({
classname: "trigger_multiple",
health: 1,
index: 270,
}),
],
traceUse: () => null,
});

const trace = controller.weaponTraceAtCrosshair();

assert.equal(trace?.classname, "trigger_multiple");
assert.equal(trace?.entityIndex, 270);
});

test("non-button damageable brush targets do not bypass world trace", () => {
const controller = createWeaponsController({
damageableBrushTargets: [
Expand All @@ -108,7 +137,17 @@ test("non-button damageable brush targets do not bypass world trace", () => {
assert.equal(trace?.entityIndex, undefined);
});

function createWeaponsController({ damageBrushEntity = () => false, damageableBrushTargets, entities }) {
function createWeaponsController({
damageBrushEntity = () => false,
damageableBrushTargets,
entities,
traceUse = () => ({
classname: "worldspawn",
end: [0.1, 0, 0],
fraction: 0.01,
planeNormal: [-1, 0, 0],
}),
}) {
const entityByIndex = new Map(entities.map((entity) => [entity.index, entity]));
return weapons.createQuakeWeaponsController({
scene: { camera: { state: { rotX: 90, rotY: 180 } } },
Expand All @@ -120,14 +159,7 @@ function createWeaponsController({ damageBrushEntity = () => false, damageableBr
damageShootable: () => false,
getActiveWeapon: () => "nailgun",
getAmmo: () => 100,
getCollisionWorld: () => ({
traceUse: () => ({
classname: "worldspawn",
end: [0.1, 0, 0],
fraction: 0.01,
planeNormal: [-1, 0, 0],
}),
}),
getCollisionWorld: () => ({ traceUse }),
getDamageableBrushTargets: () => damageableBrushTargets,
getEntities: () => entityByIndex,
getPlayerEyeHeight: () => 0.92,
Expand All @@ -143,12 +175,12 @@ function createWeaponsController({ damageBrushEntity = () => false, damageableBr
});
}

function damageableBrushTarget({ classname, health, index }) {
function damageableBrushTarget({ bounds, classname, health, index }) {
return {
dead: false,
entity: quakeEntity({ classname, health, index }),
origin: [0, 0, 0],
bounds: {
bounds: bounds ?? {
min: [-100, -100, -100],
max: [100, 100, 100],
},
Expand Down
76 changes: 76 additions & 0 deletions test/multiplayer/partyRoomLifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
authority,
fireEnvelope,
facts,
helloEnvelope,
inputBatchEnvelope,
inputEnvelope,
Expand Down Expand Up @@ -273,3 +274,78 @@ test("party room keeps hello authority while trusted gameplay definitions are pe
assert.equal(connection.state.playerId, "party:client-a");
assert.equal(connection.state.authority.lastEnvelopeSequence, 2);
});

test("party room falls back to hello gameplay facts when implicit trusted asset lookup misses", async () => {
const { room, createConnection } = createFakePartyRoom("trusted-asset-miss");
const RoomClass = partyRoomModule.default;
const assetRequests = [];
room.context.assets = {
fetch: async (assetPath) => {
assetRequests.push(assetPath);
return new Response("missing", { status: 404 });
},
};
const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({
deathmatchSpawns: [{
spawnId: "asset-miss-spawn",
classname: "info_player_deathmatch",
origin: [0, 0, 0],
rotX: 90,
rotY: 0,
}],
pickupDefinitions: [],
});
const partyRoom = new RoomClass(room);
const connection = createConnection("asset-miss-connection");

partyRoom.onConnect(connection);
const result = partyRoom.onMessage(JSON.stringify(helloEnvelope({
deathmatchSpawns: gameplayDefinitions.deathmatchSpawns,
gameplayFacts: gameplayDefinitions.gameplayFacts,
messageId: "asset-miss-hello",
pickupDefinitions: gameplayDefinitions.pickupDefinitions,
sequence: 1,
sentAt: Date.now(),
})), connection);
await Promise.resolve(result);

assert.deepEqual(assetRequests, ["/q/e1m1.json"]);
assert.equal(connection.messages.some((message) => message.type === "room.reject"), false);
assert.equal(connection.state.playerId, "party:client-a");
const snapshot = latestConnectionMessage(connection, "room.snapshot");
assert.equal(snapshot.payload.players.length, 1);
});

test("party room rejects hello when explicit trusted gameplay fetcher misses", async () => {
const { room, createConnection } = createFakePartyRoom("required-trusted-fetcher-miss");
const RoomClass = partyRoomModule.default;
const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({
deathmatchSpawns: [{
spawnId: "required-fetcher-spawn",
classname: "info_player_deathmatch",
origin: [0, 0, 0],
rotX: 90,
rotY: 0,
}],
pickupDefinitions: [],
});
const partyRoom = new RoomClass(room, {
trustedGameplayDefinitionsFetcher: async () => null,
});
const connection = createConnection("required-fetcher-connection");

partyRoom.onConnect(connection);
const result = partyRoom.onMessage(JSON.stringify(helloEnvelope({
deathmatchSpawns: gameplayDefinitions.deathmatchSpawns,
gameplayFacts: gameplayDefinitions.gameplayFacts,
messageId: "required-fetcher-hello",
pickupDefinitions: gameplayDefinitions.pickupDefinitions,
sequence: 1,
sentAt: Date.now(),
})), connection);
await Promise.resolve(result);

const reject = latestConnectionMessage(connection, "room.reject");
assert.equal(reject.payload.code, "wrong-map");
assert.equal(connection.state?.playerId, undefined);
});
Loading