Files
Binding-Fignyaac/tests/regressions.test.ts
T
mayatnikov a72172df72 refactor: комплексный рефакторинг + Isaac-like геймплей
Баги и гигиена (Wave 1):
- Player.addWeapon использует MODE_MELEE вместо литерала 1
- Фильтрация мёртвых врагов из room.enemies (раньше массив рос в долгих боях)
- Кап миньёнов босса (BOSS.maxMinions=4) — иначе комната могла не зачиститься
- aliveCount корректно считает живых в knockback-фазе
- equipSlot валидирует диапазон слота
- Магические числа range=70/spread=0.15 вынесены в WeaponDef (beamRange/spread)
- Регрессия cleared-flag: после фильтрации массива длина 0, но cleared должен стать true

Распил Game.ts 683→465 строк (Wave 2):
- systems/movement.ts: moveEntity(e, dx, dy, room) — убрал 5 копий коллизионного паттерна
- systems/projectiles.ts: applyWeaponProjectileStats, explodeBomb, projectileHitWall
- systems/ai.ts: runAI через диспетчер-таблицу (вместо if/else-if каскада)
- Переходы/этажи оставлены в Game.ts (тесно завязаны на cc/cr/player)

Тесты 33→72 (Wave 3):
- movement.test.ts, projectiles.test.ts, combat.test.ts, regressions.test.ts, items.test.ts
- Покрытие: ближний/дальний бой, огнемёт, бомба, лазер, splitter, сундук→пикап,
  переход комнат, фазы босса + кап миньёнов, фильтрация мёртвых, секретка

Isaac-like геймплей (Wave 4):
- Статы игрока: damageMul/fireRateMul/rangeMul/shotSpeedMul (мультипликативно
  поверх WeaponDef), effectiveDamage/Cooldown, отображение в HUD
- 8-направленный прицел: aimDir: Dir → aimVec: {x,y}, стрелки дают диагонали
- Пассивные предметы (items.ts, 6 штук): сундук дропает 50/50 оружие/предмет
- Новый тип врага splitter: при смерти распадается на двух fast
- Новый тип комнаты secret: +1 max HP один раз при первом входе
2026-06-19 16:50:15 +03:00

