|
|
@@ -0,0 +1,170 @@
|
|
|
+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);
|
|
|
+ });
|
|
|
+});
|