Make spawn and movement acceptance server-authoritative

This commit is contained in:
Jage9
2026-02-24 19:52:38 -05:00
parent a588148039
commit 7488ac9f67
12 changed files with 78 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.25 R230";
window.CHGRID_WEB_VERSION = "2026.02.25 R231";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -196,6 +196,7 @@ const renderer = new CanvasRenderer(dom.canvas);
const audio = new AudioEngine();
const settings = new SettingsStore();
let worldGridSize = GRID_SIZE;
let movementTickMs = MOVE_COOLDOWN_MS;
let lastWallCollisionDirection: string | null = null;
let statusTimeout: number | null = null;
let lastFocusedElement: Element | null = null;
@@ -1122,7 +1123,7 @@ function handleMovement(): void {
if (state.mode !== 'normal') return;
if (activeTeleport) return;
const now = Date.now();
if (now - state.player.lastMoveTime < MOVE_COOLDOWN_MS) return;
if (now - state.player.lastMoveTime < movementTickMs) return;
let dx = 0;
let dy = 0;
@@ -1154,7 +1155,7 @@ function handleMovement(): void {
persistPlayerPosition();
state.player.lastMoveTime = now;
void refreshAudioSubscriptions(true);
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, MOVE_COOLDOWN_MS);
void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs);
signaling.send({ type: 'update_position', x: nextX, y: nextY });
const namesOnTile = getPeerNamesAtPosition(nextX, nextY);
@@ -1307,7 +1308,6 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise<void>),
signalingDisconnect: () => signaling.disconnect(),
onMessage: (message) => onSignalingMessage(message as IncomingMessage),
worldGridSize,
persistPlayerPosition,
peerManagerCleanupAll: () => peerManager.cleanupAll(),
radioCleanupAll: () => radioRuntime.cleanupAll(),
@@ -1346,6 +1346,9 @@ const onAppMessage = createOnMessageHandler({
setWorldGridSize: (size) => {
worldGridSize = size;
},
setMovementTickMs: (value) => {
movementTickMs = Math.max(1, value);
},
setConnecting: (value) => {
mediaSession.setConnecting(value);
updateConnectAvailability();

View File

@@ -7,6 +7,7 @@ import { type WorldItem } from '../state/gameState';
type MessageHandlerDeps = {
getWorldGridSize: () => number;
setWorldGridSize: (size: number) => void;
setMovementTickMs: (value: number) => void;
setConnecting: (value: boolean) => void;
rendererSetGridSize: (size: number) => void;
applyServerItemUiDefinitions: (defs: unknown) => boolean;
@@ -82,6 +83,9 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
deps.setWorldGridSize(message.worldConfig.gridSize);
}
if (message.worldConfig?.movementTickMs && Number.isInteger(message.worldConfig.movementTickMs) && message.worldConfig.movementTickMs > 0) {
deps.setMovementTickMs(message.worldConfig.movementTickMs);
}
deps.rendererSetGridSize(deps.getWorldGridSize());
const schemaReady = deps.applyServerItemUiDefinitions(message.uiDefinitions);
if (!schemaReady) {
@@ -92,8 +96,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.state.player.id = message.id;
deps.state.running = true;
deps.setConnecting(false);
deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.x));
deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, deps.state.player.y));
deps.state.player.x = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.x));
deps.state.player.y = Math.max(0, Math.min(deps.getWorldGridSize() - 1, message.player.y));
deps.dom.nicknameContainer.classList.add('hidden');
deps.dom.connectButton.classList.add('hidden');
deps.dom.disconnectButton.classList.remove('hidden');

View File

@@ -20,6 +20,12 @@ export const itemSchema = z.object({
export const welcomeMessageSchema = z.object({
type: z.literal('welcome'),
id: z.string(),
player: z.object({
id: z.string(),
nickname: z.string(),
x: z.number().int(),
y: z.number().int(),
}),
users: z.array(
z.object({
id: z.string(),
@@ -32,6 +38,8 @@ export const welcomeMessageSchema = z.object({
worldConfig: z
.object({
gridSize: z.number().int().positive(),
movementTickMs: z.number().int().positive().optional(),
movementMaxStepsPerTick: z.number().int().positive().optional(),
})
.optional(),
serverInfo: z

View File

@@ -31,7 +31,6 @@ export type ConnectFlowDeps = {
signalingConnect: (onMessage: (message: unknown) => Promise<void>) => Promise<void>;
signalingDisconnect: () => void;
onMessage: (message: unknown) => Promise<void>;
worldGridSize: number;
persistPlayerPosition: () => void;
peerManagerCleanupAll: () => void;
radioCleanupAll: () => void;
@@ -66,25 +65,6 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
return;
}
deps.state.player.x = Math.floor(Math.random() * deps.worldGridSize);
deps.state.player.y = Math.floor(Math.random() * deps.worldGridSize);
const storedPosition = localStorage.getItem('spatialChatPosition');
if (storedPosition) {
try {
const parsed = JSON.parse(storedPosition) as { x?: number; y?: number };
if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) {
const x = Math.floor(parsed.x as number);
const y = Math.floor(parsed.y as number);
if (x >= 0 && x < deps.worldGridSize && y >= 0 && y < deps.worldGridSize) {
deps.state.player.x = x;
deps.state.player.y = y;
}
}
} catch {
// Ignore malformed saved positions.
}
}
try {
await deps.mediaPopulateAudioDevices();
if (deps.dom.audioInputSelect.options.length === 0) {