diff --git a/src/runtime/multiplayer/partyRoom.ts b/src/runtime/multiplayer/partyRoom.ts index 80dcda8..7565de3 100644 --- a/src/runtime/multiplayer/partyRoom.ts +++ b/src/runtime/multiplayer/partyRoom.ts @@ -179,6 +179,11 @@ interface CssQuakeTargetDispatchSource { soundPath?: string; } +interface CssQuakeTrustedGameplayDefinitionsLoad { + promise: Promise; + required: boolean; +} + type CssQuakeMoverState = "bottom" | "moving-up" | "top" | "moving-down"; type CssQuakeMoverMotionState = Extract; @@ -241,6 +246,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { private fetchedTrustedGameplayDefinitions: QuakeMultiplayerGameplayDefinitions | null = null; private fetchedTrustedWorldDefinitions: QuakeMultiplayerWorldDefinition[] | null = null; private trustedGameplayDefinitionsPromise: Promise | null = null; + private trustedGameplayDefinitionsRequired = false; private trustedSceneMovement: { collisionWorld: QuakeCollisionWorld; playerEyeHeight: number; @@ -2169,13 +2175,14 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { roomKey: QuakeMultiplayerRoomCompatibilityKey, ): boolean | Promise { 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.", @@ -2188,19 +2195,29 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { private loadTrustedGameplayDefinitions( roomKey: QuakeMultiplayerRoomCompatibilityKey, - ): Promise | 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; @@ -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 { @@ -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; diff --git a/src/runtime/weapons.ts b/src/runtime/weapons.ts index bc13f33..261d074 100644 --- a/src/runtime/weapons.ts +++ b/src/runtime/weapons.ts @@ -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], @@ -1060,6 +1059,15 @@ export function createQuakeWeaponsController({ return best?.trace ?? null; } + function* aimAssistTargets(): Iterable { + 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") { diff --git a/test/gameplay/weaponDamageableBrushTargets.test.mjs b/test/gameplay/weaponDamageableBrushTargets.test.mjs index eb09c0b..e466e23 100644 --- a/test/gameplay/weaponDamageableBrushTargets.test.mjs +++ b/test/gameplay/weaponDamageableBrushTargets.test.mjs @@ -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: [ @@ -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 } } }, @@ -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, @@ -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], }, diff --git a/test/multiplayer/partyRoomLifecycle.test.mjs b/test/multiplayer/partyRoomLifecycle.test.mjs index 9be578a..6967175 100644 --- a/test/multiplayer/partyRoomLifecycle.test.mjs +++ b/test/multiplayer/partyRoomLifecycle.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { authority, fireEnvelope, + facts, helloEnvelope, inputBatchEnvelope, inputEnvelope, @@ -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); +});