272 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { Projectile } from '../src/core/entities/Projectile';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES, PRESETS } from '../src/core/rules';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
/** Ставит игру в normal-комнату с указанными врагами. */
function placeInNormalRoom(game: Game, enemies: Enemy[]): void {
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
room.enemies = enemies;
room.cleared = false;
return;
}
throw new Error('normal-комната не найдена');
}
describe('Регрессии Wave 1', () => {
it('мёртвые враги удаляются из room.enemies (нет утечки)', () => {
const game = new Game(DEFAULT_RULES, new Rng(31));
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
// Три врага, по 1 hp — умрут почти сразу от попадания.
const e1 = new Enemy(cx - 50, cy, 'normal');
const e2 = new Enemy(cx + 50, cy, 'normal');
const e3 = new Enemy(cx, cy - 50, 'normal');
e1.hp = e2.hp = e3.hp = 1;
placeInNormalRoom(game, [e1, e2, e3]);
// Чтобы не полагаться на полёт снарядов — убиваем напрямую и прогоняем шаг.
e1.hp = 0; e2.hp = 0; e3.hp = 0;
game.step(input());
expect(game.curRoom.enemies.length).toBe(0); // фильтранулось, а не осталось 3 мёртвых
});
it('РЕГРЕССИЯ: после убийства всех врагов комната становится cleared (двери откроются)', () => {
const game = new Game(DEFAULT_RULES, new Rng(34));
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const e = new Enemy(cx, cy, 'normal');
placeInNormalRoom(game, [e]);
expect(game.curRoom.cleared).toBe(false);
// Убиваем и прогоняем шаг — фильтр не должен помешать cleared стать true.
e.hp = 0;
game.step(input());
expect(game.curRoom.cleared).toBe(true);
expect(game.curRoom.enemies.length).toBe(0);
});
it('Player.addWeapon использует MODE_MELEE для melee-оружия', () => {
// Проверяем, что mode === MODE_MELEE (1), а не литерал 1 по ошибке.
const game = new Game(DEFAULT_RULES, new Rng(32));
game.player.addWeapon('axe'); // melee
expect(game.player.mode).toBe(1);
game.player.addWeapon('tears'); // ranged
expect(game.player.mode).toBe(0);
});
it('equipSlot молча игнорирует несуществующие слоты', () => {
const game = new Game(DEFAULT_RULES, new Rng(33));
const equippedBefore = game.player.equipped;
game.equipSlot(5); // не существует
expect(game.player.equipped).toBe(equippedBefore);
game.equipSlot(-1);
expect(game.player.equipped).toBe(equippedBefore);
});
it('WeaponDef.spread/beamRange вынесены из хардкода и читаются оружием', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 999 };
const game = new Game(endless);
game.player.addWeapon('laser');
// Выстрел — луч должен оказаться на beamRange от игрока (70 по умолчанию).
const px = game.player.x;
game.step(input({ aimVec: { x: 1, y: 0 } }));
const beam = game.curRoom.tears.find((t) => t.type === 'beam');
expect(beam).toBeDefined();
expect(beam!.x).toBe(px + 70); // если не вынесли в WeaponDef — будет undefined → NaN
});
});
describe('Босс (milestone, этаж 5/10/15)', () => {
it('босс не плодит миньёнов сверх BOSS.maxMinions', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 505 };
const game = new Game(endless);
// Домотать до 5-го этажа.
for (let i = 2; i <= 5; i++) {
const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!;
bossRoom.cleared = true;
game.step(input());
}
expect(game.floor).toBe(5);
// Заходим в комнату босса.
const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!;
game.cc = bossRoom.c;
game.cr = bossRoom.r;
game.enterRoom('up');
const boss = bossRoom.enemies.find((e) => e.type === 'boss')!;
expect(boss).toBeDefined();
// Опускаем HP до фазы 3 (< 33%), чтобы начался спавн миньёнов.
boss.hp = Math.floor(boss.maxHp * 0.1);
boss.spawnTimer = 0; // принудительно вызываем спавн в ближайший шаг
// Прогоняем много шагов, чтобы спавн многократно сработал.
for (let i = 0; i < 5000; i++) game.step(input());
const aliveEnemies = bossRoom.enemies.filter((e) => e.alive);
// Миньёны — все живые, кроме самого босса.
expect(aliveEnemies.length).toBeLessThanOrEqual(5); // босс + 4 миньёна максимум
});
});
describe('Переход между комнатами', () => {
it('игрок зачищает комнату и переходит в соседнюю', () => {
const game = new Game(DEFAULT_RULES, new Rng(41));
const spawnRoom = game.curRoom;
expect(spawnRoom.type).toBe('spawn');
// Берём первое доступное направление дверей у спавна.
const dir = (['up', 'down', 'left', 'right'] as const).find((d) => spawnRoom.doors[d]);
expect(dir).toBeDefined();
const cc0 = game.cc;
const cr0 = game.cr;
// Ставим игрока ОДИН ТАЙЛ ВНУТРЬ от двери (как делает enterRoom): тогда по
// ходу движения к двери он несколько шагов проведёт в крайнем тайле и
// checkTransition успеет сработать. (Старт прямо в door row был бы сразу
// вытолкнут за пределы комнаты.)
const center = doorNeighborCell(dir!);
game.player.place(center.x, center.y);
game.player.transCD = 0;
const move = {
up: { moveX: 0, moveY: -1 },
down: { moveX: 0, moveY: 1 },
left: { moveX: -1, moveY: 0 },
right: { moveX: 1, moveY: 0 },
}[dir!];
for (let i = 0; i < 80; i++) game.step(input(move));
expect(game.cc !== cc0 || game.cr !== cr0).toBe(true);
});
});
/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */
function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } {
// Центр двери + один тайл внутрь.
const cx = 7.5;
const cy = 5.5;
switch (dir) {
case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE };
case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE };
case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE };
case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE };
}
}
describe('Сундук → пикап → экипировка', () => {
it('сундук после уничтожения выпадает пикап оружия, который подбирается', () => {
const game = new Game(DEFAULT_RULES, new Rng(51));
// Найдём сокровищницу.
let treasure: typeof game.curRoom | null = null;
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'treasure') { treasure = room; break; }
}
if (!treasure) return; // мягко пропускаем, если на этом seed нет
game.cc = treasure.c;
game.cr = treasure.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
const weaponBefore = game.player.weapons[game.player.equipped].id;
// Убиваем сундук напрямую и прогоняем несколько шагов: должен заспавнить пикап.
chest.hp = 0;
for (let i = 0; i < 5; i++) game.step(input());
expect(game.curRoom.pickup).not.toBeNull();
// Ставим игрока на пикап и прогоняем шаги — должен подобрать.
game.player.place(game.curRoom.pickup!.x, game.curRoom.pickup!.y);
for (let i = 0; i < 5; i++) game.step(input());
expect(game.curRoom.pickup).toBeNull();
expect(game.player.weapons[game.player.equipped].id).not.toBe(weaponBefore);
});
});
describe('Secret room (секретка)', () => {
it('спавнится на карте с шансом — среди 50 seed’ов хотя бы раз', () => {
let found = 0;
for (let seed = 1; seed <= 50; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'secret') { found++; break; }
}
}
expect(found).toBeGreaterThan(0);
});
it('при первом входе даёт +1 max HP; при повторном — НЕ даёт', () => {
// Найдём seed с секреткой.
let seed = 1;
let game = new Game(DEFAULT_RULES, new Rng(seed));
while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) {
seed++;
game = new Game(DEFAULT_RULES, new Rng(seed));
}
if (seed >= 200) return; // мягко пропускаем
const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!;
const maxHp0 = game.player.maxHp;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.player.maxHp).toBe(maxHp0 + 1);
// Выйдем и зайдём снова — бонуса быть не должно.
game.cc = 0; game.cr = 0; // возвращаемся в спавн
game.enterRoom('up');
const maxHp1 = game.player.maxHp;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.player.maxHp).toBe(maxHp1);
});
it('в секретке нет врагов (как в сокровищнице) — зачищается сразу', () => {
let seed = 1;
let game = new Game(DEFAULT_RULES, new Rng(seed));
while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) {
seed++;
game = new Game(DEFAULT_RULES, new Rng(seed));
}
if (seed >= 200) return;
const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.curRoom.enemies.length).toBe(0);
expect(game.curRoom.cleared).toBe(true);
});
});
/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */
function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } {
// Центр двери + один тайл внутрь.
const cx = 7.5;
const cy = 5.5;
switch (dir) {
case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE };
case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE };
case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE };
case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE };
}
}