This commit is contained in:
905
packages/shared/src/dead-air-game.ts
Normal file
905
packages/shared/src/dead-air-game.ts
Normal 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));
|
||||
}
|
||||
127
packages/shared/src/dead-air-map.ts
Normal file
127
packages/shared/src/dead-air-map.ts
Normal 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
|
||||
);
|
||||
}
|
||||
190
packages/shared/src/dead-air-types.ts
Normal file
190
packages/shared/src/dead-air-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
128
packages/shared/test/dead-air.test.mjs
Normal file
128
packages/shared/test/dead-air.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user