Improve netcode and simplify (#4)
- Simplify the logic wherever possible to remove overengineering and simplify the netcode - Actually improve the netcode - The new lines are mainly the package locks and tests Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
parent
5d854440ec
commit
93a7fe01ab
1 changed files with 15073 additions and 4754 deletions
302
test/frontend-regressions.test.ts
Normal file
302
test/frontend-regressions.test.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { KeyboardListener } from '../frontend/src/scripts/commands/keyboard-listener';
|
||||
import { TouchListener } from '../frontend/src/scripts/commands/touch-listener';
|
||||
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
|
||||
import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
|
||||
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage';
|
||||
|
||||
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
|
||||
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
|
||||
io: connect,
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/configuration', () => ({
|
||||
servers: ['https://first.test', 'https://second.test'],
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/sound-handler', () => ({
|
||||
Sounds: { click: 'click' },
|
||||
SoundHandler: { play: vi.fn(), playAmbient: vi.fn(), stopAmbient: vi.fn() },
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/charge-indicator', () => ({
|
||||
ChargeIndicator: { begin: vi.fn(), end: vi.fn() },
|
||||
}));
|
||||
|
||||
// Only the DOM operations exercised by the chooser and input listeners are needed.
|
||||
class Element extends EventTarget {
|
||||
public children: Element[] = [];
|
||||
public parentElement?: Element;
|
||||
public style: Record<string, string> = {};
|
||||
public className = '';
|
||||
public checked = false;
|
||||
public disabled = false;
|
||||
public onsubmit: ((event: Event) => void) | null = null;
|
||||
|
||||
public appendChild(child: Element) {
|
||||
child.remove();
|
||||
this.children.push(child);
|
||||
child.parentElement = this;
|
||||
}
|
||||
public append(...children: Element[]) {
|
||||
children.forEach((child) => this.appendChild(child));
|
||||
}
|
||||
public remove() {
|
||||
if (this.parentElement) {
|
||||
this.parentElement.children = this.parentElement.children.filter((c) => c !== this);
|
||||
this.parentElement = undefined;
|
||||
}
|
||||
}
|
||||
public querySelector() {
|
||||
return this.children[0];
|
||||
}
|
||||
public getBoundingClientRect() {
|
||||
return { left: 0, top: 0, width: 100, height: 100 };
|
||||
}
|
||||
}
|
||||
|
||||
class Socket extends EventEmitter {
|
||||
public io = new EventEmitter();
|
||||
public close = vi.fn();
|
||||
}
|
||||
|
||||
const dispatch = (target: EventTarget, type: string, fields = {}) => {
|
||||
const event = Object.assign(new Event(type, { cancelable: true }), fields);
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
const touch = (identifier: number, clientX = 50, clientY = 50) => ({
|
||||
identifier,
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
const touchEvent = (
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
changedTouches: unknown[],
|
||||
touches = changedTouches,
|
||||
) => dispatch(target, type, { changedTouches, touches });
|
||||
|
||||
let windowTarget: EventTarget;
|
||||
let sockets: Socket[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
windowTarget = new EventTarget();
|
||||
sockets = [];
|
||||
vi.stubGlobal('window', windowTarget);
|
||||
vi.stubGlobal('addEventListener', windowTarget.addEventListener.bind(windowTarget));
|
||||
vi.stubGlobal(
|
||||
'removeEventListener',
|
||||
windowTarget.removeEventListener.bind(windowTarget),
|
||||
);
|
||||
vi.stubGlobal('document', { createElement: () => new Element() });
|
||||
vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() });
|
||||
connect.mockImplementation(() => {
|
||||
const socket = new Socket();
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const chooser = () => {
|
||||
const form = new Element();
|
||||
form.appendChild(new Element());
|
||||
const container = new Element();
|
||||
const handler = new JoinFormHandler(form as never, container as never);
|
||||
return { form, container, handler };
|
||||
};
|
||||
const serverInfo = {
|
||||
serverName: 'test',
|
||||
playerCount: 1,
|
||||
playerLimit: 8,
|
||||
gameStatePercent: 20,
|
||||
};
|
||||
|
||||
describe('server chooser lifecycle', () => {
|
||||
it('aborts discovery and ignores responses completing after the chooser closes', async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
let resolveBody!: (value: unknown) => void;
|
||||
const body = new Promise((resolve) => {
|
||||
resolveBody = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url, { signal }) => {
|
||||
signals.push(signal);
|
||||
return Promise.resolve({ ok: true, json: () => body });
|
||||
}),
|
||||
);
|
||||
const { form, container, handler } = chooser();
|
||||
await Promise.resolve();
|
||||
handler.destroy();
|
||||
resolveBody(serverInfo);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
||||
expect(sockets).toHaveLength(0);
|
||||
expect(container.children).toHaveLength(0);
|
||||
expect(form.onsubmit).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('selects a remaining server when the selected connection fails and removes listeners on exit', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, json: async () => serverInfo })),
|
||||
);
|
||||
const { form, container, handler } = chooser();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(container.children).toHaveLength(2);
|
||||
expect(container.children[0].children[0].checked).toBe(true);
|
||||
expect(container.children[1].children[0].checked).toBe(false);
|
||||
|
||||
sockets[0].io.emit('reconnect_failed');
|
||||
expect(container.children).toHaveLength(1);
|
||||
expect(container.children[0].children[0].checked).toBe(true);
|
||||
expect(form.children[0].disabled).toBe(false);
|
||||
|
||||
handler.destroy();
|
||||
expect(
|
||||
sockets.every((socket) => socket.io.listenerCount('reconnect_failed') === 0),
|
||||
).toBe(true);
|
||||
expect(container.children).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movement input', () => {
|
||||
it('ignores unrelated keys, prevents browser movement defaults, and resends held keys after respawn', () => {
|
||||
const send = vi.fn();
|
||||
const listener = new KeyboardListener(send);
|
||||
dispatch(windowTarget, 'keydown', { code: 'KeyP' });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
const key = dispatch(windowTarget, 'keydown', { code: 'ArrowRight' });
|
||||
expect(key.defaultPrevented).toBe(true);
|
||||
expect(send.mock.lastCall?.[0].direction).toEqual([1, 0]);
|
||||
listener.resendMovement();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
dispatch(windowTarget, 'blur');
|
||||
expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]);
|
||||
listener.resendMovement();
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
listener.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
const touchControls = () => {
|
||||
const canvas = new Element();
|
||||
const overlay = new Element();
|
||||
const send = vi.fn();
|
||||
const game = {
|
||||
displayToWorldCoordinates: (position: number[]) => position,
|
||||
gameObjects: {
|
||||
localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 },
|
||||
},
|
||||
};
|
||||
const listener = new TouchListener(
|
||||
canvas as never,
|
||||
overlay as never,
|
||||
game as never,
|
||||
send,
|
||||
);
|
||||
listener.update();
|
||||
return { canvas, overlay, send, listener, fire: overlay.children[0] };
|
||||
};
|
||||
|
||||
describe('touch ownership', () => {
|
||||
it('keeps the first canvas gesture when a second finger lands before dragging', () => {
|
||||
const { canvas, send, listener } = touchControls();
|
||||
touchEvent(canvas, 'touchstart', [touch(1, 10, 20)]);
|
||||
touchEvent(
|
||||
canvas,
|
||||
'touchstart',
|
||||
[touch(2, 80, 90)],
|
||||
[touch(1, 10, 20), touch(2, 80, 90)],
|
||||
);
|
||||
touchEvent(canvas, 'touchend', [touch(2, 80, 90)], [touch(1, 10, 20)]);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
touchEvent(canvas, 'touchend', [touch(1, 10, 20)], []);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.lastCall?.[0].constructor.name).toBe('PrimaryActionCommand');
|
||||
listener.destroy();
|
||||
});
|
||||
|
||||
it('does not fire when a charge is cancelled or another finger releases', () => {
|
||||
const { fire, send, listener } = touchControls();
|
||||
touchEvent(fire, 'touchstart', [touch(1)]);
|
||||
touchEvent(fire, 'touchstart', [touch(2)]);
|
||||
touchEvent(fire, 'touchend', [touch(2)], [touch(1)]);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
touchEvent(fire, 'touchcancel', [touch(1)], []);
|
||||
touchEvent(fire, 'touchend', [touch(1)], []);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
touchEvent(fire, 'touchstart', [touch(3)]);
|
||||
touchEvent(fire, 'touchend', [touch(3)], []);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
listener.destroy();
|
||||
});
|
||||
|
||||
it('resends a held joystick after respawn and stops movement when focus is lost', () => {
|
||||
const { canvas, overlay, send, listener } = touchControls();
|
||||
touchEvent(canvas, 'touchstart', [touch(1)]);
|
||||
touchEvent(canvas, 'touchmove', [touch(1, 100, 50)]);
|
||||
listener.resendMovement();
|
||||
expect(send.mock.lastCall?.[0].direction[0]).toBe(1);
|
||||
expect(send.mock.lastCall?.[0].direction[1]).toBeCloseTo(0);
|
||||
dispatch(windowTarget, 'blur');
|
||||
expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]);
|
||||
expect(overlay.children).toHaveLength(2);
|
||||
touchEvent(canvas, 'touchstart', [touch(2)]);
|
||||
touchEvent(canvas, 'touchmove', [touch(2, 100, 50)]);
|
||||
listener.destroy();
|
||||
expect(overlay.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stored preferences', () => {
|
||||
it('ignores corrupt JSON and unavailable storage', () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue('{');
|
||||
expect(loadStoredValue('userName')).toBeUndefined();
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => {
|
||||
throw new Error('disabled');
|
||||
});
|
||||
vi.mocked(localStorage.setItem).mockImplementation(() => {
|
||||
throw new Error('disabled');
|
||||
});
|
||||
expect(loadStoredValue('options')).toBeUndefined();
|
||||
expect(() => saveStoredValue('userName', 'player')).not.toThrow();
|
||||
});
|
||||
|
||||
it('only loads boolean options that the game recognizes', () => {
|
||||
Object.assign(options, {
|
||||
soundsEnabled: true,
|
||||
musicEnabled: true,
|
||||
vibrationEnabled: true,
|
||||
});
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(
|
||||
JSON.stringify({
|
||||
soundsEnabled: 'false',
|
||||
musicEnabled: false,
|
||||
vibrationEnabled: null,
|
||||
extra: true,
|
||||
}),
|
||||
);
|
||||
initializeOptions({
|
||||
soundsEnabled: new Element(),
|
||||
musicEnabled: new Element(),
|
||||
vibrationEnabled: new Element(),
|
||||
} as never);
|
||||
expect(options).toEqual({
|
||||
soundsEnabled: true,
|
||||
musicEnabled: false,
|
||||
vibrationEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue