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); }); });