slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+82
View File
@@ -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' });
});
});
+242
View File
@@ -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);
});
});
+126
View File
@@ -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);
});
});
+114
View File
@@ -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);
});
});
+41
View File
@@ -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);
});
});
+165
View File
@@ -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);
});
});
+75
View File
@@ -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);
});
});
+28
View File
@@ -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);
});
});
+88
View File
@@ -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();
});
});
+88
View File
@@ -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));
});
});
+100
View File
@@ -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);
});
});
+110
View File
@@ -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 });
});
});
+110
View File
@@ -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);
});
});
+185
View File
@@ -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);
});
});
+129
View File
@@ -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);
});
});