Newer
Older
rpg / apps / game / src / agent / __tests__ / snapshot.test.ts
import { describe, expect, it } from 'vitest';
import {
    AGENT_SNAPSHOT_KEYS,
    collisionLayer,
    dialogueLayer,
    enemiesLayer,
    gameLayer,
    heroLayer,
    npcsLayer,
    type EnemySnapshot,
    type GameSnapshot,
    type LocationSnapshot,
    type NpcSnapshot
} from '../snapshot';
import { predKeys } from '../GameAgent';

describe('снапшот агента — слои', () => {
    it('герой: позиция округляется до 3 знаков, остальное копируется', () => {
        const layer = heroLayer({
            tile: { x: 12, y: 8 },
            pos: { x: 1.23456, y: 5.0001 },
            hp: 9,
            maxHp: 12,
            facing: 'side',
            moving: false,
            invuln: false,
            inHazard: null
        });
        expect(layer.hero).toMatchObject({
            tile: { x: 12, y: 8 },
            pos: { x: 1.235, y: 5 },
            hp: 9,
            maxHp: 12,
            facing: 'side'
        });
    });

    it('враги: поля на месте, позиция округлена', () => {
        const e: EnemySnapshot = {
            kind: 'ash',
            state: 'chase',
            hp: 4,
            pos: { x: 0.123456, y: 2.5 },
            asleep: true,
            dead: false
        };
        const layer = enemiesLayer([e]);
        expect(layer.enemies).toEqual([
            { kind: 'ash', state: 'chase', hp: 4, pos: { x: 0.123, y: 2.5 }, asleep: true, dead: false }
        ]);
    });

    it('слой коллизий: blocked по тайлам, пропы отдельным списком', () => {
        const data = {
            width: 2,
            height: 2,
            tiles: [0, 1, 0, 0],
            blocked: [1],
            props: [{ id: 2, x: 0, y: 1, w: 2, h: 1 }]
        };
        const layer = collisionLayer(data);
        expect(layer.collision).toEqual({
            width: 2,
            height: 2,
            blocked: [0, 1, 1, 1], // стена (1,0) + весь footprint пропа
            props: [{ x: 0, y: 1, w: 2, h: 1 }]
        });
    });

    it('NPC: копия с met, без ссылок на источник', () => {
        const n: NpcSnapshot = { id: 'elder', name: 'Ирвин', tile: { x: 20, y: 12 }, met: false };
        const layer = npcsLayer([n]);
        const out = (layer.npcs as unknown as NpcSnapshot[])[0]!;
        expect(out).toEqual(n);
        expect(out).not.toBe(n);
    });

    it('диалог: объект проходит, null остаётся null', () => {
        const d = { id: 'elder', nodeId: 'n1', speaker: 'Ирвин', text: 'Привет', choices: [], waitingForChoice: false };
        expect(dialogueLayer(d).dialogue).toEqual(d);
        expect(dialogueLayer(null).dialogue).toBeNull();
    });

    it('слой игры: флаги/вары/сумка копируются защитно', () => {
        const flags = ['metElder'];
        const vars = { flowers: 2 };
        const inv = [{ id: 'flower', count: 2 }];
        const layer = gameLayer({ flags, vars, inventory: inv });
        expect(layer).toEqual({ flags: ['metElder'], vars: { flowers: 2 }, inventory: [{ id: 'flower', count: 2 }] });
        // Мутация источника не меняет слой.
        flags.push('x');
        vars.flowers = 99;
        inv[0]!.count = 99;
        expect(layer.flags).toEqual(['metElder']);
        expect(layer.vars).toEqual({ flowers: 2 });
        expect(layer.inventory[0]!.count).toBe(2);
    });
});

describe('AGENT_SNAPSHOT_KEYS — анти-дрейф', () => {
    const locationSample: LocationSnapshot = {
        scene: 'location',
        area: 'meadows',
        areaName: 'Выжженные луга',
        hero: {
            tile: { x: 0, y: 0 },
            pos: { x: 0, y: 0 },
            hp: 10,
            maxHp: 10,
            facing: 'down',
            moving: false,
            invuln: false,
            inHazard: null
        },
        enemies: [],
        npcs: [],
        transitions: [],
        interactables: [],
        collision: { width: 1, height: 1, blocked: [0], props: [] },
        dialogue: null,
        cutscene: null,
        lastToast: null
    };

    it('все top-level ключи сцены и слоя игры есть в списке', () => {
        const gamePart: GameSnapshot = gameLayer({
            flags: [],
            vars: {},
            inventory: []
        }) as GameSnapshot;
        const keys = new Set([...Object.keys(locationSample), ...Object.keys(gamePart)]);
        expect([...keys].filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k))).toEqual([]);
    });

    it('движковые ключи (слоя EngineAgent) не утеряны из списка', () => {
        for (const k of ['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer']) {
            expect(AGENT_SNAPSHOT_KEYS).toContain(k);
        }
    });

    it('в списке нет лишних (список = сцена + игра + движок)', () => {
        const locationKeys = Object.keys(locationSample);
        const gameKeys = ['flags', 'vars', 'inventory'];
        const extra = AGENT_SNAPSHOT_KEYS.filter(
            (k) => !locationKeys.includes(k) && !gameKeys.includes(k) && !['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer'].includes(k)
        );
        expect(extra).toEqual([]);
    });
});

describe('predKeys — top-level ключи предиката', () => {
    it('одиночное обращение и вложенный путь', () => {
        expect(predKeys('s.hero')).toEqual(['hero']);
        expect(predKeys('s.hero && s.hero.tile.x === 1')).toEqual(['hero']);
    });

    it('несколько ключей и сложные выражения', () => {
        expect(predKeys('!s.transitioning && s.area === "zvenets"')).toEqual(
            expect.arrayContaining(['transitioning', 'area'])
        );
        expect(predKeys('s.vars.motes >= 1 || s.flags.includes("x")')).toEqual(
            expect.arrayContaining(['vars', 'flags'])
        );
        expect(predKeys('(s.dialogue != null)')).toEqual(['dialogue']);
    });

    it('не ловит не-обращения (строки, другие объекты)', () => {
        expect(predKeys('s.scene === "location"')).toEqual(['scene']);
        expect(predKeys('s.x === 1')).toEqual(['x']); // pointer-слой — легальный ключ движка
        expect(predKeys('foo.s.bar === 1')).toEqual([]); // не наш s
    });
});