slop game oder so
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClient } from '../../app/static/js/systems/ApiClient.js';
|
||||
|
||||
const endpoints = { highscores: '/api/highscores', runs: '/api/runs' };
|
||||
|
||||
/** Baut eine fetch-Attrappe mit fester Antwort. */
|
||||
function fakeFetch(status, body) {
|
||||
return vi.fn(async () => ({
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) throw new SyntaxError('kein JSON');
|
||||
return body;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ApiClient', () => {
|
||||
it('holt ein Run-Token', async () => {
|
||||
const fetch = fakeFetch(201, { run_token: 'abc' });
|
||||
expect(await new ApiClient(endpoints, fetch).startRun()).toBe('abc');
|
||||
expect(fetch).toHaveBeenCalledWith('/api/runs', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('liefert null statt Fehler, wenn der Server nicht erreichbar ist', async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
});
|
||||
const client = new ApiClient(endpoints, fetch);
|
||||
expect(await client.startRun()).toBeNull();
|
||||
expect(await client.fetchHighscores()).toEqual({ ok: false, highscores: [] });
|
||||
expect(await client.submitHighscore({ name: 'A', score: 1, wave: 1, durationS: 1, runToken: 't' })).toMatchObject({
|
||||
ok: false,
|
||||
error: 'network',
|
||||
});
|
||||
});
|
||||
|
||||
it('liest die Highscore-Liste', async () => {
|
||||
const entries = [{ rank: 1, name: 'Ada', score: 10, wave: 1, created_at: 'x' }];
|
||||
const result = await new ApiClient(endpoints, fakeFetch(200, { highscores: entries })).fetchHighscores();
|
||||
expect(result).toEqual({ ok: true, highscores: entries });
|
||||
});
|
||||
|
||||
it('behandelt kaputte Antworten als Fehlschlag', async () => {
|
||||
expect(await new ApiClient(endpoints, fakeFetch(200, undefined)).fetchHighscores()).toEqual({ ok: false, highscores: [] });
|
||||
expect(await new ApiClient(endpoints, fakeFetch(500, { error: 'x' })).startRun()).toBeNull();
|
||||
});
|
||||
|
||||
it('sendet Highscores im API-Format mit ganzzahligen Werten', async () => {
|
||||
const fetch = fakeFetch(201, { rank: 3, highscores: [] });
|
||||
const result = await new ApiClient(endpoints, fetch).submitHighscore({
|
||||
name: 'Ada',
|
||||
score: 1234.7,
|
||||
wave: 3,
|
||||
durationS: 95.9,
|
||||
runToken: 'tok',
|
||||
});
|
||||
expect(result).toEqual({ ok: true, rank: 3, highscores: [] });
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/highscores');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
expect(JSON.parse(init.body)).toEqual({ name: 'Ada', score: 1234, wave: 3, duration_s: 95, run_token: 'tok' });
|
||||
});
|
||||
|
||||
it('gibt Validierungs- und Rate-Limit-Fehler lesbar zurück', async () => {
|
||||
const invalid = await new ApiClient(
|
||||
endpoints,
|
||||
fakeFetch(400, { error: 'validation_failed', message: 'Ungültige Eingaben.', fields: { name: 'zu lang' } }),
|
||||
).submitHighscore({ name: 'A', score: 1, wave: 1, durationS: 1, runToken: 't' });
|
||||
expect(invalid).toMatchObject({ ok: false, error: 'validation_failed', fields: { name: 'zu lang' } });
|
||||
|
||||
const limited = await new ApiClient(endpoints, fakeFetch(429, { error: 'rate_limited', message: 'Zu viele' })).submitHighscore({
|
||||
name: 'A',
|
||||
score: 1,
|
||||
wave: 1,
|
||||
durationS: 1,
|
||||
runToken: 't',
|
||||
});
|
||||
expect(limited).toMatchObject({ ok: false, error: 'rate_limited', message: 'Zu viele' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
BARS,
|
||||
CHORD_ROOTS,
|
||||
eventsAtStep,
|
||||
midiToFrequency,
|
||||
STEPS_PER_BAR,
|
||||
TOTAL_STEPS,
|
||||
} from '../../app/static/js/logic/marchPattern.js';
|
||||
import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from '../../app/static/js/systems/AudioAssets.js';
|
||||
import { AudioManager } from '../../app/static/js/systems/AudioManager.js';
|
||||
import { SafeStorage, Settings } from '../../app/static/js/systems/Settings.js';
|
||||
import { SYNTH_RECIPES } from '../../app/static/js/systems/Synth.js';
|
||||
|
||||
const ASSET_DIR = join(import.meta.dirname, '..', '..', 'app', 'static', 'assets', AUDIO_PATH);
|
||||
|
||||
// ------------------------------------------------------------ minimaler AudioContext-Ersatz
|
||||
|
||||
class FakeParam {
|
||||
constructor(value = 1) {
|
||||
this.value = value;
|
||||
}
|
||||
setValueAtTime(v) {
|
||||
this.value = v;
|
||||
return this;
|
||||
}
|
||||
exponentialRampToValueAtTime() {
|
||||
return this;
|
||||
}
|
||||
setTargetAtTime(v) {
|
||||
this.value = v;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
connect(node) {
|
||||
return node;
|
||||
}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
class FakeContext {
|
||||
constructor() {
|
||||
this.state = 'running';
|
||||
this.currentTime = 0;
|
||||
this.sampleRate = 8000;
|
||||
this.destination = new FakeNode(this);
|
||||
this.created = { oscillator: 0, source: 0 };
|
||||
this.startedBuffers = [];
|
||||
}
|
||||
createGain() {
|
||||
return Object.assign(new FakeNode(this), { gain: new FakeParam(1) });
|
||||
}
|
||||
createOscillator() {
|
||||
this.created.oscillator += 1;
|
||||
return Object.assign(new FakeNode(this), { frequency: new FakeParam(440), type: 'sine', start() {}, stop() {} });
|
||||
}
|
||||
createBufferSource() {
|
||||
this.created.source += 1;
|
||||
const ctx = this;
|
||||
return Object.assign(new FakeNode(this), {
|
||||
playbackRate: new FakeParam(1),
|
||||
buffer: null,
|
||||
start() {
|
||||
ctx.startedBuffers.push(this.buffer);
|
||||
},
|
||||
stop() {},
|
||||
});
|
||||
}
|
||||
createBiquadFilter() {
|
||||
return Object.assign(new FakeNode(this), { frequency: new FakeParam(350), Q: new FakeParam(1), type: 'lowpass' });
|
||||
}
|
||||
createBuffer(_channels, length) {
|
||||
const data = new Float32Array(length);
|
||||
return { getChannelData: () => data };
|
||||
}
|
||||
decodeAudioData(bytes) {
|
||||
return Promise.resolve({ decodedFrom: bytes.byteLength });
|
||||
}
|
||||
resume() {
|
||||
this.state = 'running';
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function memorySettings() {
|
||||
const data = new Map();
|
||||
return new Settings(new SafeStorage({ getItem: (k) => data.get(k) ?? null, setItem: (k, v) => data.set(k, v) }));
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// ------------------------------------------------------------------------------ Tests
|
||||
|
||||
describe('AudioAssets', () => {
|
||||
it('verweist nur auf vorhandene Dateien', () => {
|
||||
const missing = allSoundFiles().filter((file) => !existsSync(join(ASSET_DIR, file)));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('hat für jeden Effekt einen synthetischen Ersatz (Spiel bleibt ohne Dateien hörbar)', () => {
|
||||
const withoutRecipe = Object.keys(SOUND_FILES).filter((name) => typeof SYNTH_RECIPES[name] !== 'function');
|
||||
expect(withoutRecipe).toEqual([]);
|
||||
});
|
||||
|
||||
it('hat für jeden Effekt Abspieloptionen mit gültiger Lautstärke', () => {
|
||||
for (const name of Object.keys(SOUND_FILES)) {
|
||||
const volume = SOUND_OPTIONS[name]?.volume ?? 1;
|
||||
expect(volume, name).toBeGreaterThan(0);
|
||||
expect(volume, name).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AudioManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('AudioContext', FakeContext);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('bleibt vor der ersten Nutzeraktion stumm, ohne Fehler', () => {
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
expect(() => audio.play('shot-carbine')).not.toThrow();
|
||||
expect(audio.context).toBeNull();
|
||||
});
|
||||
|
||||
it('lädt Dateien vorab und spielt sie nach dem Entsperren ab', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(16) }));
|
||||
const audio = new AudioManager(memorySettings(), { staticBase: '/glory/static/', fetchImpl });
|
||||
audio.preload();
|
||||
expect(fetchImpl).toHaveBeenCalledWith(`/glory/static/assets/${AUDIO_PATH}laserSmall_000.ogg`);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(allSoundFiles().length);
|
||||
|
||||
audio.unlock();
|
||||
await flush();
|
||||
await flush();
|
||||
audio.play('shot-carbine');
|
||||
expect(audio.context.startedBuffers).toHaveLength(1);
|
||||
expect(audio.context.startedBuffers[0]).toEqual({ decodedFrom: 16 });
|
||||
});
|
||||
|
||||
it('weicht auf den Synthesizer aus, wenn Dateien fehlen', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: false }));
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl });
|
||||
audio.preload();
|
||||
audio.unlock();
|
||||
await flush();
|
||||
audio.play('explosion-big');
|
||||
// Gespielt wurde nur Rauschen aus dem Synthesizer, keine dekodierte Datei.
|
||||
expect(audio.context.startedBuffers.filter((b) => b && 'decodedFrom' in b)).toHaveLength(0);
|
||||
expect(audio.context.created.oscillator).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('begrenzt schnelle Wiederholungen desselben Effekts', () => {
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
audio.unlock();
|
||||
const before = audio.context.created.oscillator;
|
||||
audio.play('combo-step');
|
||||
audio.play('combo-step');
|
||||
expect(audio.context.created.oscillator - before).toBe(1);
|
||||
});
|
||||
|
||||
it('überträgt Lautstärkeänderungen sofort auf die Kanäle', () => {
|
||||
const settings = memorySettings();
|
||||
const audio = new AudioManager(settings, { fetchImpl: vi.fn() });
|
||||
audio.unlock();
|
||||
settings.setVolume('music', 0.2);
|
||||
settings.setVolume('master', 0.6);
|
||||
expect(audio.gains.music.gain.value).toBe(0.2);
|
||||
expect(audio.gains.master.gain.value).toBe(0.6);
|
||||
});
|
||||
|
||||
it('startet vorgemerkte Musik erst nach dem Entsperren und stoppt sie wieder', () => {
|
||||
vi.useFakeTimers();
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
audio.startMusic();
|
||||
expect(audio.music).toBeNull();
|
||||
audio.unlock();
|
||||
expect(audio.music).not.toBeNull();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(audio.context.created.oscillator).toBeGreaterThan(0);
|
||||
audio.stopMusic();
|
||||
expect(audio.music).toBeNull();
|
||||
expect(audio.musicWanted).toBe(false);
|
||||
});
|
||||
|
||||
it('senkt die Musik in der Pause ab', () => {
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
audio.unlock();
|
||||
audio.duck(true);
|
||||
expect(audio.gains.duck.gain.value).toBeLessThan(1);
|
||||
audio.duck(false);
|
||||
expect(audio.gains.duck.gain.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Marschmuster', () => {
|
||||
const all = Array.from({ length: TOTAL_STEPS }, (_, step) => eventsAtStep(step));
|
||||
const count = (instrument) => all.flat().filter((e) => e.instrument === instrument).length;
|
||||
|
||||
it('hat 8 Takte à 16 Schritte', () => {
|
||||
expect(TOTAL_STEPS).toBe(BARS * STEPS_PER_BAR);
|
||||
expect(CHORD_ROOTS).toHaveLength(BARS);
|
||||
});
|
||||
|
||||
it('schlägt die große Trommel auf 1 und 3 jedes Takts', () => {
|
||||
expect(count('kick')).toBe(BARS * 2);
|
||||
for (let bar = 0; bar < BARS; bar += 1) {
|
||||
expect(all[bar * STEPS_PER_BAR].some((e) => e.instrument === 'kick')).toBe(true);
|
||||
expect(all[bar * STEPS_PER_BAR + 8].some((e) => e.instrument === 'kick')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('spielt die Melodie nur mit Tönen aus C-Dur', () => {
|
||||
const cMajor = new Set([0, 2, 4, 5, 7, 9, 11]);
|
||||
const melody = all.flat().filter((e) => e.instrument === 'lead');
|
||||
expect(melody.length).toBeGreaterThan(20);
|
||||
for (const event of melody) expect(cMajor.has(event.notes[0] % 12)).toBe(true);
|
||||
});
|
||||
|
||||
it('wiederholt sich nahtlos', () => {
|
||||
expect(eventsAtStep(TOTAL_STEPS)).toEqual(eventsAtStep(0));
|
||||
expect(eventsAtStep(-1)).toEqual(eventsAtStep(TOTAL_STEPS - 1));
|
||||
});
|
||||
|
||||
it('rechnet MIDI-Töne in Frequenzen um', () => {
|
||||
expect(midiToFrequency(69)).toBeCloseTo(440);
|
||||
expect(midiToFrequency(57)).toBeCloseTo(220);
|
||||
expect(midiToFrequency(60)).toBeCloseTo(261.63, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ORBITAL } from '../../app/static/js/config.js';
|
||||
import {
|
||||
codeToDirection,
|
||||
ComboInput,
|
||||
isPrefix,
|
||||
validateCommands,
|
||||
} from '../../app/static/js/logic/comboInput.js';
|
||||
|
||||
const COMMANDS = [
|
||||
{ id: 'strike', sequence: ['up', 'right', 'down', 'down'] },
|
||||
{ id: 'supply', sequence: ['down', 'down', 'up', 'right'] },
|
||||
{ id: 'turret', sequence: ['down', 'up', 'right', 'right', 'up'] },
|
||||
];
|
||||
|
||||
const typeAll = (combo, dirs) => dirs.map((d) => combo.push(d));
|
||||
|
||||
describe('codeToDirection', () => {
|
||||
it('bildet Pfeiltasten und WASD (physische Position) ab', () => {
|
||||
expect(codeToDirection('ArrowUp')).toBe('up');
|
||||
expect(codeToDirection('KeyW')).toBe('up');
|
||||
expect(codeToDirection('KeyA')).toBe('left');
|
||||
expect(codeToDirection('ArrowRight')).toBe('right');
|
||||
expect(codeToDirection('KeyS')).toBe('down');
|
||||
expect(codeToDirection('KeyQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrefix', () => {
|
||||
it('erkennt Anfänge von Folgen', () => {
|
||||
expect(isPrefix([], ['up'])).toBe(true);
|
||||
expect(isPrefix(['up'], ['up', 'down'])).toBe(true);
|
||||
expect(isPrefix(['down'], ['up', 'down'])).toBe(false);
|
||||
expect(isPrefix(['up', 'down', 'up'], ['up', 'down'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCommands', () => {
|
||||
it('akzeptiert die Befehle aus der Konfiguration', () => {
|
||||
expect(() => validateCommands(ORBITAL.commands)).not.toThrow();
|
||||
});
|
||||
|
||||
it('lehnt Folgen ab, die Anfang einer anderen sind', () => {
|
||||
expect(() =>
|
||||
validateCommands([
|
||||
{ id: 'a', sequence: ['up', 'down'] },
|
||||
{ id: 'b', sequence: ['up', 'down', 'left'] },
|
||||
]),
|
||||
).toThrow(/Anfang/);
|
||||
});
|
||||
|
||||
it('lehnt doppelte IDs, leere Folgen und unbekannte Richtungen ab', () => {
|
||||
expect(() => validateCommands([{ id: 'a', sequence: ['up'] }, { id: 'a', sequence: ['down'] }])).toThrow();
|
||||
expect(() => validateCommands([{ id: 'a', sequence: [] }])).toThrow();
|
||||
expect(() => validateCommands([{ id: 'a', sequence: ['oben'] }])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ComboInput', () => {
|
||||
it('ignoriert Eingaben, solange die Halte-Taste nicht gedrückt ist', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
expect(combo.push('up').status).toBe('inactive');
|
||||
});
|
||||
|
||||
it('erkennt eine vollständige Folge', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
const results = typeAll(combo, ['up', 'right', 'down', 'down']);
|
||||
expect(results.slice(0, 3).map((r) => r.status)).toEqual(['progress', 'progress', 'progress']);
|
||||
expect(results[3]).toEqual({ status: 'match', commandId: 'strike' });
|
||||
expect(combo.buffer).toEqual([]);
|
||||
});
|
||||
|
||||
it('meldet passende Kandidaten während der Eingabe', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
expect(combo.push('down')).toEqual({ status: 'progress', candidates: ['supply', 'turret'] });
|
||||
expect(combo.push('up')).toEqual({ status: 'progress', candidates: ['turret'] });
|
||||
});
|
||||
|
||||
it('meldet Fehleingaben und erlaubt direkt einen neuen Versuch', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
combo.push('up');
|
||||
expect(combo.push('up').status).toBe('fail');
|
||||
expect(combo.buffer).toEqual([]);
|
||||
const retry = typeAll(combo, ['down', 'down', 'up', 'right']);
|
||||
expect(retry.at(-1)).toEqual({ status: 'match', commandId: 'supply' });
|
||||
});
|
||||
|
||||
it('erlaubt mehrere Befehle hintereinander, solange die Taste gehalten wird', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
expect(typeAll(combo, ['up', 'right', 'down', 'down']).at(-1).commandId).toBe('strike');
|
||||
expect(typeAll(combo, ['down', 'up', 'right', 'right', 'up']).at(-1).commandId).toBe('turret');
|
||||
});
|
||||
|
||||
it('verwirft halbe Eingaben beim Loslassen', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
typeAll(combo, ['up', 'right']);
|
||||
combo.end();
|
||||
combo.begin();
|
||||
expect(combo.push('down').status).toBe('progress');
|
||||
expect(combo.buffer).toEqual(['down']);
|
||||
});
|
||||
|
||||
it('ignoriert unbekannte Richtungen ohne den Puffer zu verändern', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
combo.push('up');
|
||||
expect(combo.push('diagonal').status).toBe('ignored');
|
||||
expect(combo.buffer).toEqual(['up']);
|
||||
});
|
||||
|
||||
it('zeigt den Fortschritt je Befehl für das HUD', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
typeAll(combo, ['down', 'down']);
|
||||
expect(combo.progressFor('supply')).toBe(2);
|
||||
expect(combo.progressFor('turret')).toBe(0);
|
||||
expect(combo.progressFor('strike')).toBe(0);
|
||||
expect(combo.progressFor('unbekannt')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Plausibilitätsprüfungen der Balancing-Werte: fängt Tippfehler ab, bevor sie im
|
||||
* Spiel als seltsames Verhalten auffallen.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ENEMY_TYPES,
|
||||
FACTIONS,
|
||||
HIGHSCORE,
|
||||
ORBITAL,
|
||||
PLAYER,
|
||||
SCORING,
|
||||
WAVES,
|
||||
WEAPONS,
|
||||
} from '../../app/static/js/config.js';
|
||||
import { validateCommands } from '../../app/static/js/logic/comboInput.js';
|
||||
|
||||
/** Findet NaN, negative Zahlen und fehlende Werte in verschachtelten Objekten. */
|
||||
function assertSaneNumbers(obj, path = '') {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const here = `${path}.${key}`;
|
||||
if (typeof value === 'number') {
|
||||
expect(Number.isFinite(value), here).toBe(true);
|
||||
expect(value, here).toBeGreaterThanOrEqual(0);
|
||||
} else if (value && typeof value === 'object') {
|
||||
assertSaneNumbers(value, here);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('config', () => {
|
||||
it('enthält nur endliche, nicht-negative Zahlen', () => {
|
||||
for (const [name, section] of Object.entries({ PLAYER, WEAPONS, ENEMY_TYPES, WAVES, SCORING, ORBITAL })) {
|
||||
assertSaneNumbers(section, name);
|
||||
}
|
||||
});
|
||||
|
||||
it('hat je Fraktion genau einen kleinen, mittleren und Elite-Gegner', () => {
|
||||
for (const faction of Object.keys(FACTIONS)) {
|
||||
const tiers = Object.values(ENEMY_TYPES)
|
||||
.filter((t) => t.faction === faction)
|
||||
.map((t) => t.tier)
|
||||
.sort();
|
||||
expect(tiers, faction).toEqual(['elite', 'medium', 'small']);
|
||||
}
|
||||
});
|
||||
|
||||
it('verwendet konsistente IDs und gültige Gruppengrößen', () => {
|
||||
for (const [key, type] of Object.entries(ENEMY_TYPES)) {
|
||||
expect(type.id).toBe(key);
|
||||
expect(type.groupSize.min).toBeLessThanOrEqual(type.groupSize.max);
|
||||
expect(type.groupSize.min).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
for (const [key, weapon] of Object.entries(WEAPONS)) expect(weapon.id).toBe(key);
|
||||
});
|
||||
|
||||
it('lässt Fernkämpfer nur schießen, wenn sie in ihrem Abstandsband sind', () => {
|
||||
for (const type of Object.values(ENEMY_TYPES)) {
|
||||
if (!type.range) continue;
|
||||
expect(type.range.min, type.id).toBeLessThan(type.range.max);
|
||||
expect(type.attack.maxDistance, type.id).toBeGreaterThanOrEqual(type.range.max);
|
||||
}
|
||||
});
|
||||
|
||||
it('verweist nur auf existierende Waffen und Brut-Typen', () => {
|
||||
for (const slot of PLAYER.weaponSlots) expect(WEAPONS[slot]).toBeDefined();
|
||||
expect(ENEMY_TYPES[ENEMY_TYPES.brutmutter.brood.childType]).toBeDefined();
|
||||
});
|
||||
|
||||
it('hat mindestens drei gültige Orbital-Befehle mit Verzögerung und Abklingzeit', () => {
|
||||
expect(ORBITAL.commands.length).toBeGreaterThanOrEqual(3);
|
||||
expect(() => validateCommands(ORBITAL.commands)).not.toThrow();
|
||||
for (const command of ORBITAL.commands) {
|
||||
expect(command.delayMs, command.id).toBeGreaterThan(0);
|
||||
expect(command.cooldownMs, command.id).toBeGreaterThan(command.delayMs);
|
||||
}
|
||||
const ids = ORBITAL.commands.map((c) => c.id);
|
||||
expect(ids).toEqual(expect.arrayContaining(['strike', 'supply', 'turret']));
|
||||
});
|
||||
|
||||
it('verwendet eigene Befehlscodes statt Codes aus bekannten Spielen', () => {
|
||||
// Codes, die in einem bekannten Koop-Shooter mit ähnlicher Mechanik belegt sind
|
||||
// (vom unabhängigen Review als zu nah beanstandet). Keiner unserer Codes darf gleich sein.
|
||||
const taken = [
|
||||
['down', 'down', 'up', 'right'],
|
||||
['down', 'up', 'right', 'right', 'up'],
|
||||
['up', 'down', 'right', 'left', 'up'],
|
||||
['up', 'right', 'down', 'down', 'down'],
|
||||
['right', 'right', 'up'],
|
||||
['down', 'up', 'right', 'left'],
|
||||
].map((s) => s.join(','));
|
||||
for (const command of ORBITAL.commands) {
|
||||
expect(taken, command.id).not.toContain(command.sequence.join(','));
|
||||
}
|
||||
// Jeder Code beginnt mit einer anderen Richtung – schon der erste Tastendruck ist eindeutig.
|
||||
const starts = ORBITAL.commands.map((c) => c.sequence[0]);
|
||||
expect(new Set(starts).size).toBe(starts.length);
|
||||
});
|
||||
|
||||
it('nutzt als Halte-Taste weder Strg (Strg+W schließt den Tab) noch Shift (Einrastfunktion)', () => {
|
||||
expect(['CTRL', 'CONTROL', 'SHIFT']).not.toContain(ORBITAL.holdKey.toUpperCase());
|
||||
});
|
||||
|
||||
it('hält das Wellenbudget mindestens so hoch wie die teuerste Elite in Elite-Wellen', () => {
|
||||
const maxEliteCost = Math.max(...Object.values(ENEMY_TYPES).filter((t) => t.tier === 'elite').map((t) => t.cost));
|
||||
const firstEliteBudget = WAVES.firstBudget + WAVES.budgetGrowth * Math.pow(WAVES.eliteEvery - 1, WAVES.budgetExponent);
|
||||
expect(firstEliteBudget).toBeGreaterThan(maxEliteCost);
|
||||
});
|
||||
|
||||
it('stimmt die maximale Namenslänge mit dem Backend ab', () => {
|
||||
expect(HIGHSCORE.nameMaxLength).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CooldownTracker } from '../../app/static/js/logic/cooldowns.js';
|
||||
|
||||
describe('CooldownTracker', () => {
|
||||
it('ist für unbekannte Fähigkeiten sofort bereit', () => {
|
||||
const cd = new CooldownTracker();
|
||||
expect(cd.isReady('dodge', 0)).toBe(true);
|
||||
expect(cd.remaining('dodge', 0)).toBe(0);
|
||||
expect(cd.progress('dodge', 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('läuft nach dem Auslösen ab', () => {
|
||||
const cd = new CooldownTracker();
|
||||
cd.trigger('dodge', 1000, 5000);
|
||||
expect(cd.isReady('dodge', 5000)).toBe(false);
|
||||
expect(cd.remaining('dodge', 5250)).toBe(750);
|
||||
expect(cd.progress('dodge', 5250)).toBeCloseTo(0.25);
|
||||
expect(cd.isReady('dodge', 6000)).toBe(true);
|
||||
expect(cd.progress('dodge', 9000)).toBe(1);
|
||||
});
|
||||
|
||||
it('verwaltet mehrere Fähigkeiten unabhängig', () => {
|
||||
const cd = new CooldownTracker();
|
||||
cd.trigger('a', 100, 0);
|
||||
cd.trigger('b', 1000, 0);
|
||||
expect(cd.isReady('a', 150)).toBe(true);
|
||||
expect(cd.isReady('b', 150)).toBe(false);
|
||||
});
|
||||
|
||||
it('lässt sich zurücksetzen', () => {
|
||||
const cd = new CooldownTracker();
|
||||
cd.trigger('a', 100, 0);
|
||||
cd.trigger('b', 100, 0);
|
||||
cd.reset('a');
|
||||
expect(cd.isReady('a', 0)).toBe(true);
|
||||
expect(cd.isReady('b', 0)).toBe(false);
|
||||
cd.reset();
|
||||
expect(cd.isReady('b', 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ENEMY_TYPES } from '../../app/static/js/config.js';
|
||||
import {
|
||||
attackStep,
|
||||
broodStep,
|
||||
createAttackState,
|
||||
createBrain,
|
||||
flankVelocity,
|
||||
leaperStep,
|
||||
swarmVelocity,
|
||||
updateStrafe,
|
||||
volleyAngles,
|
||||
} from '../../app/static/js/logic/enemyAi.js';
|
||||
import { degToRad, distance, length } from '../../app/static/js/logic/math.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
|
||||
const player = { x: 0, y: 0 };
|
||||
|
||||
describe('createBrain', () => {
|
||||
it('verzögert den ersten Angriff und ist reproduzierbar', () => {
|
||||
const a = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 });
|
||||
const b = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 });
|
||||
expect(a).toEqual(b);
|
||||
expect(a.attack.nextAttackAt).toBeGreaterThanOrEqual(1400);
|
||||
expect(a.attack.nextAttackAt).toBeLessThan(2200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swarmVelocity', () => {
|
||||
const type = ENEMY_TYPES.krabbler;
|
||||
|
||||
it('steuert aus der Distanz den eigenen Umzingelungsplatz an', () => {
|
||||
const brain = { slotAngle: Math.PI / 2 }; // Platz unterhalb des Spielers
|
||||
const v = swarmVelocity(brain, { x: 800, y: 0 }, player, 100, type);
|
||||
// Ziel ist (0, encircleRadius): nach links UND nach unten.
|
||||
expect(v.x).toBeLessThan(0);
|
||||
expect(v.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('greift in Reichweite direkt an', () => {
|
||||
const v = swarmVelocity({ slotAngle: Math.PI / 2 }, { x: 100, y: 0 }, player, 100, type);
|
||||
expect(v.x).toBeCloseTo(-100);
|
||||
expect(v.y).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaperStep', () => {
|
||||
const { leap } = ENEMY_TYPES.sprungkralle;
|
||||
|
||||
it('verfolgt außerhalb der Sprungreichweite', () => {
|
||||
const brain = createBrain(createRng(1), 0);
|
||||
const r = leaperStep(brain, { x: 1000, y: 0 }, player, 0, 120, leap);
|
||||
expect(r.telegraph).toBe(false);
|
||||
expect(r.velocity.x).toBeCloseTo(-120);
|
||||
});
|
||||
|
||||
it('durchläuft Ansetzen → Sprung → Abklingzeit', () => {
|
||||
const brain = createBrain(createRng(1), 0);
|
||||
const self = { x: 200, y: 0 };
|
||||
|
||||
const windup = leaperStep(brain, self, player, 0, 120, leap);
|
||||
expect(windup).toMatchObject({ telegraph: true, leaping: false, velocity: { x: 0, y: 0 } });
|
||||
|
||||
// Spieler bewegt sich während des Ansetzens: Die Sprungrichtung bleibt fest.
|
||||
const moved = { x: 0, y: 500 };
|
||||
const jump = leaperStep(brain, self, moved, leap.windupMs, 120, leap);
|
||||
expect(jump.leaping).toBe(true);
|
||||
expect(jump.velocity.x).toBeCloseTo(-leap.speed);
|
||||
expect(jump.velocity.y).toBeCloseTo(0);
|
||||
|
||||
const landed = leaperStep(brain, self, player, leap.windupMs + leap.durationMs, 120, leap);
|
||||
expect(landed.leaping).toBe(false);
|
||||
expect(landed.telegraph).toBe(false);
|
||||
|
||||
// Während der Abklingzeit kein neuer Sprung, obwohl in Reichweite.
|
||||
const cooling = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + 10, 120, leap);
|
||||
expect(cooling.telegraph).toBe(false);
|
||||
const again = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + leap.cooldownMs, 120, leap);
|
||||
expect(again.telegraph).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flankVelocity', () => {
|
||||
it('schwenkt seitlich ein statt frontal anzulaufen', () => {
|
||||
const type = ENEMY_TYPES.blechsoldat;
|
||||
const self = { x: 600, y: 0 };
|
||||
const left = flankVelocity({ flankSide: 1 }, self, player, 80, type);
|
||||
const right = flankVelocity({ flankSide: -1 }, self, player, 80, type);
|
||||
expect(Math.sign(left.y)).not.toBe(Math.sign(right.y));
|
||||
expect(length(left)).toBeCloseTo(80);
|
||||
});
|
||||
|
||||
it('hält an der Flankenposition Abstand zum Spieler', () => {
|
||||
const type = ENEMY_TYPES.blechsoldat;
|
||||
const angle = degToRad(type.flankAngleDeg);
|
||||
// Genau an der Flankenposition relativ zu sich selbst gibt es keinen festen Punkt,
|
||||
// daher: sehr nah am Spieler → muss zurückweichen oder seitlich ausweichen.
|
||||
const self = { x: Math.cos(angle) * 100, y: Math.sin(angle) * 100 };
|
||||
const v = flankVelocity({ flankSide: 1 }, self, player, 80, type);
|
||||
expect(distance({ x: self.x + v.x, y: self.y + v.y }, player)).toBeGreaterThanOrEqual(distance(self, player) - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStrafe', () => {
|
||||
it('wechselt die Richtung nach Ablauf des Intervalls', () => {
|
||||
const brain = { strafeDirection: 1, nextStrafeSwitchAt: 100 };
|
||||
updateStrafe(brain, 50, { min: 1000, max: 1000 }, createRng(1));
|
||||
expect(brain.strafeDirection).toBe(1);
|
||||
updateStrafe(brain, 100, { min: 1000, max: 1000 }, createRng(1));
|
||||
expect(brain.strafeDirection).toBe(-1);
|
||||
expect(brain.nextStrafeSwitchAt).toBe(1100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attackStep', () => {
|
||||
const attack = { cooldownMs: 1000, burst: 3, burstIntervalMs: 100, maxDistance: 400 };
|
||||
|
||||
it('feuert nicht außerhalb der Reichweite', () => {
|
||||
const state = createAttackState(0);
|
||||
expect(attackStep(state, 0, 401, attack)).toBe(false);
|
||||
});
|
||||
|
||||
it('feuert eine Salve mit Abständen und wartet dann die Abklingzeit ab', () => {
|
||||
const state = createAttackState(0);
|
||||
const shots = [];
|
||||
for (let t = 0; t <= 1200; t += 50) {
|
||||
if (attackStep(state, t, 100, attack)) shots.push(t);
|
||||
}
|
||||
expect(shots).toEqual([0, 100, 200, 1000, 1100, 1200]);
|
||||
});
|
||||
|
||||
it('respektiert die Verzögerung des ersten Angriffs', () => {
|
||||
const state = createAttackState(500);
|
||||
expect(attackStep(state, 499, 100, attack)).toBe(false);
|
||||
expect(attackStep(state, 500, 100, attack)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('volleyAngles', () => {
|
||||
it('liefert bei einem Projektil genau die Zielrichtung', () => {
|
||||
expect(volleyAngles(1.2, 1, 40)).toEqual([1.2]);
|
||||
});
|
||||
|
||||
it('verteilt einen Fächer symmetrisch um die Zielrichtung', () => {
|
||||
const angles = volleyAngles(0, 5, 40);
|
||||
expect(angles).toHaveLength(5);
|
||||
expect(angles[0]).toBeCloseTo(-degToRad(20));
|
||||
expect(angles[2]).toBeCloseTo(0);
|
||||
expect(angles[4]).toBeCloseTo(degToRad(20));
|
||||
});
|
||||
});
|
||||
|
||||
describe('broodStep', () => {
|
||||
const brood = { intervalMs: 1000, spawnCount: 3, maxChildren: 5 };
|
||||
|
||||
it('brütet im Takt und respektiert die Obergrenze', () => {
|
||||
const brain = { nextBroodAt: 0 };
|
||||
expect(broodStep(brain, 0, 0, brood)).toBe(3);
|
||||
expect(broodStep(brain, 500, 3, brood)).toBe(0);
|
||||
expect(broodStep(brain, 1000, 3, brood)).toBe(2);
|
||||
expect(broodStep(brain, 2000, 5, brood)).toBe(0);
|
||||
expect(broodStep(brain, 3000, 9, brood)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatCooldown,
|
||||
formatDuration,
|
||||
formatMultiplier,
|
||||
formatScore,
|
||||
formatSequence,
|
||||
sanitizeName,
|
||||
validateName,
|
||||
} from '../../app/static/js/logic/format.js';
|
||||
|
||||
describe('Formatierung', () => {
|
||||
it('formatiert Punkte mit Tausenderpunkt', () => {
|
||||
expect(formatScore(0)).toBe('0');
|
||||
expect(formatScore(1234567)).toBe('1.234.567');
|
||||
expect(formatScore(-5)).toBe('0');
|
||||
});
|
||||
|
||||
it('formatiert Dauer als m:ss', () => {
|
||||
expect(formatDuration(0)).toBe('0:00');
|
||||
expect(formatDuration(65_400)).toBe('1:05');
|
||||
expect(formatDuration(3_600_000)).toBe('60:00');
|
||||
});
|
||||
|
||||
it('formatiert Richtungsfolgen als Pfeile', () => {
|
||||
expect(formatSequence(['up', 'right', 'down', 'left'])).toBe('↑ → ↓ ←');
|
||||
expect(formatSequence(['left', 'left'], ' ')).toBe('← ←');
|
||||
expect(formatSequence(['quer'])).toBe('?');
|
||||
});
|
||||
|
||||
it('formatiert Multiplikator und Abklingzeit mit Dezimalkomma', () => {
|
||||
expect(formatMultiplier(1)).toBe('×1');
|
||||
expect(formatMultiplier(2.5)).toBe('×2,5');
|
||||
expect(formatCooldown(0)).toBe('');
|
||||
expect(formatCooldown(12_100)).toBe('13');
|
||||
expect(formatCooldown(800)).toBe('0,8');
|
||||
});
|
||||
});
|
||||
|
||||
// Dieselben Fälle wie tests/test_validation.py – Frontend und Backend müssen gleich bereinigen.
|
||||
describe('sanitizeName (gleich wie Backend)', () => {
|
||||
const ZWSP = String.fromCodePoint(0x200b);
|
||||
const RLO = String.fromCodePoint(0x202e);
|
||||
const cases = [
|
||||
['Ada', 'Ada'],
|
||||
[' Max Muster ', 'Max Muster'],
|
||||
['Jürg Größli', 'Jürg Größli'],
|
||||
['Fullwidth', 'Fullwidth'],
|
||||
['Null\u0000Byte', 'NullByte'],
|
||||
[`Zero${ZWSP}Width`, 'ZeroWidth'],
|
||||
[`Bidi${RLO}Trick`, 'BidiTrick'],
|
||||
['Tab\tNeu\nZeile', 'TabNeuZeile'],
|
||||
['<script>alert(1)</script>', 'scriptalert1script'],
|
||||
["O'Neil-Test!?", "O'Neil-Test!?"],
|
||||
['Emoji 🚀 Held', 'Emoji Held'],
|
||||
['', ''],
|
||||
];
|
||||
it.each(cases)('%j → %j', (raw, expected) => {
|
||||
expect(sanitizeName(raw)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateName', () => {
|
||||
it('akzeptiert gültige Namen bis 16 Zeichen', () => {
|
||||
expect(validateName(' Kadettin Ada ')).toEqual({ ok: true, name: 'Kadettin Ada' });
|
||||
expect(validateName('N'.repeat(16)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt leere und zu lange Namen ab', () => {
|
||||
expect(validateName(' ').ok).toBe(false);
|
||||
expect(validateName('<>').ok).toBe(false);
|
||||
expect(validateName('N'.repeat(17)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { oncePerEvent } from '../../app/static/js/systems/keyboard.js';
|
||||
|
||||
describe('oncePerEvent', () => {
|
||||
it('lässt dasselbe Ereignisobjekt nur einmal durch', () => {
|
||||
const handler = vi.fn();
|
||||
const wrapped = oncePerEvent(handler);
|
||||
const first = { code: 'ArrowDown' };
|
||||
const second = { code: 'ArrowDown' };
|
||||
// Phaser meldet bei mehreren Ereignissen pro Frame frühere Ereignisse erneut.
|
||||
wrapped(first);
|
||||
wrapped(first);
|
||||
wrapped(second);
|
||||
wrapped(first);
|
||||
wrapped(second);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler.mock.calls.map(([event]) => event)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('funktioniert auch ohne Ereignisobjekt (z. B. Zeiger-Aufrufe)', () => {
|
||||
const handler = vi.fn();
|
||||
const wrapped = oncePerEvent(handler);
|
||||
wrapped(undefined);
|
||||
wrapped(undefined);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
angleTo,
|
||||
clamp,
|
||||
clampLength,
|
||||
directionFromKeys,
|
||||
distance,
|
||||
inCircle,
|
||||
length,
|
||||
normalize,
|
||||
rotate,
|
||||
} from '../../app/static/js/logic/math.js';
|
||||
import { createRng, pick, randFloat, randInt } from '../../app/static/js/logic/rng.js';
|
||||
|
||||
describe('math', () => {
|
||||
it('clamp begrenzt Werte', () => {
|
||||
expect(clamp(5, 0, 3)).toBe(3);
|
||||
expect(clamp(-1, 0, 3)).toBe(0);
|
||||
expect(clamp(2, 0, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('normalize lässt den Nullvektor unverändert', () => {
|
||||
expect(normalize({ x: 0, y: 0 })).toEqual({ x: 0, y: 0 });
|
||||
expect(length(normalize({ x: 3, y: 4 }))).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('clampLength kürzt nur zu lange Vektoren', () => {
|
||||
expect(length(clampLength({ x: 30, y: 40 }, 10))).toBeCloseTo(10);
|
||||
expect(clampLength({ x: 1, y: 1 }, 10)).toEqual({ x: 1, y: 1 });
|
||||
});
|
||||
|
||||
it('rotate und angleTo passen zusammen', () => {
|
||||
const r = rotate({ x: 1, y: 0 }, Math.PI / 2);
|
||||
expect(r.x).toBeCloseTo(0);
|
||||
expect(r.y).toBeCloseTo(1);
|
||||
expect(angleTo({ x: 0, y: 0 }, { x: 0, y: 5 })).toBeCloseTo(Math.PI / 2);
|
||||
});
|
||||
|
||||
it('inCircle berücksichtigt die Ausdehnung des Objekts', () => {
|
||||
expect(inCircle({ x: 110, y: 0 }, { x: 0, y: 0 }, 100)).toBe(false);
|
||||
expect(inCircle({ x: 110, y: 0 }, { x: 0, y: 0 }, 100, 12)).toBe(true);
|
||||
expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
|
||||
});
|
||||
|
||||
it('directionFromKeys normalisiert Diagonalen (8-Wege-Bewegung)', () => {
|
||||
expect(directionFromKeys({ up: true, down: false, left: false, right: false })).toEqual({ x: 0, y: -1 });
|
||||
const diag = directionFromKeys({ up: true, down: false, left: false, right: true });
|
||||
expect(length(diag)).toBeCloseTo(1);
|
||||
expect(diag.x).toBeGreaterThan(0);
|
||||
expect(diag.y).toBeLessThan(0);
|
||||
expect(directionFromKeys({ up: true, down: true, left: true, right: true })).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rng', () => {
|
||||
it('ist mit gleichem Seed deterministisch und liegt in [0, 1)', () => {
|
||||
const a = createRng(123);
|
||||
const b = createRng(123);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const value = a();
|
||||
expect(value).toBe(b());
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('randInt deckt die Grenzen ab und bleibt im Intervall', () => {
|
||||
const rng = createRng(7);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const n = randInt(rng, 1, 3);
|
||||
expect(n).toBeGreaterThanOrEqual(1);
|
||||
expect(n).toBeLessThanOrEqual(3);
|
||||
seen.add(n);
|
||||
}
|
||||
expect(seen).toEqual(new Set([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('randFloat und pick verhalten sich korrekt', () => {
|
||||
const rng = createRng(9);
|
||||
const f = randFloat(rng, 5, 6);
|
||||
expect(f).toBeGreaterThanOrEqual(5);
|
||||
expect(f).toBeLessThan(6);
|
||||
expect(['a', 'b']).toContain(pick(rng, ['a', 'b']));
|
||||
expect(() => pick(rng, [])).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SCORING } from '../../app/static/js/config.js';
|
||||
import { multiplierForStreak, ScoreKeeper, waveBonus } from '../../app/static/js/logic/scoring.js';
|
||||
|
||||
describe('multiplierForStreak', () => {
|
||||
it('steigt alle stepKills Abschüsse um stepValue', () => {
|
||||
expect(multiplierForStreak(0)).toBe(1);
|
||||
expect(multiplierForStreak(1)).toBe(1);
|
||||
expect(multiplierForStreak(SCORING.stepKills - 1)).toBe(1);
|
||||
expect(multiplierForStreak(SCORING.stepKills)).toBe(1 + SCORING.stepValue);
|
||||
expect(multiplierForStreak(SCORING.stepKills * 2)).toBe(1 + 2 * SCORING.stepValue);
|
||||
});
|
||||
|
||||
it('ist nach oben gedeckelt', () => {
|
||||
expect(multiplierForStreak(10_000)).toBe(SCORING.maxMultiplier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveBonus', () => {
|
||||
it('wächst linear mit der Welle', () => {
|
||||
expect(waveBonus(1)).toBe(SCORING.waveBonusPerWave);
|
||||
expect(waveBonus(7)).toBe(7 * SCORING.waveBonusPerWave);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScoreKeeper', () => {
|
||||
const cfg = { ...SCORING, streakWindowMs: 1000, stepKills: 2, stepValue: 1, maxMultiplier: 3 };
|
||||
|
||||
it('zählt Abschüsse ohne Serie mit Faktor 1', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
expect(keeper.registerKill(10, 0)).toEqual({ awarded: 10, multiplier: 1, streak: 1 });
|
||||
expect(keeper.registerKill(10, 5000)).toEqual({ awarded: 10, multiplier: 1, streak: 1 });
|
||||
expect(keeper.score).toBe(20);
|
||||
expect(keeper.kills).toBe(2);
|
||||
});
|
||||
|
||||
it('baut innerhalb des Fensters eine Serie mit Multiplikator auf', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
const results = [0, 500, 900, 1400, 1800].map((t) => keeper.registerKill(10, t));
|
||||
expect(results.map((r) => r.multiplier)).toEqual([1, 2, 2, 3, 3]);
|
||||
expect(keeper.score).toBe(10 + 20 + 20 + 30 + 30);
|
||||
expect(keeper.bestStreak).toBe(5);
|
||||
});
|
||||
|
||||
it('lässt die Serie nach Ablauf des Fensters verfallen', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
keeper.registerKill(10, 100);
|
||||
expect(keeper.multiplierAt(500)).toBe(2);
|
||||
expect(keeper.multiplierAt(1200)).toBe(1);
|
||||
expect(keeper.registerKill(10, 1200).streak).toBe(1);
|
||||
expect(keeper.bestStreak).toBe(2);
|
||||
});
|
||||
|
||||
it('beendet die Serie bei Treffern am Präsidenten', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
keeper.registerKill(10, 100);
|
||||
keeper.breakStreak();
|
||||
expect(keeper.multiplierAt(200)).toBe(1);
|
||||
expect(keeper.registerKill(10, 200)).toEqual({ awarded: 10, multiplier: 1, streak: 1 });
|
||||
});
|
||||
|
||||
it('vergibt Bonuspunkte ohne Multiplikator und ignoriert negative Werte', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
keeper.registerKill(10, 10);
|
||||
expect(keeper.addBonus(100)).toBe(100);
|
||||
expect(keeper.addBonus(-50)).toBe(0);
|
||||
expect(keeper.score).toBe(10 + 20 + 100);
|
||||
});
|
||||
|
||||
it('liefert eine Momentaufnahme mit Restzeit der Serie', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
const snap = keeper.snapshot(250);
|
||||
expect(snap).toMatchObject({ score: 10, kills: 1, streak: 1, multiplier: 1, bestStreak: 1 });
|
||||
expect(snap.streakTimeLeft).toBeCloseTo(0.75);
|
||||
expect(keeper.snapshot(5000)).toMatchObject({ streak: 0, streakTimeLeft: 0 });
|
||||
});
|
||||
|
||||
it('rundet Punkte bei gebrochenen Multiplikatoren', () => {
|
||||
const keeper = new ScoreKeeper({ ...cfg, stepValue: 0.5 });
|
||||
keeper.registerKill(7, 0);
|
||||
expect(keeper.registerKill(7, 10).awarded).toBe(Math.round(7 * 1.5));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AUDIO, STORAGE_KEYS } from '../../app/static/js/config.js';
|
||||
import { SafeStorage, Settings } from '../../app/static/js/systems/Settings.js';
|
||||
|
||||
/** Einfacher localStorage-Ersatz. */
|
||||
function memoryBackend(initial = {}) {
|
||||
const data = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => (data.has(key) ? data.get(key) : null),
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/** localStorage, der jeden Zugriff verweigert (z. B. blockierte Website-Daten). */
|
||||
const throwingBackend = {
|
||||
getItem: () => {
|
||||
throw new Error('SecurityError');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('QuotaExceededError');
|
||||
},
|
||||
};
|
||||
|
||||
describe('SafeStorage', () => {
|
||||
it('speichert JSON-Werte', () => {
|
||||
const storage = new SafeStorage(memoryBackend());
|
||||
expect(storage.set('a', { x: 1 })).toBe(true);
|
||||
expect(storage.get('a', null)).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it('liefert den Standardwert bei fehlenden oder kaputten Einträgen', () => {
|
||||
const storage = new SafeStorage(memoryBackend({ kaputt: '{nicht json' }));
|
||||
expect(storage.get('fehlt', 42)).toBe(42);
|
||||
expect(storage.get('kaputt', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('funktioniert ohne localStorage für die laufende Sitzung weiter', () => {
|
||||
for (const backend of [null, throwingBackend]) {
|
||||
const storage = new SafeStorage(backend);
|
||||
expect(storage.set('k', 'v')).toBe(false);
|
||||
expect(storage.get('k', null)).toBe('v');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('merkt sich die Disclaimer-Bestätigung', () => {
|
||||
const backend = memoryBackend();
|
||||
const settings = new Settings(new SafeStorage(backend));
|
||||
expect(settings.isDisclaimerAccepted()).toBe(false);
|
||||
settings.acceptDisclaimer();
|
||||
expect(new Settings(new SafeStorage(backend)).isDisclaimerAccepted()).toBe(true);
|
||||
expect(backend.data.get(STORAGE_KEYS.disclaimerAccepted)).toBe('true');
|
||||
});
|
||||
|
||||
it('startet mit Standardlautstärken und begrenzt Werte auf 0..1', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
expect(settings.getVolume('music')).toBe(AUDIO.defaultVolumes.music);
|
||||
settings.setVolume('music', 5);
|
||||
expect(settings.getVolume('music')).toBe(1);
|
||||
settings.setVolume('sfx', -1);
|
||||
expect(settings.getVolume('sfx')).toBe(0);
|
||||
settings.setVolume('sfx', Number.NaN);
|
||||
expect(settings.getVolume('sfx')).toBe(0);
|
||||
});
|
||||
|
||||
it('ignoriert ungültige gespeicherte Lautstärken', () => {
|
||||
const backend = memoryBackend({ [STORAGE_KEYS.volumes]: JSON.stringify({ master: 'laut', music: 0.25, sfx: 9 }) });
|
||||
const settings = new Settings(new SafeStorage(backend));
|
||||
expect(settings.getVolume('master')).toBe(AUDIO.defaultVolumes.master);
|
||||
expect(settings.getVolume('music')).toBe(0.25);
|
||||
expect(settings.getVolume('sfx')).toBe(1);
|
||||
});
|
||||
|
||||
it('berechnet die effektive Lautstärke inklusive Gesamtregler', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
settings.setVolume('master', 0.5);
|
||||
settings.setVolume('sfx', 0.5);
|
||||
expect(settings.effectiveVolume('sfx')).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('benachrichtigt Zuhörer bei Lautstärkeänderungen', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = settings.onVolumeChange(listener);
|
||||
settings.setVolume('master', 0.3);
|
||||
expect(listener).toHaveBeenCalledWith('master', 0.3);
|
||||
unsubscribe();
|
||||
settings.setVolume('master', 0.4);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('speichert den letzten Namen gekürzt', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
settings.setLastName('A'.repeat(40));
|
||||
expect(settings.getLastName()).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { distance, length } from '../../app/static/js/logic/math.js';
|
||||
import {
|
||||
combine,
|
||||
encirclePoint,
|
||||
flankPoint,
|
||||
flee,
|
||||
keepDistance,
|
||||
seek,
|
||||
separation,
|
||||
steerTowards,
|
||||
} from '../../app/static/js/logic/steering.js';
|
||||
|
||||
const close = (actual, expected) => {
|
||||
expect(actual.x).toBeCloseTo(expected.x);
|
||||
expect(actual.y).toBeCloseTo(expected.y);
|
||||
};
|
||||
|
||||
describe('seek / flee', () => {
|
||||
it('bewegt sich mit voller Geschwindigkeit zum Ziel bzw. davon weg', () => {
|
||||
close(seek({ x: 0, y: 0 }, { x: 10, y: 0 }, 5), { x: 5, y: 0 });
|
||||
close(flee({ x: 0, y: 0 }, { x: 0, y: 10 }, 3), { x: 0, y: -3 });
|
||||
});
|
||||
|
||||
it('steht still, wenn es bereits am Ziel ist', () => {
|
||||
expect(seek({ x: 4, y: 4 }, { x: 4, y: 4 }, 100)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('separation', () => {
|
||||
it('stößt nahe Nachbarn ab und ignoriert entfernte', () => {
|
||||
const self = { x: 0, y: 0 };
|
||||
const push = separation(self, [self, { x: 10, y: 0 }, { x: 500, y: 0 }], 20);
|
||||
expect(push.x).toBeLessThan(0);
|
||||
expect(push.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('trennt exakt übereinanderliegende Gegner ohne NaN', () => {
|
||||
const push = separation({ x: 5, y: 5 }, [{ x: 5, y: 5 }], 20);
|
||||
expect(Number.isFinite(push.x) && Number.isFinite(push.y)).toBe(true);
|
||||
expect(length(push)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('drückt stärker, je näher der Nachbar ist', () => {
|
||||
const near = separation({ x: 0, y: 0 }, [{ x: 2, y: 0 }], 20);
|
||||
const far = separation({ x: 0, y: 0 }, [{ x: 18, y: 0 }], 20);
|
||||
expect(length(near)).toBeGreaterThan(length(far));
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepDistance', () => {
|
||||
const band = { min: 200, max: 300 };
|
||||
const target = { x: 0, y: 0 };
|
||||
|
||||
it('nähert sich an, wenn es zu weit weg ist', () => {
|
||||
const v = keepDistance({ x: 500, y: 0 }, target, band, 10, 1);
|
||||
close(v, { x: -10, y: 0 });
|
||||
});
|
||||
|
||||
it('weicht zurück, wenn es zu nah ist', () => {
|
||||
const v = keepDistance({ x: 100, y: 0 }, target, band, 10, 1);
|
||||
close(v, { x: 10, y: 0 });
|
||||
});
|
||||
|
||||
it('weicht im Wohlfühlbereich seitlich aus (senkrecht zur Linie)', () => {
|
||||
const cw = keepDistance({ x: 250, y: 0 }, target, band, 10, 1, 0.5);
|
||||
const ccw = keepDistance({ x: 250, y: 0 }, target, band, 10, -1, 0.5);
|
||||
expect(cw.x).toBeCloseTo(0);
|
||||
expect(Math.abs(cw.y)).toBeCloseTo(5);
|
||||
expect(ccw.y).toBeCloseTo(-cw.y);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flankPoint / encirclePoint', () => {
|
||||
it('liegt im gewünschten Abstand seitlich versetzt', () => {
|
||||
const target = { x: 0, y: 0 };
|
||||
const enemy = { x: 100, y: 0 };
|
||||
const p = flankPoint(target, enemy, Math.PI / 2, 50);
|
||||
expect(distance(p, target)).toBeCloseTo(50);
|
||||
close(p, { x: 0, y: 50 });
|
||||
const other = flankPoint(target, enemy, -Math.PI / 2, 50);
|
||||
close(other, { x: 0, y: -50 });
|
||||
});
|
||||
|
||||
it('verteilt Umzingelungsplätze auf einem Kreis', () => {
|
||||
const target = { x: 10, y: 10 };
|
||||
close(encirclePoint(target, 0, 100), { x: 110, y: 10 });
|
||||
close(encirclePoint(target, Math.PI, 100), { x: -90, y: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('combine / steerTowards', () => {
|
||||
it('gewichtet Anteile und begrenzt auf die Höchstgeschwindigkeit', () => {
|
||||
const v = combine(
|
||||
[
|
||||
{ vector: { x: 10, y: 0 }, weight: 1 },
|
||||
{ vector: { x: 0, y: 10 }, weight: 2 },
|
||||
],
|
||||
15,
|
||||
);
|
||||
expect(length(v)).toBeCloseTo(15);
|
||||
expect(v.y).toBeGreaterThan(v.x);
|
||||
});
|
||||
|
||||
it('nähert sich der Wunschgeschwindigkeit schrittweise an', () => {
|
||||
close(steerTowards({ x: 0, y: 0 }, { x: 10, y: -10 }, 0.25), { x: 2.5, y: -2.5 });
|
||||
close(steerTowards({ x: 3, y: 3 }, { x: 10, y: -10 }, 1), { x: 10, y: -10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WAVES } from '../../app/static/js/config.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
import { WaveDirector } from '../../app/static/js/logic/waveDirector.js';
|
||||
|
||||
/** Minimaler Spielersatz: zählt gespawnte Gegner und lässt sie auf Befehl sterben. */
|
||||
function harness(cfgOverrides = {}) {
|
||||
const log = { spawned: 0, started: [], cleared: [] };
|
||||
let alive = 0;
|
||||
const director = new WaveDirector({
|
||||
rng: createRng(1),
|
||||
cfg: { ...WAVES, ...cfgOverrides },
|
||||
spawnGroup: (group) => {
|
||||
log.spawned += group.count;
|
||||
alive += group.count;
|
||||
},
|
||||
aliveCount: () => alive,
|
||||
onWaveStart: (plan) => log.started.push(plan.number),
|
||||
onWaveCleared: (n) => log.cleared.push(n),
|
||||
});
|
||||
return {
|
||||
director,
|
||||
log,
|
||||
killAll: () => {
|
||||
alive = 0;
|
||||
},
|
||||
get alive() {
|
||||
return alive;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Lässt die Zeit in 50-ms-Schritten laufen. */
|
||||
function run(director, from, until, step = 50) {
|
||||
let now = from;
|
||||
while (now < until) {
|
||||
now += step;
|
||||
director.update(now, step);
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
describe('WaveDirector', () => {
|
||||
it('startet die erste Welle nach der Startverzögerung', () => {
|
||||
const { director, log } = harness();
|
||||
director.start(0);
|
||||
expect(director.state).toBe('intermission');
|
||||
run(director, 0, WAVES.firstWaveDelayMs - 50);
|
||||
expect(log.started).toEqual([]);
|
||||
run(director, WAVES.firstWaveDelayMs - 50, WAVES.firstWaveDelayMs);
|
||||
expect(log.started).toEqual([1]);
|
||||
expect(director.state).toBe('active');
|
||||
});
|
||||
|
||||
it('spawnt alle Gruppen der Welle nach Plan', () => {
|
||||
const { director, log } = harness();
|
||||
director.start(0);
|
||||
const now = run(director, 0, WAVES.firstWaveDelayMs);
|
||||
const planned = director.plan.totalEnemies;
|
||||
run(director, now, now + 60_000);
|
||||
expect(log.spawned).toBe(planned);
|
||||
expect(director.pendingEnemies).toBe(0);
|
||||
});
|
||||
|
||||
it('meldet eine Welle erst als geschafft, wenn alle Gegner besiegt sind', () => {
|
||||
const h = harness();
|
||||
h.director.start(0);
|
||||
let now = run(h.director, 0, 60_000);
|
||||
expect(h.log.cleared).toEqual([]);
|
||||
h.killAll();
|
||||
now = run(h.director, now, now + 100);
|
||||
expect(h.log.cleared).toEqual([1]);
|
||||
expect(h.director.state).toBe('intermission');
|
||||
expect(h.director.intermissionRemaining(now)).toBeGreaterThan(WAVES.intermissionMs - 200);
|
||||
});
|
||||
|
||||
it('startet nach der Pause die nächste, größere Welle', () => {
|
||||
const h = harness();
|
||||
h.director.start(0);
|
||||
let now = run(h.director, 0, 60_000);
|
||||
const firstSize = h.director.plan.totalEnemies;
|
||||
h.killAll();
|
||||
now = run(h.director, now, now + WAVES.intermissionMs + 200);
|
||||
expect(h.log.started).toEqual([1, 2]);
|
||||
run(h.director, now, now + 60_000);
|
||||
expect(h.director.plan.totalEnemies).toBeGreaterThan(firstSize);
|
||||
});
|
||||
|
||||
it('hält den Spawn-Ablauf an, solange zu viele Gegner leben', () => {
|
||||
const h = harness({ maxAliveEnemies: 3 });
|
||||
h.director.start(0);
|
||||
// Wellenstart plus ein Frame: Die erste Gruppe (≥4 Krabbler) überschreitet das Limit sofort.
|
||||
let now = run(h.director, 0, WAVES.firstWaveDelayMs + 50);
|
||||
const spawnedBefore = h.log.spawned;
|
||||
expect(h.alive).toBeGreaterThanOrEqual(3);
|
||||
now = run(h.director, now, now + 60_000);
|
||||
expect(h.log.spawned).toBe(spawnedBefore);
|
||||
h.killAll();
|
||||
run(h.director, now, now + 60_000);
|
||||
expect(h.log.spawned).toBeGreaterThan(spawnedBefore);
|
||||
});
|
||||
|
||||
it('tut vor dem Start nichts', () => {
|
||||
const { director, log } = harness();
|
||||
run(director, 0, 10_000);
|
||||
expect(log.started).toEqual([]);
|
||||
expect(director.pendingEnemies).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ENEMY_TYPES, WAVES } from '../../app/static/js/config.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
import {
|
||||
EDGES,
|
||||
eliteCount,
|
||||
generateWave,
|
||||
spawnPointOnEdge,
|
||||
waveBudget,
|
||||
waveFactions,
|
||||
waveMultipliers,
|
||||
} from '../../app/static/js/logic/waves.js';
|
||||
|
||||
const cost = (plan) => plan.groups.reduce((sum, g) => sum + g.count * ENEMY_TYPES[g.type].cost, 0);
|
||||
const factionsIn = (plan) => new Set(plan.groups.map((g) => ENEMY_TYPES[g.type].faction));
|
||||
const tiersIn = (plan) => plan.groups.map((g) => ENEMY_TYPES[g.type].tier);
|
||||
|
||||
describe('waveBudget', () => {
|
||||
it('beginnt mit dem Startbudget und wächst streng monoton', () => {
|
||||
expect(waveBudget(1)).toBe(WAVES.firstBudget);
|
||||
for (let n = 1; n < 50; n += 1) {
|
||||
expect(waveBudget(n + 1)).toBeGreaterThan(waveBudget(n));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveFactions', () => {
|
||||
it('führt die Fraktionen nacheinander ein', () => {
|
||||
expect(waveFactions(1)).toEqual(['swarm']);
|
||||
expect(waveFactions(2)).toEqual(['swarm']);
|
||||
expect(waveFactions(3)).toEqual(['legion']);
|
||||
expect(waveFactions(4)).toEqual(['legion']);
|
||||
expect(waveFactions(5)).toEqual(['swarm', 'legion']);
|
||||
expect(waveFactions(30)).toEqual(['swarm', 'legion']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eliteCount', () => {
|
||||
it('bringt Eliten nur in jeder n-ten Welle und begrenzt ihre Zahl', () => {
|
||||
expect(eliteCount(4)).toBe(0);
|
||||
expect(eliteCount(5)).toBe(1);
|
||||
expect(eliteCount(7)).toBe(0);
|
||||
expect(eliteCount(10)).toBe(2);
|
||||
expect(eliteCount(WAVES.eliteEvery * 50)).toBe(WAVES.maxElitesPerWave);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveMultipliers', () => {
|
||||
it('lässt frühe Wellen unverändert', () => {
|
||||
expect(waveMultipliers(1)).toEqual({ hp: 1, speed: 1 });
|
||||
expect(waveMultipliers(WAVES.scalingFromWave - 1)).toEqual({ hp: 1, speed: 1 });
|
||||
});
|
||||
|
||||
it('macht spätere Wellen zäher, deckelt aber das Tempo', () => {
|
||||
expect(waveMultipliers(WAVES.scalingFromWave).hp).toBeGreaterThan(1);
|
||||
expect(waveMultipliers(20).hp).toBeGreaterThan(waveMultipliers(10).hp);
|
||||
expect(waveMultipliers(500).speed).toBe(WAVES.speedScaleMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWave', () => {
|
||||
it('ist mit gleichem Seed reproduzierbar', () => {
|
||||
expect(generateWave(7, createRng(42))).toEqual(generateWave(7, createRng(42)));
|
||||
});
|
||||
|
||||
it('liefert mit anderem Seed eine andere Welle', () => {
|
||||
expect(generateWave(7, createRng(1))).not.toEqual(generateWave(7, createRng(2)));
|
||||
});
|
||||
|
||||
it('wirft bei ungültiger Wellennummer', () => {
|
||||
expect(() => generateWave(0, createRng(1))).toThrow(RangeError);
|
||||
expect(() => generateWave(1.5, createRng(1))).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('schickt in Welle 1–2 nur Schwärmer und in Welle 3–4 nur die Blechlegion', () => {
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
expect(factionsIn(generateWave(1, createRng(seed)))).toEqual(new Set(['swarm']));
|
||||
expect(factionsIn(generateWave(2, createRng(seed)))).toEqual(new Set(['swarm']));
|
||||
expect(factionsIn(generateWave(3, createRng(seed)))).toEqual(new Set(['legion']));
|
||||
expect(factionsIn(generateWave(4, createRng(seed)))).toEqual(new Set(['legion']));
|
||||
}
|
||||
});
|
||||
|
||||
it('mischt ab Welle 6 beide Fraktionen', () => {
|
||||
const seen = new Set();
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
for (const faction of factionsIn(generateWave(6, createRng(seed)))) seen.add(faction);
|
||||
}
|
||||
expect(seen).toEqual(new Set(['swarm', 'legion']));
|
||||
});
|
||||
|
||||
it('enthält Eliten genau in Elite-Wellen', () => {
|
||||
for (let n = 1; n <= 20; n += 1) {
|
||||
const plan = generateWave(n, createRng(n));
|
||||
const elites = tiersIn(plan).filter((t) => t === 'elite').length;
|
||||
expect(elites, `Welle ${n}`).toBe(eliteCount(n));
|
||||
}
|
||||
});
|
||||
|
||||
it('wechselt die Elite-Fraktion zwischen Elite-Wellen', () => {
|
||||
const eliteFaction = (n) =>
|
||||
generateWave(n, createRng(3))
|
||||
.groups.map((g) => ENEMY_TYPES[g.type])
|
||||
.find((t) => t.tier === 'elite').faction;
|
||||
expect(eliteFaction(5)).toBe('swarm');
|
||||
expect(eliteFaction(10)).toBe('legion');
|
||||
});
|
||||
|
||||
it('schöpft das Budget in Wellen ohne Eliten fast vollständig aus', () => {
|
||||
const cheapest = Math.min(...Object.values(ENEMY_TYPES).map((t) => t.cost));
|
||||
for (const n of [1, 2, 3, 4, 6, 7, 8, 13]) {
|
||||
for (let seed = 0; seed < 10; seed += 1) {
|
||||
const plan = generateWave(n, createRng(seed));
|
||||
expect(cost(plan)).toBeLessThanOrEqual(plan.budget);
|
||||
// Rest kleiner als der teuerste Kleingegner einer Fraktion (Legion: 2).
|
||||
expect(plan.budget - cost(plan)).toBeLessThan(cheapest + 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('hält die Gruppengrößen der Gegnertypen ein', () => {
|
||||
for (let n = 1; n <= 25; n += 1) {
|
||||
for (const group of generateWave(n, createRng(n * 7)).groups) {
|
||||
const { groupSize } = ENEMY_TYPES[group.type];
|
||||
expect(group.count).toBeGreaterThanOrEqual(1);
|
||||
expect(group.count).toBeLessThanOrEqual(groupSize.max);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('plant Spawns zeitlich aufsteigend ab 0 ms und an gültigen Rändern', () => {
|
||||
const plan = generateWave(12, createRng(99));
|
||||
expect(plan.groups[0].atMs).toBe(0);
|
||||
for (let i = 1; i < plan.groups.length; i += 1) {
|
||||
expect(plan.groups[i].atMs).toBeGreaterThanOrEqual(plan.groups[i - 1].atMs);
|
||||
}
|
||||
for (const group of plan.groups) expect(EDGES).toContain(group.edge);
|
||||
});
|
||||
|
||||
it('zählt die Gegner korrekt und wird mit der Zeit größer', () => {
|
||||
const early = generateWave(2, createRng(5));
|
||||
const late = generateWave(15, createRng(5));
|
||||
expect(early.totalEnemies).toBe(early.groups.reduce((s, g) => s + g.count, 0));
|
||||
expect(late.totalEnemies).toBeGreaterThan(early.totalEnemies);
|
||||
});
|
||||
|
||||
it('setzt mehr mittlere Gegner in späten Wellen ein', () => {
|
||||
const mediumShare = (n) => {
|
||||
let medium = 0;
|
||||
let total = 0;
|
||||
for (let seed = 0; seed < 30; seed += 1) {
|
||||
const plan = generateWave(n, createRng(seed));
|
||||
for (const g of plan.groups) {
|
||||
const t = ENEMY_TYPES[g.type];
|
||||
if (t.tier === 'medium') medium += g.count * t.cost;
|
||||
total += g.count * t.cost;
|
||||
}
|
||||
}
|
||||
return medium / total;
|
||||
};
|
||||
expect(mediumShare(12)).toBeGreaterThan(mediumShare(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnPointOnEdge', () => {
|
||||
const world = { width: 2000, height: 1000 };
|
||||
|
||||
it('setzt Punkte an den richtigen Rand innerhalb der Welt', () => {
|
||||
const rng = createRng(1);
|
||||
expect(spawnPointOnEdge('north', world, 40, rng).y).toBe(40);
|
||||
expect(spawnPointOnEdge('south', world, 40, rng).y).toBe(960);
|
||||
expect(spawnPointOnEdge('west', world, 40, rng).x).toBe(40);
|
||||
expect(spawnPointOnEdge('east', world, 40, rng).x).toBe(1960);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const p = spawnPointOnEdge('north', world, 40, rng);
|
||||
expect(p.x).toBeGreaterThanOrEqual(40);
|
||||
expect(p.x).toBeLessThanOrEqual(1960);
|
||||
}
|
||||
});
|
||||
|
||||
it('wirft bei unbekanntem Rand', () => {
|
||||
expect(() => spawnPointOnEdge('oben', world, 40, createRng(1))).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WEAPONS } from '../../app/static/js/config.js';
|
||||
import { degToRad } from '../../app/static/js/logic/math.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
import { pelletOffsets, WeaponState } from '../../app/static/js/logic/weapon.js';
|
||||
|
||||
const def = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
magazineSize: 3,
|
||||
reserveStart: 4,
|
||||
reserveMax: 10,
|
||||
fireIntervalMs: 100,
|
||||
reloadMs: 500,
|
||||
damage: 1,
|
||||
pellets: 1,
|
||||
spreadDeg: 0,
|
||||
bulletSpeed: 1,
|
||||
rangePx: 1,
|
||||
automatic: true,
|
||||
};
|
||||
const rng = createRng(1);
|
||||
|
||||
describe('WeaponState', () => {
|
||||
it('startet mit vollem Magazin und Startreserve', () => {
|
||||
const w = new WeaponState(def);
|
||||
expect(w.magazine).toBe(3);
|
||||
expect(w.reserve).toBe(4);
|
||||
expect(w.isReloading).toBe(false);
|
||||
});
|
||||
|
||||
it('respektiert die Feuerrate', () => {
|
||||
const w = new WeaponState(def);
|
||||
expect(w.fire(0, rng)).toHaveLength(1);
|
||||
expect(w.fire(50, rng)).toBeNull();
|
||||
expect(w.fire(100, rng)).toHaveLength(1);
|
||||
expect(w.magazine).toBe(1);
|
||||
});
|
||||
|
||||
it('lädt bei leerem Magazin automatisch nach und kann währenddessen nicht feuern', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.fire(0, rng);
|
||||
w.fire(100, rng);
|
||||
w.fire(200, rng);
|
||||
expect(w.magazine).toBe(0);
|
||||
expect(w.isReloading).toBe(true);
|
||||
expect(w.fire(300, rng)).toBeNull();
|
||||
expect(w.update(699)).toBe(false);
|
||||
expect(w.update(700)).toBe(true);
|
||||
expect(w.magazine).toBe(3);
|
||||
expect(w.reserve).toBe(1);
|
||||
});
|
||||
|
||||
it('lädt bei knapper Reserve nur teilweise nach und ist danach leer', () => {
|
||||
const w = new WeaponState({ ...def, reserveStart: 1 });
|
||||
w.fire(0, rng);
|
||||
w.fire(100, rng);
|
||||
w.fire(200, rng);
|
||||
w.update(10_000);
|
||||
expect(w.magazine).toBe(1);
|
||||
expect(w.reserve).toBe(0);
|
||||
w.fire(10_100, rng);
|
||||
expect(w.isEmpty).toBe(true);
|
||||
expect(w.startReload(10_200)).toBe(false);
|
||||
});
|
||||
|
||||
it('lädt nicht nach, wenn das Magazin voll ist', () => {
|
||||
const w = new WeaponState(def);
|
||||
expect(w.startReload(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('bricht das Nachladen beim Waffenwechsel ohne Verlust ab', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.fire(0, rng);
|
||||
expect(w.startReload(100)).toBe(true);
|
||||
w.cancelReload();
|
||||
w.update(10_000);
|
||||
expect(w.magazine).toBe(2);
|
||||
expect(w.reserve).toBe(4);
|
||||
});
|
||||
|
||||
it('zeigt den Nachladefortschritt', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.fire(0, rng);
|
||||
w.startReload(1000);
|
||||
expect(w.reloadProgress(1250)).toBeCloseTo(0.5);
|
||||
w.update(1500);
|
||||
expect(w.reloadProgress(1600)).toBe(1);
|
||||
});
|
||||
|
||||
it('füllt die Reserve per Nachschub auf', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.refill();
|
||||
expect(w.reserve).toBe(10);
|
||||
});
|
||||
|
||||
it('hat bei der Dienstpistole unbegrenzte Reserve', () => {
|
||||
const pistol = new WeaponState(WEAPONS.pistol);
|
||||
expect(pistol.hasInfiniteReserve).toBe(true);
|
||||
let t = 0;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
t += 10_000;
|
||||
pistol.update(t);
|
||||
pistol.fire(t, rng);
|
||||
}
|
||||
expect(pistol.isEmpty).toBe(false);
|
||||
pistol.refill();
|
||||
expect(pistol.reserve).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pelletOffsets', () => {
|
||||
it('streut ein Einzelprojektil innerhalb des Fächers', () => {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const [offset] = pelletOffsets(1, 10, rng);
|
||||
expect(Math.abs(offset)).toBeLessThanOrEqual(degToRad(5));
|
||||
}
|
||||
});
|
||||
|
||||
it('verteilt Schrot über den ganzen Fächer', () => {
|
||||
const offsets = pelletOffsets(8, 24, rng);
|
||||
expect(offsets).toHaveLength(8);
|
||||
const spread = degToRad(24);
|
||||
expect(Math.min(...offsets)).toBeLessThan(-spread / 3);
|
||||
expect(Math.max(...offsets)).toBeGreaterThan(spread / 3);
|
||||
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(spread / 2 + spread / 7);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user