| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- import { describe, expect, it } from 'vitest';
- import { loadCharacterStats, loadMap } from '../src/server/assets';
- import { Game } from '../src/server/game';
- import { Anim } from '../src/shared/characters';
- import { Button } from '../src/shared/input';
- import { applyTerrainEvent } from '../src/shared/terrain/events';
- import { ASSETS, realMaterials } from './helpers';
- function newGame(): Game {
- const materials = realMaterials();
- const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
- return new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
- }
- describe('server game', () => {
- it('a spawned player lands on the cake and stays on it', () => {
- const game = newGame();
- const { id } = game.addPlayer('A', 'generic');
- for (let i = 0; i < 180; i++) game.step();
- const [p] = game.snapshot();
- expect(p.id).toBe(id);
- expect(p.grounded).toBe(true);
- expect(p.y).toBeLessThan(game.terrain.height);
- });
- it('attacking carves the terrain and logs sequenced ops', () => {
- const game = newGame();
- const { id } = game.addPlayer('A', 'generic');
- for (let i = 0; i < 120; i++) game.step();
- const [p] = game.snapshot();
- const before = game.terrain.hash();
- game.setInput(id, { buttons: Button.Attack, aimX: Math.round(p.x), aimY: Math.round(p.y + 40) });
- const applied = game.step().terrain;
- expect(applied[0]).toMatchObject({ seq: 1, ev: { kind: 'op' } });
- expect(game.terrain.hash()).not.toBe(before);
- expect(game.events.length).toBeGreaterThanOrEqual(1);
- });
- });
- describe('Castor Glouton', () => {
- it('eats through the cake toward the aim, grows, then digests back', () => {
- const game = newGame();
- const { id } = game.addPlayer('C', 'castor');
- for (let i = 0; i < 120; i++) game.step();
- const [start] = game.snapshot();
- expect(start.size).toBe(20);
- // Burrow straight down for 2 s.
- for (let i = 0; i < 120; i++) {
- const [p] = game.snapshot();
- game.setInput(id, { buttons: Button.Special, aimX: Math.round(p.x), aimY: Math.round(p.y + 200) });
- game.step();
- }
- const [fed] = game.snapshot();
- expect(game.events.length).toBeGreaterThan(10);
- expect(fed.y).toBeGreaterThan(start.y + 40);
- expect(fed.size).toBeGreaterThan(start.size + 3);
- expect(fed.anim & Anim.Special).toBeTruthy();
- game.setInput(id, { buttons: 0, aimX: 0, aimY: 0 });
- for (let i = 0; i < 60 * 30; i++) game.step();
- expect(game.snapshot()[0].size).toBe(20);
- });
- it('a bite knocks back and stuns a player in reach, not one out of reach', () => {
- const game = newGame();
- const castor = game.addPlayer('C', 'castor');
- const victim = game.addPlayer('V', 'generic');
- for (let i = 0; i < 120; i++) game.step();
- const c = game.snapshot().find((p) => p.id === castor.id)!;
- game.teleport(victim.id, c.x + 200, c.y);
- game.setInput(castor.id, { buttons: Button.Attack, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
- game.step();
- expect(game.snapshot().find((p) => p.id === victim.id)!.anim & Anim.Stunned).toBe(0);
- game.setInput(castor.id, { buttons: 0, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
- for (let i = 0; i < 30; i++) game.step(); // attack cooldown
- game.teleport(victim.id, c.x + 50, c.y);
- game.setInput(castor.id, { buttons: Button.Attack, aimX: Math.round(c.x + 200), aimY: Math.round(c.y) });
- game.step();
- const v = game.snapshot().find((p) => p.id === victim.id)!;
- expect(v.anim & Anim.Stunned).toBeTruthy();
- expect(v.vx).toBeGreaterThan(200);
- expect(v.vy).toBeLessThan(0);
- });
- });
- describe('falling chunks (M2)', () => {
- /** Replays the server's event stream on a fresh copy of the map, like a client does. */
- function replay(game: Game): number {
- const materials = realMaterials();
- const { terrain } = loadMap(ASSETS, 'gateau-etages', materials);
- for (const { ev } of game.events) applyTerrainEvent(terrain, ev, materials);
- return terrain.hash();
- }
- it('cutting under the top tier makes it fall, land and merge back; clients stay bit-identical', () => {
- const game = newGame();
- // Slice through the base of the top tier (x 780..1268, base at y ≈ 446..470).
- for (let x = 760; x <= 1290; x += 12) game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
- const first = game.step();
- const detach = first.terrain.find((e) => e.ev.kind === 'detach' && !e.ev.debris);
- expect(detach).toBeDefined();
- expect(game.chunkStates()).toHaveLength(1);
- const startY = game.chunkStates()[0].y;
- let merged = false;
- for (let i = 0; i < 60 * 10 && !merged; i++) merged = game.step().terrain.some((e) => e.ev.kind === 'merge');
- expect(merged).toBe(true);
- expect(game.chunkStates()).toHaveLength(0);
- // It fell onto the middle tier, so the cake top is now lower.
- const lastMerge = [...game.events].reverse().find((e) => e.ev.kind === 'merge')!;
- expect(lastMerge.ev.kind === 'merge' && lastMerge.ev.y).toBeGreaterThan(startY);
- // A client replaying the stream gets exactly the server's bitmap.
- expect(replay(game)).toBe(game.terrain.hash());
- });
- it('a chunk falling on a player crushes them', () => {
- const game = newGame();
- const { id } = game.addPlayer('Victime', 'generic');
- game.teleport(id, 640, 400);
- for (let i = 0; i < 90; i++) game.step();
- const p = game.snapshot()[0];
- expect(p.grounded).toBe(true);
- // A chocolate ball appears in the air above them: unanchored, it falls at once.
- game.queueOp({ kind: 'fill', x: Math.round(p.x), y: Math.round(p.y - 220), r: 40, material: 2 });
- const first = game.step();
- expect(first.terrain.some((e) => e.ev.kind === 'detach' && !e.ev.debris)).toBe(true);
- let crushed = false;
- for (let i = 0; i < 60 * 2 && !crushed; i++) crushed = game.step().crushed.some((c) => c.id === id);
- expect(crushed).toBe(true);
- });
- });
- describe('falling chunks: rotated landing', () => {
- it('a tilted chunk still merges (diagonal-only crumbs are dropped) and clients stay identical', () => {
- const materials = realMaterials();
- const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
- const game = new Game({ map: definition, terrain, materials, seed: 3, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
- // Cut a slanted corner off the middle tier's right side so it tumbles onto the bottom tier.
- for (let k = 0; k < 14; k++) game.queueOp({ kind: 'carve', x: 1390 + k * 10, y: 470 + k * 9, r: 12, power: 99 });
- for (let y = 470; y <= 600; y += 10) game.queueOp({ kind: 'carve', x: 1395, y, r: 12, power: 99 });
- game.step();
- expect(game.chunkStates().length).toBeGreaterThan(0);
- let merges = 0;
- for (let i = 0; i < 60 * 12 && game.chunkStates().length > 0; i++) merges += game.step().terrain.filter((e) => e.ev.kind === 'merge').length;
- expect(game.chunkStates()).toHaveLength(0);
- expect(merges).toBeGreaterThan(0);
- const replayed = loadMap(ASSETS, 'gateau-etages', materials).terrain;
- for (const { ev } of game.events) applyTerrainEvent(replayed, ev, materials);
- expect(replayed.hash()).toBe(game.terrain.hash());
- });
- });
- describe('Castor Glouton: no flying', () => {
- it('holding eat while aiming at empty sky does not lift him', () => {
- const game = newGame();
- const { id } = game.addPlayer('C', 'castor');
- for (let i = 0; i < 120; i++) game.step();
- const [start] = game.snapshot();
- for (let i = 0; i < 90; i++) {
- const [p] = game.snapshot();
- game.setInput(id, { buttons: Button.Special, aimX: Math.round(p.x), aimY: Math.round(p.y - 300) });
- game.step();
- }
- expect(game.snapshot()[0].y).toBeGreaterThan(start.y - 10);
- });
- });
|