add secure acoustic DEAD AIR game
All checks were successful
build / image (push) Successful in 41s

This commit is contained in:
Syncer Deploy
2026-08-31 13:17:58 -04:00
parent f759a3138b
commit 6361ecbdd7
13 changed files with 2709 additions and 7 deletions

View File

@@ -0,0 +1,905 @@
import {
createJsonCodec,
defineMultiplayerGame,
withInputStream,
} from "@syncer/engine";
import {
DEAD_AIR_ARTIFACT_SPAWN,
DEAD_AIR_EXTRACTION,
DEAD_AIR_POWER_SWITCH,
DEAD_AIR_SPAWNS,
DEAD_AIR_WARDEN_SPAWNS,
collidesWithDeadAirMap,
hasDeadAirLineOfSight,
} from "./dead-air-map.js";
import type {
DeadAirAuthorityEvent,
DeadAirAuthorityPlayer,
DeadAirAuthorityState,
DeadAirClientState,
DeadAirDistanceBand,
DeadAirGameContract,
DeadAirInput,
DeadAirPerception,
DeadAirPlayerView,
DeadAirSoundCue,
} from "./dead-air-types.js";
export const DEAD_AIR_SOCKET_PATH = "/ws/dead-air";
export const DEAD_AIR_TICK_RATE = 30;
export const DEAD_AIR_SNAPSHOT_RATE = 10;
export const DEAD_AIR_WARDEN_COUNT = 5;
export const DEAD_AIR_WARDEN_ID_BASE = 50_000;
export const DEAD_AIR_MAGAZINE_SIZE = 8;
const walkSpeed = 5.2;
const sprintSpeed = 7.8;
const movementAcceleration = 36;
const movementFriction = 12;
const shotRange = 34;
const shotDamage = 34;
const shotCooldownTicks = 10;
const reloadDurationTicks = 42;
const presentationLifetimeTicks = DEAD_AIR_TICK_RATE * 7;
const inputCodec = createJsonCodec<DeadAirInput>();
const stateCodec = createJsonCodec<DeadAirClientState>();
const eventCodec = createJsonCodec<DeadAirPerception>();
const baseDeadAirGame = defineMultiplayerGame<DeadAirGameContract>({
clock: {
ticksPerSecond: DEAD_AIR_TICK_RATE,
snapshotsPerSecond: DEAD_AIR_SNAPSHOT_RATE,
},
authority: {
createInitialState: createAuthorityState,
cloneState: cloneAuthorityState,
addPlayer(state, { playerId }) {
state.players.push(createPlayer(playerId, false, state.round));
state.players.sort((left, right) => left.id - right.id);
},
removePlayer(state, { playerId, emit }) {
const player = findPlayer(state, playerId);
if (player?.carryingArtifact) dropArtifact(state, player, emit);
state.players = state.players.filter((candidate) => candidate.id !== playerId);
},
applyInput(state, input, { playerId, emit }) {
const player = findPlayer(state, playerId);
if (!player || player.bot || !player.alive || state.resetTicks > 0) return;
applyCommand(player, input);
if (input.reload) beginReload(player);
if (input.toggleFlashlight) player.flashlight = !player.flashlight;
if (input.interact) interact(state, player, emit);
if (input.throwDecoy) throwDecoy(state, player, emit);
},
step(state, { tick, deltaSeconds, emit }) {
if (state.resetTicks > 0) {
state.resetTicks -= 1;
if (state.resetTicks === 0) resetRound(state);
return;
}
state.elapsedTicks += 1;
if (state.elapsedTicks % 75 === 0) {
emit({
id: state.nextEventId++,
type: "noise",
sourceId: null,
cue: "curse",
x: state.artifactX,
z: state.artifactZ,
loudness: 27,
});
}
for (const player of state.players) {
if (!player.alive) {
updateRespawn(state, player);
continue;
}
updateTimers(player);
if (player.botState) updateWarden(state, player, tick);
simulatePlayer(player, deltaSeconds);
emitFootsteps(state, player, emit);
if (player.firing) fire(state, player, emit);
}
const carrier = state.artifactCarrierId === null
? null
: findPlayer(state, state.artifactCarrierId);
if (carrier?.alive) {
state.artifactX = carrier.x;
state.artifactZ = carrier.z;
if (
!carrier.bot &&
Math.hypot(carrier.x - DEAD_AIR_EXTRACTION.x, carrier.z - DEAD_AIR_EXTRACTION.z) <=
DEAD_AIR_EXTRACTION.radius
) {
carrier.extractions += 1;
state.lastExtractorId = carrier.id;
state.artifactCarrierId = null;
carrier.carryingArtifact = false;
state.resetTicks = DEAD_AIR_TICK_RATE * 4;
emit({
id: state.nextEventId++,
type: "artifact",
action: "extracted",
actorId: carrier.id,
x: carrier.x,
z: carrier.z,
round: state.round,
});
}
}
},
validateState(state) {
return (
Number.isInteger(state.round) &&
state.round > 0 &&
Number.isFinite(state.artifactX + state.artifactZ) &&
state.players.every(
(player) =>
Number.isFinite(
player.x + player.z + player.velocityX + player.velocityZ + player.yaw + player.pitch,
) &&
player.health >= 0 &&
player.health <= 100 &&
player.magazine >= 0 &&
player.reserve >= 0,
)
);
},
},
prediction: {
createInitialState: createClientState,
cloneState: cloneClientState,
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (!player || !player.alive || state.resetTicks > 0) return;
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.2, 1.2);
player.firing = input.fire;
player.sprinting = input.sprint;
if (input.reload && player.reloadTicks === 0 && (player.magazine ?? 0) < DEAD_AIR_MAGAZINE_SIZE) {
player.reloadTicks = reloadDurationTicks;
}
if (input.toggleFlashlight) player.flashlight = !player.flashlight;
},
step(state, { tick, deltaSeconds }) {
if (state.resetTicks > 0) state.resetTicks -= 1;
state.elapsedTicks += 1;
for (const player of state.players) {
if (!player.alive) {
if (player.respawnTicks > 0) player.respawnTicks -= 1;
continue;
}
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.reloadTicks > 0) player.reloadTicks -= 1;
simulateVisiblePlayer(player, deltaSeconds);
}
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - presentationLifetimeTicks,
);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const merged = cloneClientState(snapshot);
merged.events = predicted.events
.filter((entry) => entry.receivedTick >= tick - presentationLifetimeTicks)
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
if (state.events.length > 72) state.events.shift();
},
validateState(state) {
return (
Number.isInteger(state.round) &&
state.players.every(
(player) =>
Number.isFinite(player.x + player.z + player.yaw + player.pitch) &&
(player.health === null || (player.health >= 0 && player.health <= 100)),
)
);
},
},
visibility: {
createSnapshot(authority, { playerId }) {
const viewer = findPlayer(authority, playerId);
const visiblePlayers = viewer
? authority.players
.filter(
(target) => target.id === viewer.id || canSeePlayer(authority, viewer, target),
)
.map((player) => playerView(player, player.id === viewer.id))
: [];
const carrier = authority.artifactCarrierId === null
? null
: findPlayer(authority, authority.artifactCarrierId);
const visibleCarrier = carrier && visiblePlayers.some((player) => player.id === carrier.id);
const artifactVisible = Boolean(
viewer &&
(carrier?.id === viewer.id ||
visibleCarrier ||
(!carrier && canSeePoint(authority, viewer, authority.artifactX, authority.artifactZ))),
);
return {
players: visiblePlayers,
artifact: {
visible: artifactVisible,
x: artifactVisible ? authority.artifactX : null,
z: artifactVisible ? authority.artifactZ : null,
carrierId: artifactVisible ? authority.artifactCarrierId : null,
},
powerOn: authority.powerOn,
elapsedTicks: authority.elapsedTicks,
round: authority.round,
resetTicks: authority.resetTicks,
lastExtractorId: authority.lastExtractorId === playerId ? playerId : null,
events: [],
};
},
validateClientState(authority, candidate, { playerId }) {
const expected = findPlayer(authority, playerId);
const reported = candidate.players.find((player) => player.id === playerId);
return Boolean(
expected &&
reported &&
reported.health === expected.health &&
reported.magazine === expected.magazine &&
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 3.5,
);
},
perceive(authority, event, { playerId }) {
return perceive(authority, event, playerId);
},
},
input: {
validate(input) {
return (
typeof input === "object" &&
input !== null &&
Number.isFinite(input.forward) &&
Number.isFinite(input.strafe) &&
Number.isFinite(input.yaw) &&
Number.isFinite(input.pitch) &&
Math.abs(input.forward) <= 1 &&
Math.abs(input.strafe) <= 1 &&
input.pitch >= -1.25 &&
input.pitch <= 1.25 &&
typeof input.fire === "boolean" &&
typeof input.sprint === "boolean" &&
typeof input.reload === "boolean" &&
typeof input.interact === "boolean" &&
typeof input.toggleFlashlight === "boolean" &&
typeof input.throwDecoy === "boolean"
);
},
},
encoding: {
input: inputCodec,
clientState: stateCodec,
perception: eventCodec,
},
});
export const deadAirGame = withInputStream(baseDeadAirGame, {
heartbeatRateHz: 20,
timeoutMs: 400,
inputsEqual(left, right) {
return (
left.forward === right.forward &&
left.strafe === right.strafe &&
left.yaw === right.yaw &&
left.pitch === right.pitch &&
left.fire === right.fire &&
left.sprint === right.sprint &&
left.reload === right.reload &&
left.interact === right.interact &&
left.toggleFlashlight === right.toggleFlashlight &&
left.throwDecoy === right.throwDecoy
);
},
neutralize(lastInput) {
return {
...lastInput,
forward: 0,
strafe: 0,
fire: false,
sprint: false,
reload: false,
interact: false,
toggleFlashlight: false,
throwDecoy: false,
};
},
resume(lastInput) {
return {
...lastInput,
reload: false,
interact: false,
toggleFlashlight: false,
throwDecoy: false,
};
},
});
function createAuthorityState(): DeadAirAuthorityState {
const players: DeadAirAuthorityPlayer[] = [];
for (let index = 0; index < DEAD_AIR_WARDEN_COUNT; index += 1) {
players.push(createPlayer(DEAD_AIR_WARDEN_ID_BASE + index, true, 1));
}
return {
players,
artifactX: DEAD_AIR_ARTIFACT_SPAWN.x,
artifactZ: DEAD_AIR_ARTIFACT_SPAWN.z,
artifactCarrierId: null,
powerOn: true,
elapsedTicks: 0,
round: 1,
resetTicks: 0,
lastExtractorId: null,
nextEventId: 1,
};
}
function createClientState(): DeadAirClientState {
return {
players: [],
artifact: { visible: false, x: null, z: null, carrierId: null },
powerOn: true,
elapsedTicks: 0,
round: 1,
resetTicks: 0,
lastExtractorId: null,
events: [],
};
}
function createPlayer(id: number, bot: boolean, round: number): DeadAirAuthorityPlayer {
const spawn = bot
? DEAD_AIR_WARDEN_SPAWNS[(id - DEAD_AIR_WARDEN_ID_BASE) % DEAD_AIR_WARDEN_SPAWNS.length]!
: DEAD_AIR_SPAWNS[(id + round) % DEAD_AIR_SPAWNS.length]!;
return {
id,
bot,
alive: true,
x: spawn.x,
z: spawn.z,
velocityX: 0,
velocityZ: 0,
yaw: spawn.yaw,
pitch: 0,
health: 100,
flashlight: true,
carryingArtifact: false,
firing: false,
sprinting: false,
inputForward: 0,
inputStrafe: 0,
cooldownTicks: 0,
reloadTicks: 0,
respawnTicks: 0,
magazine: DEAD_AIR_MAGAZINE_SIZE,
reserve: 32,
decoys: bot ? 0 : 3,
extractions: 0,
footstepTicks: 0,
botState: bot
? { phase: id * 0.71, targetId: null, lastKnownX: spawn.x, lastKnownZ: spawn.z }
: null,
};
}
function applyCommand(player: DeadAirAuthorityPlayer, input: DeadAirInput): void {
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.2, 1.2);
player.firing = input.fire;
player.sprinting = input.sprint;
}
function updateTimers(player: DeadAirAuthorityPlayer): void {
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.footstepTicks > 0) player.footstepTicks -= 1;
if (player.reloadTicks > 0) {
player.reloadTicks -= 1;
if (player.reloadTicks === 0) completeReload(player);
}
}
function simulatePlayer(player: DeadAirAuthorityPlayer, deltaSeconds: number): void {
const speed = player.sprinting && !player.carryingArtifact ? sprintSpeed : walkSpeed;
const sin = Math.sin(player.yaw);
const cos = Math.cos(player.yaw);
let desiredX = (sin * player.inputForward + cos * player.inputStrafe) * speed;
let desiredZ = (cos * player.inputForward - sin * player.inputStrafe) * speed;
const inputLength = Math.hypot(player.inputForward, player.inputStrafe);
if (inputLength > 1) {
desiredX /= inputLength;
desiredZ /= inputLength;
}
const blend = Math.min(1, movementAcceleration * deltaSeconds);
player.velocityX += (desiredX - player.velocityX) * blend;
player.velocityZ += (desiredZ - player.velocityZ) * blend;
if (inputLength < 0.01) {
const damping = Math.max(0, 1 - movementFriction * deltaSeconds);
player.velocityX *= damping;
player.velocityZ *= damping;
}
moveWithCollision(player, deltaSeconds);
}
function simulateVisiblePlayer(player: DeadAirPlayerView, deltaSeconds: number): void {
const speed = player.sprinting && !player.carryingArtifact ? sprintSpeed : walkSpeed;
const sin = Math.sin(player.yaw);
const cos = Math.cos(player.yaw);
let desiredX = (sin * player.inputForward + cos * player.inputStrafe) * speed;
let desiredZ = (cos * player.inputForward - sin * player.inputStrafe) * speed;
const inputLength = Math.hypot(player.inputForward, player.inputStrafe);
if (inputLength > 1) {
desiredX /= inputLength;
desiredZ /= inputLength;
}
const blend = Math.min(1, movementAcceleration * deltaSeconds);
player.velocityX += (desiredX - player.velocityX) * blend;
player.velocityZ += (desiredZ - player.velocityZ) * blend;
if (inputLength < 0.01) {
const damping = Math.max(0, 1 - movementFriction * deltaSeconds);
player.velocityX *= damping;
player.velocityZ *= damping;
}
moveWithCollision(player, deltaSeconds);
}
function moveWithCollision(
player: Pick<DeadAirPlayerView, "x" | "z" | "velocityX" | "velocityZ">,
deltaSeconds: number,
): void {
const nextX = player.x + player.velocityX * deltaSeconds;
if (!collidesWithDeadAirMap(nextX, player.z)) player.x = nextX;
else player.velocityX = 0;
const nextZ = player.z + player.velocityZ * deltaSeconds;
if (!collidesWithDeadAirMap(player.x, nextZ)) player.z = nextZ;
else player.velocityZ = 0;
}
function emitFootsteps(
state: DeadAirAuthorityState,
player: DeadAirAuthorityPlayer,
emit: (event: DeadAirAuthorityEvent) => void,
): void {
if (Math.hypot(player.velocityX, player.velocityZ) < 1.2 || player.footstepTicks > 0) return;
player.footstepTicks = player.sprinting ? 7 : 12;
emit({
id: state.nextEventId++,
type: "noise",
sourceId: player.id,
cue: "footstep",
x: player.x,
z: player.z,
loudness: player.sprinting ? 20 : player.carryingArtifact ? 14 : 9,
});
}
function fire(
state: DeadAirAuthorityState,
shooter: DeadAirAuthorityPlayer,
emit: (event: DeadAirAuthorityEvent) => void,
): void {
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) {
if (shooter.magazine <= 0) beginReload(shooter);
return;
}
shooter.magazine -= 1;
shooter.cooldownTicks = shotCooldownTicks;
emit({
id: state.nextEventId++,
type: "shot",
sourceId: shooter.id,
x: shooter.x,
z: shooter.z,
yaw: shooter.yaw,
pitch: shooter.pitch,
});
if (Math.abs(shooter.pitch) > 0.48) return;
const directionX = Math.sin(shooter.yaw);
const directionZ = Math.cos(shooter.yaw);
let hit: DeadAirAuthorityPlayer | null = null;
let hitDistance = shotRange;
for (const target of state.players) {
if (!target.alive || target.id === shooter.id || target.bot === shooter.bot) continue;
const offsetX = target.x - shooter.x;
const offsetZ = target.z - shooter.z;
const forwardDistance = offsetX * directionX + offsetZ * directionZ;
const crossDistance = Math.abs(offsetX * directionZ - offsetZ * directionX);
if (
forwardDistance <= 0 ||
forwardDistance >= hitDistance ||
crossDistance > 0.78 ||
!hasDeadAirLineOfSight(shooter.x, shooter.z, target.x, target.z)
) continue;
hit = target;
hitDistance = forwardDistance;
}
if (!hit) return;
hit.health = Math.max(0, hit.health - shotDamage);
const eliminated = hit.health === 0;
if (eliminated) eliminate(state, hit, emit);
emit({
id: state.nextEventId++,
type: "damage",
sourceId: shooter.id,
targetId: hit.id,
amount: shotDamage,
health: hit.health,
eliminated,
x: hit.x,
z: hit.z,
});
}
function eliminate(
state: DeadAirAuthorityState,
player: DeadAirAuthorityPlayer,
emit: (event: DeadAirAuthorityEvent) => void,
): void {
player.alive = false;
player.respawnTicks = DEAD_AIR_TICK_RATE * 3;
player.velocityX = 0;
player.velocityZ = 0;
player.firing = false;
if (player.carryingArtifact) dropArtifact(state, player, emit);
}
function updateRespawn(state: DeadAirAuthorityState, player: DeadAirAuthorityPlayer): void {
if (player.respawnTicks > 0) player.respawnTicks -= 1;
if (player.respawnTicks > 0) return;
const fresh = createPlayer(player.id, player.bot, state.round);
const extractions = player.extractions;
Object.assign(player, fresh, { extractions });
}
function beginReload(player: DeadAirAuthorityPlayer): void {
if (
player.reloadTicks > 0 ||
player.magazine >= DEAD_AIR_MAGAZINE_SIZE ||
player.reserve <= 0
) return;
player.reloadTicks = reloadDurationTicks;
player.firing = false;
}
function completeReload(player: DeadAirAuthorityPlayer): void {
const needed = DEAD_AIR_MAGAZINE_SIZE - player.magazine;
const loaded = Math.min(needed, player.reserve);
player.magazine += loaded;
player.reserve -= loaded;
}
function interact(
state: DeadAirAuthorityState,
player: DeadAirAuthorityPlayer,
emit: (event: DeadAirAuthorityEvent) => void,
): void {
if (player.carryingArtifact) {
dropArtifact(state, player, emit);
return;
}
if (
state.artifactCarrierId === null &&
Math.hypot(player.x - state.artifactX, player.z - state.artifactZ) <= 2.3
) {
state.artifactCarrierId = player.id;
player.carryingArtifact = true;
emit({
id: state.nextEventId++,
type: "artifact",
action: "grabbed",
actorId: player.id,
x: player.x,
z: player.z,
round: state.round,
});
return;
}
if (
Math.hypot(player.x - DEAD_AIR_POWER_SWITCH.x, player.z - DEAD_AIR_POWER_SWITCH.z) <= 2.5
) {
state.powerOn = !state.powerOn;
emit({
id: state.nextEventId++,
type: "power",
on: state.powerOn,
actorId: player.id,
x: player.x,
z: player.z,
});
}
}
function dropArtifact(
state: DeadAirAuthorityState,
player: DeadAirAuthorityPlayer,
emit: (event: DeadAirAuthorityEvent) => void,
): void {
player.carryingArtifact = false;
state.artifactCarrierId = null;
state.artifactX = player.x;
state.artifactZ = player.z;
emit({
id: state.nextEventId++,
type: "artifact",
action: "dropped",
actorId: player.id,
x: player.x,
z: player.z,
round: state.round,
});
}
function throwDecoy(
state: DeadAirAuthorityState,
player: DeadAirAuthorityPlayer,
emit: (event: DeadAirAuthorityEvent) => void,
): void {
if (player.decoys <= 0) return;
player.decoys -= 1;
const candidateX = player.x + Math.sin(player.yaw) * 8;
const candidateZ = player.z + Math.cos(player.yaw) * 8;
emit({
id: state.nextEventId++,
type: "noise",
sourceId: null,
cue: "decoy",
x: collidesWithDeadAirMap(candidateX, candidateZ, 0.1) ? player.x : candidateX,
z: collidesWithDeadAirMap(candidateX, candidateZ, 0.1) ? player.z : candidateZ,
loudness: 32,
});
}
function updateWarden(
state: DeadAirAuthorityState,
warden: DeadAirAuthorityPlayer,
tick: number,
): void {
const visibleTargets = state.players.filter(
(target) => !target.bot && target.alive && canSeePlayer(state, warden, target),
);
visibleTargets.sort(
(left, right) =>
Math.hypot(left.x - warden.x, left.z - warden.z) -
Math.hypot(right.x - warden.x, right.z - warden.z),
);
const target = visibleTargets[0] ?? null;
const botState = warden.botState!;
botState.targetId = target?.id ?? null;
if (target) {
botState.lastKnownX = target.x;
botState.lastKnownZ = target.z;
}
let targetX: number;
let targetZ: number;
if (target) {
targetX = target.x;
targetZ = target.z;
} else if (state.artifactCarrierId !== null) {
targetX = state.artifactX;
targetZ = state.artifactZ;
} else {
const angle = botState.phase + tick * 0.008;
targetX = DEAD_AIR_ARTIFACT_SPAWN.x + Math.sin(angle) * 22;
targetZ = DEAD_AIR_ARTIFACT_SPAWN.z + Math.cos(angle * 0.83) * 22;
if (collidesWithDeadAirMap(targetX, targetZ, 0.8)) {
targetX = DEAD_AIR_ARTIFACT_SPAWN.x;
targetZ = DEAD_AIR_ARTIFACT_SPAWN.z;
}
}
const desiredYaw = Math.atan2(targetX - warden.x, targetZ - warden.z);
warden.yaw = rotateToward(warden.yaw, desiredYaw, target ? 0.14 : 0.055);
const distance = Math.hypot(targetX - warden.x, targetZ - warden.z);
warden.inputForward = distance > (target ? 6 : 3) ? 0.76 : 0;
warden.inputStrafe = target ? Math.sin(tick * 0.045 + warden.id) * 0.25 : 0;
warden.sprinting = false;
warden.pitch = 0;
warden.firing = Boolean(target && distance < shotRange - 2 && angleDifference(warden.yaw, desiredYaw) < 0.13);
if (warden.magazine === 0) beginReload(warden);
}
function canSeePlayer(
state: DeadAirAuthorityState,
viewer: DeadAirAuthorityPlayer,
target: DeadAirAuthorityPlayer,
): boolean {
if (!target.alive || !viewer.alive || target.id === viewer.id) return target.id === viewer.id;
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
if (distance > 38 || !hasDeadAirLineOfSight(viewer.x, viewer.z, target.x, target.z)) return false;
if (distance <= 2.2) return true;
if (target.flashlight && distance <= 34) return true;
if (target.carryingArtifact && distance <= 15) return true;
return canSeePoint(state, viewer, target.x, target.z);
}
function canSeePoint(
state: DeadAirAuthorityState,
viewer: DeadAirAuthorityPlayer,
x: number,
z: number,
): boolean {
const distance = Math.hypot(x - viewer.x, z - viewer.z);
if (!hasDeadAirLineOfSight(viewer.x, viewer.z, x, z)) return false;
if (distance <= 2.2 || (state.powerOn && distance <= 9.5)) return true;
if (!viewer.flashlight || distance > 28) return false;
const bearing = Math.atan2(x - viewer.x, z - viewer.z);
return angleDifference(viewer.yaw, bearing) <= 0.62;
}
function perceive(
state: DeadAirAuthorityState,
event: DeadAirAuthorityEvent,
playerId: number,
): DeadAirPerception | null {
const viewer = findPlayer(state, playerId);
if (!viewer) return null;
switch (event.type) {
case "noise":
if (event.sourceId === playerId) return null;
return soundPerception(viewer, event.id, event.cue, event.x, event.z, event.loudness);
case "shot": {
const source = findPlayer(state, event.sourceId);
if (
event.sourceId === playerId ||
(source && canSeePlayer(state, viewer, source)) ||
canSeePoint(state, viewer, event.x, event.z)
) {
return { id: event.id, type: "muzzle", x: event.x, z: event.z, yaw: event.yaw, pitch: event.pitch };
}
return soundPerception(viewer, event.id, "gunshot", event.x, event.z, 52);
}
case "damage":
if (event.targetId === playerId) {
return {
id: event.id,
type: "damage",
amount: event.amount,
health: event.health,
eliminated: event.eliminated,
};
}
if (event.sourceId === playerId) {
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.eliminated };
}
return soundPerception(viewer, event.id, "impact", event.x, event.z, 13);
case "artifact": {
const actor = findPlayer(state, event.actorId);
const actorVisible = event.actorId === playerId || Boolean(actor && canSeePlayer(state, viewer, actor));
return {
id: event.id,
type: "artifact",
action: event.action,
actorId: actorVisible ? event.actorId : null,
round: event.round,
};
}
case "power":
return { id: event.id, type: "power", on: event.on };
}
}
function soundPerception(
viewer: DeadAirAuthorityPlayer,
id: number,
cue: DeadAirSoundCue,
x: number,
z: number,
loudness: number,
): DeadAirPerception | null {
const distance = Math.hypot(x - viewer.x, z - viewer.z);
if (distance > loudness) return null;
const relative = normalizeAngle(Math.atan2(x - viewer.x, z - viewer.z) - viewer.yaw);
const direction = ((Math.round(relative / (Math.PI / 4)) % 8) + 8) % 8;
const band: DeadAirDistanceBand = distance < loudness * 0.28 ? "near" : distance < loudness * 0.64 ? "mid" : "far";
return {
id,
type: "sound",
cue,
direction,
distance: band,
intensity: clamp(Math.round((1 - distance / loudness) * 3) + 1, 1, 3),
};
}
function resetRound(state: DeadAirAuthorityState): void {
state.round += 1;
state.elapsedTicks = 0;
state.lastExtractorId = null;
state.artifactX = DEAD_AIR_ARTIFACT_SPAWN.x;
state.artifactZ = DEAD_AIR_ARTIFACT_SPAWN.z;
state.artifactCarrierId = null;
state.powerOn = true;
for (const player of state.players) {
const fresh = createPlayer(player.id, player.bot, state.round);
Object.assign(player, fresh, { extractions: player.extractions });
}
}
function playerView(player: DeadAirAuthorityPlayer, own: boolean): DeadAirPlayerView {
return {
id: player.id,
bot: player.bot,
alive: player.alive,
x: player.x,
z: player.z,
velocityX: player.velocityX,
velocityZ: player.velocityZ,
yaw: player.yaw,
pitch: player.pitch,
health: own ? player.health : null,
flashlight: player.flashlight,
carryingArtifact: player.carryingArtifact,
firing: player.firing,
sprinting: player.sprinting,
cooldownTicks: player.cooldownTicks,
reloadTicks: player.reloadTicks,
respawnTicks: player.respawnTicks,
magazine: own ? player.magazine : null,
reserve: own ? player.reserve : null,
decoys: own ? player.decoys : null,
extractions: player.extractions,
inputForward: own ? player.inputForward : 0,
inputStrafe: own ? player.inputStrafe : 0,
};
}
function cloneAuthorityState(state: DeadAirAuthorityState): DeadAirAuthorityState {
return {
...state,
players: state.players.map((player) => ({
...player,
botState: player.botState ? { ...player.botState } : null,
})),
};
}
function cloneClientState(state: DeadAirClientState): DeadAirClientState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
artifact: { ...state.artifact },
events: state.events.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
})),
};
}
function findPlayer(state: DeadAirAuthorityState, id: number): DeadAirAuthorityPlayer | undefined {
return state.players.find((player) => player.id === id);
}
function angleDifference(left: number, right: number): number {
return Math.abs(normalizeAngle(left - right));
}
function rotateToward(current: number, target: number, maximum: number): number {
const difference = normalizeAngle(target - current);
return normalizeAngle(current + clamp(difference, -maximum, maximum));
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,127 @@
export const DEAD_AIR_MAP_SIZE = 72;
export const DEAD_AIR_PLAYER_RADIUS = 0.56;
export const DEAD_AIR_EXTRACTION = Object.freeze({ x: -29, z: 29, radius: 4.2 });
export const DEAD_AIR_ARTIFACT_SPAWN = Object.freeze({ x: 2, z: -1 });
export const DEAD_AIR_POWER_SWITCH = Object.freeze({ x: 29, z: -29 });
export interface DeadAirWall {
id: string;
x: number;
z: number;
width: number;
depth: number;
height: number;
}
/** Public static geometry. Secret state is never encoded into the level. */
export const DEAD_AIR_WALLS: readonly DeadAirWall[] = Object.freeze([
{ id: "north", x: 0, z: -36, width: 73, depth: 1.2, height: 4.8 },
{ id: "south", x: 0, z: 36, width: 73, depth: 1.2, height: 4.8 },
{ id: "west", x: -36, z: 0, width: 1.2, depth: 73, height: 4.8 },
{ id: "east", x: 36, z: 0, width: 1.2, depth: 73, height: 4.8 },
{ id: "loading-a", x: -27, z: -20, width: 17, depth: 1, height: 4.2 },
{ id: "loading-b", x: -5, z: -20, width: 13, depth: 1, height: 4.2 },
{ id: "loading-c", x: 21.5, z: -20, width: 25, depth: 1, height: 4.2 },
{ id: "west-a", x: -16, z: -28, width: 1, depth: 15, height: 4.2 },
{ id: "west-b", x: -16, z: -7, width: 1, depth: 21, height: 4.2 },
{ id: "west-c", x: -16, z: 27, width: 1, depth: 18, height: 4.2 },
{ id: "east-a", x: 16, z: -27, width: 1, depth: 17, height: 4.2 },
{ id: "east-b", x: 16, z: 0, width: 1, depth: 21, height: 4.2 },
{ id: "east-c", x: 16, z: 27, width: 1, depth: 17, height: 4.2 },
{ id: "office-a", x: -27, z: 15, width: 17, depth: 1, height: 4.2 },
{ id: "office-b", x: -6, z: 15, width: 11, depth: 1, height: 4.2 },
{ id: "office-c", x: 23, z: 15, width: 25, depth: 1, height: 4.2 },
{ id: "archive-a", x: -27, z: -2, width: 17, depth: 1, height: 3.8 },
{ id: "archive-b", x: 27, z: -2, width: 17, depth: 1, height: 3.8 },
{ id: "vault-north", x: 1, z: -9, width: 15, depth: 1, height: 4.4 },
{ id: "vault-south-a", x: -4, z: 7, width: 5, depth: 1, height: 4.4 },
{ id: "vault-south-b", x: 7, z: 7, width: 5, depth: 1, height: 4.4 },
]);
export const DEAD_AIR_SPAWNS = Object.freeze([
{ x: -30, z: 29, yaw: Math.PI * 0.75 },
{ x: -27, z: 31, yaw: Math.PI },
{ x: -31, z: 25, yaw: Math.PI * 0.5 },
{ x: -25, z: 27, yaw: Math.PI * 0.85 },
]);
export const DEAD_AIR_WARDEN_SPAWNS = Object.freeze([
{ x: 28, z: -28, yaw: 0 },
{ x: -27, z: -28, yaw: 0 },
{ x: 28, z: 27, yaw: Math.PI },
{ x: 3, z: -14, yaw: 0 },
{ x: 4, z: 11, yaw: Math.PI },
]);
export function collidesWithDeadAirMap(
x: number,
z: number,
radius = DEAD_AIR_PLAYER_RADIUS,
): boolean {
for (const wall of DEAD_AIR_WALLS) {
if (
Math.abs(x - wall.x) < wall.width / 2 + radius &&
Math.abs(z - wall.z) < wall.depth / 2 + radius
) {
return true;
}
}
return false;
}
export function hasDeadAirLineOfSight(
fromX: number,
fromZ: number,
toX: number,
toZ: number,
): boolean {
return !DEAD_AIR_WALLS.some((wall) =>
segmentIntersectsRectangle(fromX, fromZ, toX, toZ, wall, 0.06),
);
}
function segmentIntersectsRectangle(
fromX: number,
fromZ: number,
toX: number,
toZ: number,
wall: DeadAirWall,
padding: number,
): boolean {
const minimumX = wall.x - wall.width / 2 - padding;
const maximumX = wall.x + wall.width / 2 + padding;
const minimumZ = wall.z - wall.depth / 2 - padding;
const maximumZ = wall.z + wall.depth / 2 + padding;
const deltaX = toX - fromX;
const deltaZ = toZ - fromZ;
let near = 0;
let far = 1;
const clip = (denominator: number, numerator: number): boolean => {
if (denominator === 0) return numerator >= 0;
const ratio = numerator / denominator;
if (denominator < 0) {
if (ratio > far) return false;
if (ratio > near) near = ratio;
} else {
if (ratio < near) return false;
if (ratio < far) far = ratio;
}
return true;
};
return (
clip(-deltaX, fromX - minimumX) &&
clip(deltaX, maximumX - fromX) &&
clip(-deltaZ, fromZ - minimumZ) &&
clip(deltaZ, maximumZ - fromZ) &&
near <= far &&
far > 0.001 &&
near < 0.999
);
}

View File

@@ -0,0 +1,190 @@
export type DeadAirSoundCue =
| "footstep"
| "gunshot"
| "impact"
| "decoy"
| "curse"
| "power";
export type DeadAirDistanceBand = "near" | "mid" | "far";
export interface DeadAirInput {
forward: number;
strafe: number;
yaw: number;
pitch: number;
fire: boolean;
sprint: boolean;
reload: boolean;
interact: boolean;
toggleFlashlight: boolean;
throwDecoy: boolean;
}
export interface DeadAirBotState {
phase: number;
targetId: number | null;
lastKnownX: number;
lastKnownZ: number;
}
export interface DeadAirAuthorityPlayer {
id: number;
bot: boolean;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
health: number;
flashlight: boolean;
carryingArtifact: boolean;
firing: boolean;
sprinting: boolean;
inputForward: number;
inputStrafe: number;
cooldownTicks: number;
reloadTicks: number;
respawnTicks: number;
magazine: number;
reserve: number;
decoys: number;
extractions: number;
footstepTicks: number;
botState: DeadAirBotState | null;
}
export interface DeadAirAuthorityState {
players: DeadAirAuthorityPlayer[];
artifactX: number;
artifactZ: number;
artifactCarrierId: number | null;
powerOn: boolean;
elapsedTicks: number;
round: number;
resetTicks: number;
lastExtractorId: number | null;
nextEventId: number;
}
export interface DeadAirPlayerView {
id: number;
bot: boolean;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
health: number | null;
flashlight: boolean;
carryingArtifact: boolean;
firing: boolean;
sprinting: boolean;
cooldownTicks: number;
reloadTicks: number;
respawnTicks: number;
magazine: number | null;
reserve: number | null;
decoys: number | null;
extractions: number;
inputForward: number;
inputStrafe: number;
}
export interface DeadAirArtifactView {
visible: boolean;
x: number | null;
z: number | null;
carrierId: number | null;
}
export interface DeadAirClientState {
players: DeadAirPlayerView[];
artifact: DeadAirArtifactView;
powerOn: boolean;
elapsedTicks: number;
round: number;
resetTicks: number;
lastExtractorId: number | null;
events: DeadAirPresentationEvent[];
}
export type DeadAirAuthorityEvent =
| {
id: number;
type: "noise";
sourceId: number | null;
cue: DeadAirSoundCue;
x: number;
z: number;
loudness: number;
}
| {
id: number;
type: "shot";
sourceId: number;
x: number;
z: number;
yaw: number;
pitch: number;
}
| {
id: number;
type: "damage";
sourceId: number;
targetId: number;
amount: number;
health: number;
eliminated: boolean;
x: number;
z: number;
}
| {
id: number;
type: "artifact";
action: "grabbed" | "dropped" | "extracted";
actorId: number;
x: number;
z: number;
round: number;
}
| { id: number; type: "power"; on: boolean; actorId: number; x: number; z: number };
/** Hidden sounds deliberately contain no entity identifier or world position. */
export type DeadAirPerception =
| {
id: number;
type: "sound";
cue: DeadAirSoundCue;
direction: number;
distance: DeadAirDistanceBand;
intensity: number;
}
| { id: number; type: "muzzle"; x: number; z: number; yaw: number; pitch: number }
| { id: number; type: "damage"; amount: number; health: number; eliminated: boolean }
| { id: number; type: "hit"; amount: number; eliminated: boolean }
| {
id: number;
type: "artifact";
action: "grabbed" | "dropped" | "extracted";
actorId: number | null;
round: number;
}
| { id: number; type: "power"; on: boolean };
export interface DeadAirPresentationEvent {
receivedTick: number;
event: DeadAirPerception;
}
export interface DeadAirGameContract {
authority: DeadAirAuthorityState;
client: DeadAirClientState;
input: DeadAirInput;
authorityEvent: DeadAirAuthorityEvent;
perceptionEvent: DeadAirPerception;
}

View File

@@ -7,6 +7,9 @@ export interface ApiMessage {
}
export * from "./arena.js";
export * from "./dead-air-types.js";
export * from "./dead-air-map.js";
export * from "./dead-air-game.js";
export * from "./flux-types.js";
export * from "./flux-game.js";
export * from "./movers-types.js";

View File

@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEAD_AIR_ARTIFACT_SPAWN,
DEAD_AIR_EXTRACTION,
DEAD_AIR_SNAPSHOT_RATE,
DEAD_AIR_TICK_RATE,
DEAD_AIR_WARDEN_ID_BASE,
deadAirGame,
} from "../dist/index.js";
function input(overrides = {}) {
return {
forward: 0,
strafe: 0,
yaw: 0,
pitch: 0,
fire: false,
sprint: false,
reload: false,
interact: false,
toggleFlashlight: false,
throwDecoy: false,
...overrides,
};
}
test("DEAD AIR runs through the generic multiplayer contract", () => {
assert.equal(deadAirGame.tickRateHz, DEAD_AIR_TICK_RATE);
assert.equal(deadAirGame.snapshotRateHz, DEAD_AIR_SNAPSHOT_RATE);
const server = deadAirGame.createServer();
server.addPlayer(1);
const player = server.currentState.players.find((candidate) => candidate.id === 1);
player.x = DEAD_AIR_ARTIFACT_SPAWN.x;
player.z = DEAD_AIR_ARTIFACT_SPAWN.z;
assert.deepEqual(server.submitInput(1, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: input({ interact: true }),
}), { accepted: true });
const grabbed = server.step();
assert.equal(player.carryingArtifact, true);
assert.ok(grabbed.events.some((event) => event.type === "artifact" && event.action === "grabbed"));
player.x = DEAD_AIR_EXTRACTION.x;
player.z = DEAD_AIR_EXTRACTION.z;
const extracted = server.step();
assert.equal(player.extractions, 1);
assert.ok(extracted.events.some((event) => event.type === "artifact" && event.action === "extracted"));
});
test("an occluded warden never appears in snapshots and becomes anonymous sound", () => {
const server = deadAirGame.createServer();
server.addPlayer(1);
const viewer = server.currentState.players.find((player) => player.id === 1);
const hidden = server.currentState.players.find((player) => player.id === DEAD_AIR_WARDEN_ID_BASE);
for (const player of server.currentState.players) {
if (player.bot && player.id !== hidden.id) player.alive = false;
}
viewer.x = -25;
viewer.z = 0;
viewer.yaw = Math.PI / 2;
viewer.flashlight = true;
hidden.x = -10;
hidden.z = 0;
hidden.flashlight = false;
const snapshot = server.createSnapshot(1, 0).state;
assert.equal(snapshot.players.some((player) => player.id === hidden.id), false);
assert.equal(JSON.stringify(snapshot).includes(hidden.id.toString()), false);
const perception = server.createPerceptions(1, [{
id: 777,
type: "shot",
sourceId: hidden.id,
x: hidden.x,
z: hidden.z,
yaw: hidden.yaw,
pitch: 0,
}])[0];
assert.equal(perception.type, "sound");
assert.equal(perception.cue, "gunshot");
assert.equal("sourceId" in perception, false);
assert.equal("x" in perception, false);
assert.equal("z" in perception, false);
assert.ok(Number.isInteger(perception.direction));
});
test("private objective coordinates are redacted outside sight", () => {
const server = deadAirGame.createServer();
server.addPlayer(1);
const viewer = server.currentState.players.find((player) => player.id === 1);
viewer.x = -28;
viewer.z = 28;
viewer.yaw = Math.PI;
viewer.flashlight = false;
const snapshot = server.createSnapshot(1, 0).state;
assert.deepEqual(snapshot.artifact, {
visible: false,
x: null,
z: null,
carrierId: null,
});
assert.equal("artifactX" in snapshot, false);
assert.equal("nextEventId" in snapshot, false);
});
test("visible wardens expose pose but redact combat inventory", () => {
const server = deadAirGame.createServer();
server.addPlayer(1);
const viewer = server.currentState.players.find((player) => player.id === 1);
const visible = server.currentState.players.find((player) => player.id === DEAD_AIR_WARDEN_ID_BASE);
viewer.x = -30;
viewer.z = 30;
viewer.yaw = Math.PI / 2;
visible.x = -25;
visible.z = 30;
visible.health = 31;
visible.magazine = 2;
const replicated = server.createSnapshot(1, 0).state.players.find((player) => player.id === visible.id);
assert.ok(replicated);
assert.equal(replicated.x, visible.x);
assert.equal(replicated.health, null);
assert.equal(replicated.magazine, null);
assert.equal(replicated.reserve, null);
});