diff --git a/src/__tests__/ai.test.ts b/src/__tests__/ai.test.ts index 0455174..031c9ae 100644 --- a/src/__tests__/ai.test.ts +++ b/src/__tests__/ai.test.ts @@ -1,21 +1,22 @@ import {expect, test} from 'bun:test'; -import {RED, SimplePuyoScreen} from '../screen'; +import {RED} from '../screen'; import { DEFAULT_TARGET_POINTS, MOVES, MultiplayerGame, + MultiplayerParams, PASS, SimpleGame, - randomBag, - randomColorSelection, + defaultRules, + randomMultiplayer, } from '../game'; import {effectiveLockout, flexDropletStrategy1} from '../ai'; -import {randomSeed} from '../jkiss'; import {puyosEqual} from '../bitboard'; import {TimeWarpingGame, TimeWarpingMirror} from '../realtime'; +import {simpleFromLines} from './utils'; test('Effective lockout', () => { - const screen = SimplePuyoScreen.fromLines([ + const screen = simpleFromLines([ '', '', '', @@ -42,14 +43,15 @@ test('Effective lockout', () => { 0, 0, [0, 1, 2, 3], - [RED, RED] + [RED, RED], + defaultRules() ); const heuristic = effectiveLockout(game); expect(heuristic).toBeLessThan(0); }); test('Ineffective lockout', () => { - const screen = SimplePuyoScreen.fromLines([ + const screen = simpleFromLines([ '', '', '', @@ -76,14 +78,15 @@ test('Ineffective lockout', () => { 0, 0, [0, 1, 2, 3], - [RED, RED] + [RED, RED], + defaultRules() ); const heuristic = effectiveLockout(game); expect(heuristic).toBe(0); }); test('Ineffective lockout (no bag)', () => { - const screen = SimplePuyoScreen.fromLines([ + const screen = simpleFromLines([ '', '', '', @@ -110,7 +113,8 @@ test('Ineffective lockout (no bag)', () => { 0, 0, [0, 1, 2, 3], - [] + [], + defaultRules() ); const heuristic = effectiveLockout(game); expect(heuristic).toBe(0); @@ -118,27 +122,17 @@ test('Ineffective lockout (no bag)', () => { test('Server/client pausing game simulation', () => { const maxConsecutiveRerolls = 10; - const gameSeeds = [randomSeed(), randomSeed()]; - const screenSeeds = [randomSeed(), randomSeed()]; - const colorSelection = randomColorSelection(); - const colorSelections = [colorSelection, colorSelection]; - const initialBag = randomBag(colorSelection); - const initialBags = [initialBag, initialBag]; - const main = new MultiplayerGame( - gameSeeds, - screenSeeds, - colorSelections, - initialBags - ); + const params = randomMultiplayer(); + const main = new MultiplayerGame(params); // In practice this would be two mirrors for each client const knownBags = main.initialBags; - const mirror = new MultiplayerGame( - null, - screenSeeds, - colorSelections, - knownBags - ); + const mirrorParams: MultiplayerParams = { + ...params, + bagSeeds: null, + initialBags: knownBags, + }; + const mirror = new MultiplayerGame(mirrorParams); for (let i = 0; i < mirror.games.length; ++i) { // Send initial bag and prompt moves with next pieces mirror.games[i].bag = main.games[i].initialBag.concat( @@ -224,29 +218,18 @@ test('Server/client pausing game simulation', () => { test('Server/client realtime game simulation', () => { const maxConsecutiveRerolls = 10; - const gameSeeds = [randomSeed(), randomSeed()]; - const screenSeeds = [randomSeed(), randomSeed()]; - const colorSelection = randomColorSelection(); - const colorSelections = [colorSelection, colorSelection]; - const initialBag = randomBag(colorSelection); - const initialBags = [initialBag, initialBag]; - const origin = new MultiplayerGame( - gameSeeds, - screenSeeds, - colorSelections, - initialBags - ); - + const params = randomMultiplayer(); + const origin = new MultiplayerGame(params); // Server const main = new TimeWarpingGame(origin); const knownBags = origin.initialBags; - const mirrorOrigin = new MultiplayerGame( - null, - screenSeeds, - colorSelections, - knownBags - ); + const mirrorParams: MultiplayerParams = { + ...params, + bagSeeds: null, + initialBags: knownBags, + }; + const mirrorOrigin = new MultiplayerGame(mirrorParams); // Two dueling clients const mirrors = [ new TimeWarpingMirror(mirrorOrigin), diff --git a/src/__tests__/algebraic.test.ts b/src/__tests__/algebraic.test.ts index 3b9230c..ec869cc 100644 --- a/src/__tests__/algebraic.test.ts +++ b/src/__tests__/algebraic.test.ts @@ -2,7 +2,6 @@ import {expect, test} from 'bun:test'; import {SimplePuyoScreen, isEmpty, puyosEqual} from '..'; import { - algebraicToGameStates, applyAlgebraic, joinTokens, // replayToAlgebraic, @@ -11,6 +10,7 @@ import { } from '../algebraic'; import {LUMI_VS_FLEX2, fixedRandomGame} from './archive'; +/* test('Documentation example', () => { const apn = [ '[Event "Documentation"]', @@ -85,7 +85,6 @@ test('Utter multilines', () => { expect(utterAlgebraic('5Lr')).toBe('rock right.'); }); -/* test('Known game', () => { const replay = fixedRandomGame(); // TODO: Figure out why it says '2Nbcdef 3Lr' instead of '5Lr' diff --git a/src/__tests__/archive.ts b/src/__tests__/archive.ts index 07c6132..4803d6a 100644 --- a/src/__tests__/archive.ts +++ b/src/__tests__/archive.ts @@ -1,40 +1,24 @@ import {WIDTH} from '../bitboard'; -import { - DEFAULT_MARGIN_FRAMES, - DEFAULT_MERCY_FRAMES, - MultiplayerGame, -} from '../game'; +import {MultiplayerGame, ReplayParams, defaultRules} from '../game'; import {JKISS32} from '../jkiss'; import {Replay} from '../replay'; export function fixedRandomGame() { - const gameSeeds = [7, 7]; const colorSelection = [1, 2, 3, 4]; const colorSelections = [colorSelection, colorSelection]; const initialBags = [[], []]; - const screenSeeds = [11, 11]; - const targetPoints = [70, 70]; - const marginFrames = DEFAULT_MARGIN_FRAMES; - const mercyFrames = DEFAULT_MERCY_FRAMES; - const game = new MultiplayerGame( - gameSeeds, - screenSeeds, + const params: ReplayParams = { + bagSeeds: [7, 7], + garbageSeeds: [11, 11], colorSelections, initialBags, - targetPoints, - marginFrames, - mercyFrames - ); + rules: defaultRules(), + }; + const game = new MultiplayerGame(params); const rng = new JKISS32(8); const replay: Replay = { - gameSeeds, - screenSeeds, - colorSelections, - initialBags, - targetPoints, - marginFrames, - mercyFrames, + params, moves: [], metadata: { event: 'Fixed Random Match', @@ -72,16 +56,16 @@ export function fixedRandomGame() { } export const LUMI_VS_FLEX2: Replay = { - gameSeeds: [3864657304, 3864657304], - screenSeeds: [2580717322, 2580717322], - colorSelections: [ - [3, 1, 0, 2], - [3, 1, 0, 2], - ], - initialBags: [[], []], - targetPoints: [70, 70], - marginFrames: DEFAULT_MARGIN_FRAMES, - mercyFrames: Infinity, + params: { + bagSeeds: [3864657304, 3864657304], + garbageSeeds: [2580717322, 2580717322], + colorSelections: [ + [3, 1, 0, 2], + [3, 1, 0, 2], + ], + initialBags: [[], []], + rules: defaultRules(), + }, metadata: { event: 'First human vs. machine game to be captured in algebraic notation for Puyo', diff --git a/src/__tests__/game.test.ts b/src/__tests__/game.test.ts index 650051a..beec994 100644 --- a/src/__tests__/game.test.ts +++ b/src/__tests__/game.test.ts @@ -3,10 +3,14 @@ import { DEFAULT_TARGET_POINTS, MOVES, MultiplayerGame, + MultiplayerParams, + ReplayParams, SimpleGame, SinglePlayerGame, - randomBag, - randomColorSelection, + defaultRules, + randomMultiplayer, + randomSinglePlayer, + seededMultiplayer, } from '../game'; import {JKISS32, randomSeed} from '../jkiss'; import { @@ -14,19 +18,19 @@ import { GARBAGE, GREEN, HEIGHT, - PuyoScreen, + PURPLE, RED, - SimplePuyoScreen, WIDTH, YELLOW, puyoCount, puyosEqual, } from '..'; +import {screenFromLines, simpleFromLines} from './utils'; test('Pending commit time', () => { - const game = new MultiplayerGame(); + const game = new MultiplayerGame(randomMultiplayer()); game.games[0].bag[0] = RED; - game.games[0].screen = PuyoScreen.fromLines([ + game.games[0].screen = screenFromLines([ 'R ', 'RNNNNN', 'RNNNNN', @@ -57,7 +61,7 @@ test('Pending commit time', () => { }); test('No pending flash', () => { - const game = new MultiplayerGame(); + const game = new MultiplayerGame(randomMultiplayer()); game.pendingGarbage[0] = 30; const tickResults = game.tick(); expect(tickResults[0].busy).toBeFalse(); @@ -72,7 +76,8 @@ test('No pending flash', () => { test('Garbage schedule', () => { // Create a deterministic game. - const game = new MultiplayerGame([0, 0]); + const params = seededMultiplayer(0); + const game = new MultiplayerGame(params); // Create a deterministic player that is somewhat successful. const jkiss = new JKISS32(1); // Create a dummy opponent. @@ -102,12 +107,15 @@ test('Garbage schedule', () => { game.tick(); } - expect(puyoCount(game.games[1].screen.grid[GARBAGE])).toBe(11); + expect(puyoCount(game.games[1].screen.grid[GARBAGE])).toBe(8); }); test('Garbage offset in a fixed symmetric game', () => { // Create a random game. - const game = new MultiplayerGame([592624221, 592624221]); + const params = randomMultiplayer(); + params.bagSeeds = [592624221, 592624221]; + params.garbageSeeds = [592624221, 592624221]; + const game = new MultiplayerGame(params); // Create players with identical strategies. const players = [new JKISS32(3848740175), new JKISS32(3848740175)]; @@ -125,8 +133,9 @@ test('Garbage offset in a fixed symmetric game', () => { test('Garbage offset in a random symmetric game', () => { // Create a random game. - const gameSeed = randomSeed(); - const game = new MultiplayerGame([gameSeed, gameSeed]); + const params = randomMultiplayer(); + params.bagSeeds[1] = params.bagSeeds[0]; + const game = new MultiplayerGame(params); // Create players with identical strategies. const playerSeed = randomSeed(); const players = [new JKISS32(playerSeed), new JKISS32(playerSeed)]; @@ -144,7 +153,7 @@ test('Garbage offset in a random symmetric game', () => { }); test('Simple game late garbage offsetting', () => { - const screen = SimplePuyoScreen.fromLines(['YRGB ', 'YYRG B', 'RRGGBB']); + const screen = simpleFromLines(['YRGB ', 'YYRG B', 'RRGGBB']); screen.tick(); const game = new SimpleGame( @@ -156,7 +165,8 @@ test('Simple game late garbage offsetting', () => { 1000, 1000, [RED, GREEN, YELLOW, BLUE], - [YELLOW, YELLOW] + [YELLOW, YELLOW], + defaultRules() ); game.playAndTick(0); @@ -165,7 +175,7 @@ test('Simple game late garbage offsetting', () => { }); test('Simple game pending garbage offsetting', () => { - const screen = SimplePuyoScreen.fromLines(['YRGB ', 'YYRG B', 'RRGGBB']); + const screen = simpleFromLines(['YRGB ', 'YYRG B', 'RRGGBB']); screen.tick(); const game = new SimpleGame( @@ -177,7 +187,8 @@ test('Simple game pending garbage offsetting', () => { 0, 0, [RED, GREEN, YELLOW, BLUE], - [YELLOW, YELLOW] + [YELLOW, YELLOW], + defaultRules() ); game.playAndTick(0); @@ -186,38 +197,23 @@ test('Simple game pending garbage offsetting', () => { }); test('Roof play', () => { - const game = new SinglePlayerGame(); + const game = new SinglePlayerGame(randomSinglePlayer()); // Not recommended to play on the garbage insert line but kicks should still apply. game.play(0, 1, 0); expect(puyoCount(game.screen.mask)).toBe(2); }); test('Mirror driving', () => { - const mainSeeds = [randomSeed(), randomSeed()]; - const colorSelection = randomColorSelection(); - const colorSelections = [colorSelection, colorSelection]; - const screenSeeds = [randomSeed(), randomSeed()]; - const initialBag = randomBag(colorSelection); - const initialBags = [initialBag, initialBag]; - const targetPoints = [70, 70]; - const marginTime = 5000; - const main = new MultiplayerGame( - mainSeeds, - screenSeeds, - colorSelections, - initialBags, - targetPoints, - marginTime - ); + const params = randomMultiplayer(); + params.rules.marginFrames = 5000; + const main = new MultiplayerGame(params); - const mirror = new MultiplayerGame( - null, - screenSeeds, - colorSelections, - [[], []], - targetPoints, - marginTime - ); + const mirrorParams: MultiplayerParams = { + ...params, + bagSeeds: null, + initialBags: [[], []], + }; + const mirror = new MultiplayerGame(mirrorParams); // No independent moves. expect(() => mirror.play(0, 0, 2, 0)).toThrow(); @@ -260,7 +256,7 @@ test('Mirror driving', () => { }); test('No 1-frame cheese', () => { - const game = new MultiplayerGame(); + const game = new MultiplayerGame(randomMultiplayer()); game.pendingGarbage[1] = 44; game.play(1, 0, HEIGHT - 1, 0); @@ -275,7 +271,7 @@ test('No 1-frame cheese', () => { }); test('Permanent lockout', () => { - const game = new SinglePlayerGame(); + const game = new SinglePlayerGame(randomSinglePlayer()); while (!game.lockedOut) { game.play(Math.floor(Math.random() * WIDTH), 2, 0, Math.random() < 0.5); while (game.tick().busy); @@ -291,7 +287,7 @@ test('Permanent lockout', () => { }); test('To simple game JSON', () => { - const game = new MultiplayerGame([0, 0]); + const game = new MultiplayerGame(randomMultiplayer()); game.play(0, 1, 2, 3, true); game.play(1, 2, 3, 0, true); while (game.tick()[0].busy); @@ -303,7 +299,7 @@ test('To simple game JSON', () => { }); test('Default move count', () => { - const screen = new SimplePuyoScreen(); + const screen = simpleFromLines([]); const game = new SimpleGame( screen, DEFAULT_TARGET_POINTS, @@ -313,13 +309,14 @@ test('Default move count', () => { 0, 0, [RED, GREEN, YELLOW, BLUE], - [RED, GREEN] + [RED, GREEN], + defaultRules() ); expect(game.availableMoves.length).toBe(22); }); test('Move count with pass', () => { - const screen = new SimplePuyoScreen(); + const screen = simpleFromLines([]); const game = new SimpleGame( screen, DEFAULT_TARGET_POINTS, @@ -329,13 +326,14 @@ test('Move count with pass', () => { 1, 1, [RED, GREEN, YELLOW, BLUE], - [RED, GREEN] + [RED, GREEN], + defaultRules() ); expect(game.availableMoves.length).toBe(23); }); test('Move count reduction (symmetry)', () => { - const screen = new SimplePuyoScreen(); + const screen = simpleFromLines([]); const game = new SimpleGame( screen, DEFAULT_TARGET_POINTS, @@ -345,13 +343,14 @@ test('Move count reduction (symmetry)', () => { 0, 0, [RED, GREEN, YELLOW, BLUE], - [RED, RED] + [RED, RED], + defaultRules() ); expect(game.availableMoves.length).toBe(11); }); test('Move count reduction (rerolls)', () => { - const screen = SimplePuyoScreen.fromLines([ + const screen = simpleFromLines([ 'RR', 'GG', 'RR', @@ -376,13 +375,14 @@ test('Move count reduction (rerolls)', () => { 0, 0, [RED, GREEN, YELLOW, BLUE], - [RED, GREEN] + [RED, GREEN], + defaultRules() ); expect(game.availableMoves.length).toBe(22 - 4 - 2 + 1); }); test('Null end', () => { - const game = new MultiplayerGame([0, 0]); + const game = new MultiplayerGame(seededMultiplayer(0)); while (true) { if (!game.games[0].busy) { @@ -401,7 +401,7 @@ test('Null end', () => { }); test('AFK end', () => { - const game = new MultiplayerGame([1, 1]); + const game = new MultiplayerGame(seededMultiplayer(1)); while (!game.tick()[0].lockedOut); expect(game.age).toBe(12502); @@ -410,13 +410,12 @@ test('AFK end', () => { test('Handicap', () => { const colorSelection = [RED, GREEN, YELLOW, BLUE]; const colorSelections = [colorSelection, colorSelection]; - const game = new MultiplayerGame( - [11, 11], - [17, 17], - colorSelections, - [[], []], - [1, 70] - ); + const params = randomMultiplayer(); + params.colorSelections = colorSelections; + const initialBag = [GREEN, RED, RED, RED, RED, RED]; + params.initialBags = [initialBag, initialBag]; + params.rules.targetPoints = [1, 70]; + const game = new MultiplayerGame(params); game.play(0, 0, 0, 0, true); while (game.tick()[0].busy); game.play(0, 1, 0, 0, true); @@ -429,7 +428,7 @@ test('Handicap', () => { }); test('Soft drops make sound', () => { - const game = new SinglePlayerGame(); + const game = new SinglePlayerGame(randomSinglePlayer()); game.play(0, 1, 0, false); let numLandings = 0; for (let i = 0; i < 100; ++i) { @@ -441,7 +440,7 @@ test('Soft drops make sound', () => { }); test('Hard drops make sound', () => { - const game = new SinglePlayerGame(); + const game = new SinglePlayerGame(randomSinglePlayer()); game.play(0, 1, 0, true); let numLandings = 0; for (let i = 0; i < 100; ++i) { @@ -453,7 +452,7 @@ test('Hard drops make sound', () => { }); test('Garbage forced on a passive opponent', () => { - const game = new MultiplayerGame(); + const game = new MultiplayerGame(randomMultiplayer()); game.pendingGarbage[0] = 9001; for (let i = 0; i < 2000; ++i) { game.tick(); @@ -462,7 +461,7 @@ test('Garbage forced on a passive opponent', () => { }); test('No mercy flashes', () => { - const game = new MultiplayerGame(); + const game = new MultiplayerGame(randomMultiplayer()); for (let i = 0; i < 1000; ++i) { expect(game.games[0].busy).toBeFalse(); expect(game.games[1].busy).toBeFalse(); @@ -472,7 +471,9 @@ test('No mercy flashes', () => { test('No mirror cheese', () => { // Create a deterministic game with anti-cheese seeds. - const game = new MultiplayerGame([0, 1]); + const params = seededMultiplayer(0); + params.bagSeeds = [0, 1]; + const game = new MultiplayerGame(params); // Create a deterministic player that is somewhat successful. const jkiss = new JKISS32(7); @@ -487,7 +488,7 @@ test('No mirror cheese', () => { } if (!game.games[0].busy) { // The cheese works but only up to a limit. - if (game.age < 426) { + if (game.age < 384) { for (let i = 0; i < game.games[0].screen.grid.length; ++i) { expect( puyosEqual( @@ -505,3 +506,56 @@ test('No mirror cheese', () => { expect(game.games[0].score).not.toBe(game.games[1].score); }); + +test('Custom rules', () => { + const params: ReplayParams = { + bagSeeds: [1, 2], + garbageSeeds: [3, 4], + colorSelections: [ + [RED, GREEN, BLUE], + [RED, GREEN, YELLOW, BLUE, PURPLE], + ], + initialBags: [ + [RED, RED, RED, RED, GREEN, GREEN], + [RED, RED, RED, RED, RED, PURPLE], + ], + rules: { + clearThreshold: 5, + jiggleFrames: 20, + sparkFrames: 30, + targetPoints: [90, 50], + marginFrames: Infinity, + mercyFrames: Infinity, + }, + }; + const game = new MultiplayerGame(params); + game.play(0, 0, 0, 0, true); + game.play(1, 0, 0, 0, true); + while (game.games.some(g => g.busy)) game.tick(); + expect(game.age).toBe(23); + game.play(0, 0, 0, 0, true); + game.play(1, 0, 0, 0, true); + while (game.games.some(g => g.busy)) game.tick(); + expect(game.games[0].score).toBe(0); + expect(game.games[1].score).toBe(0); + game.play(0, 0, 0, 0, true); + game.play(1, 0, 0, 0, true); + while (game.games.some(g => g.busy)) game.tick(); + expect(game.games[0].score).toBe(0); + expect(game.games[1].score).toBe(50); + expect(game.pendingGarbage[0]).toBe(1); + + const clone = game.clone(); + expect(clone.games[0].colorSelection).toHaveLength(3); + expect(clone.games[1].colorSelection).toHaveLength(5); + expect(clone.targetPoints[0]).toBe(90); + expect(clone.targetPoints[1]).toBe(50); + + for (const g of clone.games) { + expect(g.rules.clearThreshold).toBe(5); + expect(g.rules.jiggleFrames).toBe(20); + expect(g.rules.sparkFrames).toBe(30); + expect(g.rules.marginFrames).toBe(Infinity); + expect(g.rules.mercyFrames).toBe(Infinity); + } +}); diff --git a/src/__tests__/realtime.test.ts b/src/__tests__/realtime.test.ts index adcc9d9..de8e9c5 100644 --- a/src/__tests__/realtime.test.ts +++ b/src/__tests__/realtime.test.ts @@ -1,16 +1,16 @@ import {expect, test} from 'bun:test'; import {fixedRandomGame} from './archive'; import { - DEFAULT_MARGIN_FRAMES, - DEFAULT_MERCY_FRAMES, MultiplayerGame, + MultiplayerParams, PlayedMove, + randomMultiplayer, } from '../game'; import {RevealedPiece, TimeWarpingGame, TimeWarpingMirror} from '../realtime'; import {HEIGHT} from '../bitboard'; test('Rejects duplicate moves', () => { - const origin = new MultiplayerGame(); + const origin = new MultiplayerGame(randomMultiplayer()); const move: PlayedMove = { x1: 0, y1: 1, @@ -29,7 +29,7 @@ test('Rejects duplicate moves', () => { }); test('Reveals pieces only once', () => { - const origin = new MultiplayerGame(); + const origin = new MultiplayerGame(randomMultiplayer()); const main = new TimeWarpingGame(origin); expect(main.revealPieces(0)).toHaveLength(2); @@ -40,15 +40,7 @@ test('Reveals pieces only once', () => { test('Fixed random game (time warp)', () => { const replay = fixedRandomGame(); - const origin = new MultiplayerGame( - replay.gameSeeds, - replay.screenSeeds, - replay.colorSelections, - replay.initialBags, - replay.targetPoints, - replay.marginFrames, - replay.mercyFrames - ); + const origin = new MultiplayerGame(replay.params); const main = new TimeWarpingGame(origin); @@ -82,29 +74,18 @@ test('Fixed random game (time warp)', () => { test('Fixed random game (mirror time warp)', () => { const replay = fixedRandomGame(); - const origin = new MultiplayerGame( - replay.gameSeeds, - replay.screenSeeds, - replay.colorSelections, - replay.initialBags, - replay.targetPoints, - replay.marginFrames, - replay.mercyFrames - ); - - const mirrorOrigin = new MultiplayerGame( - null, - replay.screenSeeds, - replay.colorSelections, - origin.initialBags, - replay.targetPoints, - replay.marginFrames, - replay.mercyFrames - ); + const origin = new MultiplayerGame(replay.params); + + const mirrorParams = { + ...replay.params, + bagSeeds: null, + initialBags: origin.initialBags, + }; + const mirrorOrigin = new MultiplayerGame(mirrorParams); const mirror = new TimeWarpingMirror(mirrorOrigin); - const game = origin.clone(true); + const game = origin.clone(); for (let i = 0; i < 2; ++i) { const piece: RevealedPiece = { @@ -147,12 +128,12 @@ test('Fixed random game (mirror time warp)', () => { }); test('Sounds of the past', () => { - const origin = new MultiplayerGame(); + const origin = new MultiplayerGame(randomMultiplayer()); const mirror = new TimeWarpingMirror(origin); mirror.warp(10); - const move = origin.clone(true).play(1, 0, HEIGHT - 3, 0); + const move = origin.clone().play(1, 0, HEIGHT - 3, 0); mirror.addMove(move); @@ -171,24 +152,8 @@ test('Multiplayer subclassability', () => { class TestClass extends MultiplayerGame { sound: string; - constructor( - seeds?: number[] | null | null[], - screenSeeds?: number[], - colorSelections?: number[][], - initialBags?: number[][], - targetPoints?: number[], - marginFrames = DEFAULT_MARGIN_FRAMES, - mercyFrames = DEFAULT_MERCY_FRAMES - ) { - super( - seeds, - screenSeeds, - colorSelections, - initialBags, - targetPoints, - marginFrames, - mercyFrames - ); + constructor(params: MultiplayerParams) { + super(params); this.sound = 'tick'; } @@ -202,21 +167,21 @@ test('Multiplayer subclassability', () => { return results; } - clone(preserveSeed = false) { - const instance = super.clone(preserveSeed); + clone() { + const instance = super.clone(); instance.sound = this.sound; return instance; } } - const instance = new TestClass(); + const instance = new TestClass(randomMultiplayer()); expect(instance.sound).toBe('tick'); instance.tick(); expect(instance.sound).toBe('tock'); expect(instance.age).toBe(1); - const clone = instance.clone(true); + const clone = instance.clone(); expect(clone.sound).toBe('tock'); expect(clone.age).toBe(1); expect(clone.games[0].jkiss!.state).toEqual(instance.games[0].jkiss!.state); @@ -230,7 +195,7 @@ test('Multiplayer subclassability', () => { }); test('Has memory limits', () => { - const origin = new MultiplayerGame(); + const origin = new MultiplayerGame(randomMultiplayer()); const main = new TimeWarpingGame(origin, 5, 5); // Two scheduled checkpoints at 5 and 10 main.warp(10); @@ -252,7 +217,10 @@ test('Has memory limits', () => { }); test('Mirror has memory limits', () => { - const origin = new MultiplayerGame(null, [1, 1], [[], []]); + const params: MultiplayerParams = randomMultiplayer(); + params.bagSeeds = null; + params.initialBags = [[], []]; + const origin = new MultiplayerGame(params); const mirror = new TimeWarpingMirror(origin, 5, 5); // Two scheduled checkpoints at 5 and 10 mirror.warp(10); diff --git a/src/__tests__/replay.test.ts b/src/__tests__/replay.test.ts index 4e601c5..b593c2e 100644 --- a/src/__tests__/replay.test.ts +++ b/src/__tests__/replay.test.ts @@ -12,15 +12,7 @@ test('Fixed random game', () => { expect(track.filter(i => i.type === 'lockout')[0].player).toBe(1); - const game = new MultiplayerGame( - replay.gameSeeds, - replay.screenSeeds, - replay.colorSelections, - replay.initialBags, - replay.targetPoints, - replay.marginFrames, - replay.mercyFrames - ); + const game = new MultiplayerGame(replay.params); for (const move of replay.moves) { while (game.age < move.time) { game.tick(); @@ -46,18 +38,10 @@ test('Lumi vs. Flex2', () => { test('Re-entrance', () => { const snapShots: MultiplayerGame[] = []; - const game = new MultiplayerGame( - LUMI_VS_FLEX2.gameSeeds, - LUMI_VS_FLEX2.screenSeeds, - LUMI_VS_FLEX2.colorSelections, - LUMI_VS_FLEX2.initialBags, - LUMI_VS_FLEX2.targetPoints, - LUMI_VS_FLEX2.marginFrames, - LUMI_VS_FLEX2.mercyFrames - ); + const game = new MultiplayerGame(LUMI_VS_FLEX2.params); let index = 0; for (let j = 0; j < 11; ++j) { - const snapShot = game.clone(true); + const snapShot = game.clone(); snapShots.push(snapShot); for (let i = 0; i < 2; ++i) { expect(game.games[i].jkiss!.state).toEqual( diff --git a/src/__tests__/screen.test.ts b/src/__tests__/screen.test.ts index 1fc27f3..3d271e7 100644 --- a/src/__tests__/screen.test.ts +++ b/src/__tests__/screen.test.ts @@ -1,18 +1,10 @@ import {expect, test} from 'bun:test'; -import { - BLUE, - GARBAGE, - GREEN, - PURPLE, - PuyoScreen, - RED, - SimplePuyoScreen, - YELLOW, -} from '../screen'; +import {BLUE, GARBAGE, GREEN, PURPLE, RED, YELLOW} from '../screen'; import {HEIGHT, isEmpty, isNonEmpty, puyoAt, puyoCount} from '../bitboard'; +import {screenFromLines, simpleFromLines} from './utils'; test('Gravity', () => { - const screen = new PuyoScreen(); + const screen = screenFromLines([]); screen.insertPuyo(0, 0, PURPLE); while (screen.tick().busy); expect(isEmpty(screen.grid[RED])).toBeTruthy(); @@ -25,7 +17,7 @@ test('Gravity', () => { }); test('Landing signal', () => { - const screen = new PuyoScreen(); + const screen = screenFromLines([]); screen.insertPuyo(3, HEIGHT - 3, YELLOW); screen.insertPuyo(4, HEIGHT - 4, GARBAGE); const firstTick = screen.tick(); @@ -40,7 +32,7 @@ test('Landing signal', () => { }); test('Garbage clearing', () => { - const screen = new PuyoScreen(); + const screen = screenFromLines([]); screen.insertPuyo(0, HEIGHT - 1, GARBAGE); screen.insertPuyo(0, HEIGHT - 2, GARBAGE); screen.insertPuyo(1, HEIGHT - 2, GARBAGE); @@ -57,7 +49,7 @@ test('Garbage clearing', () => { }); test('Garbage clearing across the old seam 1', () => { - const screen = PuyoScreen.fromLines([ + const screen = screenFromLines([ ' PPPP ', 'NNNNNN', 'NNNNNN', @@ -70,7 +62,7 @@ test('Garbage clearing across the old seam 1', () => { }); test('Garbage clearing across the old seam 2', () => { - const screen = PuyoScreen.fromLines([ + const screen = screenFromLines([ 'NNNNNN', 'NRRRRN', 'NNNNNN', @@ -83,7 +75,7 @@ test('Garbage clearing across the old seam 2', () => { }); test('Garbage clearing across the old seam 3', () => { - const screen = PuyoScreen.fromLines([ + const screen = screenFromLines([ ' GGGG', 'NNNNNN', 'NNNNNN', @@ -101,7 +93,7 @@ test('Garbage clearing across the old seam 3', () => { }); test('Garbage clearing across the old seam 4', () => { - const screen = PuyoScreen.fromLines([ + const screen = screenFromLines([ 'NNNNNN', 'NBBBBN', 'NNNNNN', @@ -119,7 +111,7 @@ test('Garbage clearing across the old seam 4', () => { }); test('Ghost garbage preservation', () => { - const screen = new PuyoScreen(); + const screen = screenFromLines([]); for (let j = 0; j < 4; ++j) { for (let i = 0; i < 3; ++i) { screen.insertPuyo(5, 3 + i + 3 * j, j); @@ -150,14 +142,14 @@ test('Ghost garbage elimination', () => { 'B', ]; - const screen = PuyoScreen.fromLines(lines); + const screen = screenFromLines(lines); expect(isNonEmpty(screen.grid[GARBAGE])).toBeTruthy(); while (screen.tick().busy); expect(isEmpty(screen.grid[GARBAGE])).toBeTruthy(); }); test('Rocks of garbage', () => { - const screen = new PuyoScreen(); + const screen = screenFromLines([]); screen.insertPuyo(0, 1, RED); screen.insertPuyo(1, 1, GREEN); screen.bufferedGarbage = 30; @@ -182,7 +174,7 @@ test('Rocks of garbage', () => { }); test('Simple screen gravity resolution', () => { - const screen = new SimplePuyoScreen(); + const screen = simpleFromLines([]); screen.insertPuyo(0, 0, RED); screen.tick(); expect(puyoAt(screen.grid[RED], 0, HEIGHT - 1)).toBeTruthy(); @@ -199,7 +191,7 @@ test('Simple screen chain resolution', () => { 'BBRGYR', 'RRGGYR', ]; - const screen = SimplePuyoScreen.fromLines(lines); + const screen = simpleFromLines(lines); const zero = screen.tick().score; expect(zero).toBe(0); @@ -229,7 +221,7 @@ test('Ghost group preservation', () => { 'NNNN', 'NNNN', ]; - const screen = SimplePuyoScreen.fromLines(lines); + const screen = simpleFromLines(lines); const zero = screen.tick().score; expect(zero).toBe(0); }); @@ -252,20 +244,20 @@ test('Top group elimination', () => { 'NNNN', 'NNNN', ]; - const screen = SimplePuyoScreen.fromLines(lines); + const screen = simpleFromLines(lines); const score = screen.tick().score; expect(score).toBe(40); }); test('Simple screen partial garbage line', () => { - const screen = new SimplePuyoScreen(); + const screen = simpleFromLines([]); screen.bufferedGarbage = 2; screen.tick(); expect(puyoCount(screen.grid[GARBAGE])).toBe(2); }); test('Screen partial garbage line', () => { - const screen = new PuyoScreen(); + const screen = screenFromLines([]); screen.bufferedGarbage = 2; screen.tick(); expect(puyoCount(screen.grid[GARBAGE])).toBe(2); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts new file mode 100644 index 0000000..1bbd723 --- /dev/null +++ b/src/__tests__/utils.ts @@ -0,0 +1,9 @@ +import {PuyoScreen, SimplePuyoScreen} from '../screen'; + +export function simpleFromLines(lines: string[]) { + return SimplePuyoScreen.fromLines(lines, 0, {clearThreshold: 4}); +} + +export function screenFromLines(lines: string[]) { + return PuyoScreen.fromLines(lines, 0, {clearThreshold: 4}); +} diff --git a/src/ai.ts b/src/ai.ts index 4df8df2..af8b7f4 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,6 +1,5 @@ import {JKISS32} from '.'; import { - CLEAR_THRESHOLD, VISIBLE_HEIGHT, WIDTH, clone, @@ -34,6 +33,9 @@ function materialCount(game: SimpleGame) { * Determine if the game is effectively locked out. */ export function effectiveLockout(game: SimpleGame) { + if (game.rules.clearThreshold !== 4) { + throw new Error('Can only evaluate lockout for clear threshold of 4'); + } let mask = game.screen.mask; const count = puyoCount(visible(mask)); if (count < WIDTH * VISIBLE_HEIGHT - 2) { @@ -52,7 +54,7 @@ export function effectiveLockout(game: SimpleGame) { const puyos = visible(game.screen.grid[game.bag[0]]); merge(puyos, mask); flood(mask, puyos); - if (puyoCount(mask) >= CLEAR_THRESHOLD) { + if (puyoCount(mask) >= 4) { return 0; } } else { @@ -61,14 +63,14 @@ export function effectiveLockout(game: SimpleGame) { let puyos = visible(game.screen.grid[game.bag[0]]); merge(puyos, spot); flood(spot, puyos); - if (puyoCount(spot) >= CLEAR_THRESHOLD) { + if (puyoCount(spot) >= 4) { return 0; } spot = clone(pieces[i]); puyos = visible(game.screen.grid[game.bag[1]]); merge(puyos, spot); flood(spot, puyos); - if (puyoCount(spot) >= CLEAR_THRESHOLD) { + if (puyoCount(spot) >= 4) { return 0; } } @@ -83,7 +85,7 @@ export function effectiveLockout(game: SimpleGame) { const target = clone(puyos); merge(target, spot); flood(spot, target); - if (puyoCount(spot) >= CLEAR_THRESHOLD) { + if (puyoCount(spot) >= 4) { return 0; } } @@ -92,7 +94,7 @@ export function effectiveLockout(game: SimpleGame) { const target = clone(puyos); merge(spot, target); flood(spot, puyos); - if (puyoCount(spot) >= CLEAR_THRESHOLD) { + if (puyoCount(spot) >= 4) { return 0; } } diff --git a/src/algebraic.ts b/src/algebraic.ts index abb53a5..0e98456 100644 --- a/src/algebraic.ts +++ b/src/algebraic.ts @@ -405,6 +405,7 @@ export function applyAlgebraic(screens: SimplePuyoScreen[], token: string) { } } +/* export function algebraicToGameStates(tokens: string[]): GameState[][] { const bags: number[][] = [[], []]; for (let token of tokens) { @@ -509,7 +510,7 @@ export function algebraicToGameStates(tokens: string[]): GameState[][] { while (screens[index].tick().busy); busy = false; } else { - token = token.replace(/\*/g, ''); + token = token.replace(/\*¤¤¤/g, ''); const index = /\d/.test(token[1]) ? 0 : 1; // eslint-disable-next-line no-inner-declarations @@ -569,6 +570,7 @@ export function algebraicToGameStates(tokens: string[]): GameState[][] { } return result; } +*/ const COLOR_NAMES: Record = { R: 'red', diff --git a/src/bitboard.ts b/src/bitboard.ts index ddd3ed3..5cc591e 100644 --- a/src/bitboard.ts +++ b/src/bitboard.ts @@ -17,8 +17,6 @@ export const VISIBLE_HEIGHT = 12; export const GHOST_Y = 3; export const BOTTOM_Y = 15; -// Rules -export const CLEAR_THRESHOLD = 4; // Scoring const GROUP_BONUS = [0, 2, 3, 4, 5, 6, 7, 10]; @@ -447,15 +445,15 @@ export function puyoCount(puyos: Puyos): number { ); } -function getGroupBonus(group_size: number) { - group_size -= CLEAR_THRESHOLD; - if (group_size >= GROUP_BONUS.length) { - group_size = GROUP_BONUS.length - 1; +function getGroupBonus(groupSize: number, clearThreshold: number) { + groupSize -= clearThreshold; + if (groupSize >= GROUP_BONUS.length) { + groupSize = GROUP_BONUS.length - 1; } - return GROUP_BONUS[group_size]; + return GROUP_BONUS[groupSize]; } -export function sparkGroups(puyos: Puyos): ClearResult { +export function sparkGroups(puyos: Puyos, clearThreshold: number): ClearResult { let numCleared = 0; let groupBonus = 0; const sparks = emptyPuyos(); @@ -475,9 +473,9 @@ export function sparkGroups(puyos: Puyos): ClearResult { flood(group, temp); applyXor(temp, group); const groupSize = puyoCount(group); - if (groupSize >= CLEAR_THRESHOLD) { + if (groupSize >= clearThreshold) { merge(sparks, group); - groupBonus += getGroupBonus(groupSize); + groupBonus += getGroupBonus(groupSize, clearThreshold); numCleared += groupSize; } if (isEmpty(temp)) { diff --git a/src/evaluate-ai.ts b/src/evaluate-ai.ts index 2730381..6fd5e13 100644 --- a/src/evaluate-ai.ts +++ b/src/evaluate-ai.ts @@ -5,7 +5,7 @@ import { flexDropletStrategy3, flexDropletStrategy2, } from '.'; -import {MOVES, MultiplayerGame, PASS} from './game'; +import {MOVES, MultiplayerGame, PASS, randomMultiplayer} from './game'; const MAX_CONSECUTIVE_REROLLS = 10; @@ -18,7 +18,7 @@ function duel( const strategies = [strategyA, strategyB]; const hardDrops = [hardDropA, hardDropB]; const passing = [false, false]; - const game = new MultiplayerGame(); + const game = new MultiplayerGame(randomMultiplayer()); while (true) { for (let i = 0; i < 2; ++i) { if (passing[i]) { diff --git a/src/game.ts b/src/game.ts index a079616..b1db37a 100644 --- a/src/game.ts +++ b/src/game.ts @@ -5,6 +5,7 @@ import { GARBAGE, NUM_PUYO_COLORS, PuyoScreen, + ScreenRules, ScreenState, SimplePuyoScreen, TickResult, @@ -12,6 +13,30 @@ import { colorOf, } from './screen'; +export interface GameRules extends ScreenRules { + jiggleFrames: number; // How long puyos "jiggle" after landing + sparkFrames: number; // How long puyos "spark" when cleared + marginFrames: number; // How long until sent garbage starts getting multiplied + mercyFrames: number; // How long until garbage is forced on a passive opponent + targetPoints: number[]; // Conversion factor from scored points to nuisance puyos generated +} + +export type OnePlayerParams = { + bagSeed: number | Uint32Array | null; + garbageSeed: number | Uint32Array; + colorSelection: number[]; + initialBag: number[]; + rules: GameRules; +}; + +export type MultiplayerParams = { + bagSeeds: (number | Uint32Array)[] | null; + garbageSeeds: (number | Uint32Array)[]; + colorSelections: number[][]; + initialBags: number[][]; + rules: GameRules; +}; + export type GameState = { screen: ScreenState; age: number; @@ -40,10 +65,11 @@ export interface MultiplayerTickResult extends TickResult { time: number; } -// Timings (gravity acts in units of one) +// Timings and rules (gravity acts in units of one) export const NOMINAL_FRAME_RATE = 30; -export const JIGGLE_TIME = 15; -export const SPARK_TIME = 20; +export const DEFAULT_JIGGLE_FRAMES = 15; +export const DEFAULT_SPARK_FRAMES = 20; +export const DEFAULT_CLEAR_THRESHOLD = 4; // Colors const COLOR_SELECTION_SIZE = 4; @@ -65,6 +91,17 @@ const FORCE_RELEASE = -100; const ONE_ROCK = WIDTH * 5; const ALL_CLEAR_GARBAGE = 30; +export function defaultRules(): GameRules { + return { + clearThreshold: DEFAULT_CLEAR_THRESHOLD, + jiggleFrames: DEFAULT_JIGGLE_FRAMES, + sparkFrames: DEFAULT_SPARK_FRAMES, + marginFrames: DEFAULT_MARGIN_FRAMES, + mercyFrames: DEFAULT_MERCY_FRAMES, + targetPoints: [DEFAULT_TARGET_POINTS, DEFAULT_TARGET_POINTS], + }; +} + export function randomColorSelection(size = COLOR_SELECTION_SIZE): number[] { if (size < 0) { throw new Error('Negative size'); @@ -114,6 +151,49 @@ export function randomBag(colorSelection: number[], jkiss?: JKISS32): number[] { return result; } +export interface ReplayParams extends MultiplayerParams { + bagSeeds: number[]; + garbageSeeds: number[]; +} + +export function randomSinglePlayer(): OnePlayerParams { + return { + bagSeed: randomSeed(), + garbageSeed: randomSeed(), + colorSelection: randomColorSelection(), + initialBag: [], + rules: defaultRules(), + }; +} + +export function randomMultiplayer(): ReplayParams { + const colorSelection = randomColorSelection(); + const initialBag = randomBag(colorSelection); + return { + bagSeeds: [randomSeed(), randomSeed()], + garbageSeeds: [randomSeed(), randomSeed()], + colorSelections: [colorSelection, colorSelection], + initialBags: [initialBag, initialBag], + rules: defaultRules(), + }; +} + +export function seededMultiplayer(seed: number): ReplayParams { + const jkiss = new JKISS32(seed); + const colorSelection = jkiss.subset( + [...Array(NUM_PUYO_COLORS).keys()], + COLOR_SELECTION_SIZE + ); + const initialBag = randomBag(colorSelection, jkiss); + return { + bagSeeds: [jkiss.step(), jkiss.step()], + garbageSeeds: [jkiss.step(), jkiss.step()], + colorSelections: [colorSelection, colorSelection], + initialBags: [initialBag, initialBag], + rules: defaultRules(), + }; +} + export class OnePlayerGame { age: number; score: number; @@ -127,27 +207,23 @@ export class OnePlayerGame { lockedOut: boolean; hardDropLanded: boolean; consecutiveRerolls: number; + rules: GameRules; - constructor( - seed: number | null, - screenSeed: number, - colorSelection: number[], - initialBag: number[] - ) { + constructor(params: OnePlayerParams) { this.age = 0; this.score = 0; this.jiggleTime = 0; this.sparkTime = 0; this.active = false; - if (seed === null) { + if (params.bagSeed === null) { this.jkiss = null; } else { - this.jkiss = new JKISS32(seed); + this.jkiss = new JKISS32(params.bagSeed); } - this.screen = new PuyoScreen(screenSeed); - this.colorSelection = [...colorSelection]; + this.screen = new PuyoScreen(params.garbageSeed, params.rules); + this.colorSelection = [...params.colorSelection]; - this.bag = [...initialBag]; + this.bag = [...params.initialBag]; if (this.bag.length < 6) { this.bag.unshift(-1); this.bag.unshift(-1); @@ -156,6 +232,7 @@ export class OnePlayerGame { this.lockedOut = false; this.hardDropLanded = false; this.consecutiveRerolls = 0; + this.rules = params.rules; } get busy(): boolean { @@ -310,9 +387,9 @@ export class OnePlayerGame { this.score += tickResult.score; this.active = tickResult.busy; if (tickResult.didJiggle) { - this.jiggleTime = JIGGLE_TIME; + this.jiggleTime = this.rules.jiggleFrames; } else if (isNonEmpty(this.screen.sparks)) { - this.sparkTime = SPARK_TIME; + this.sparkTime = this.rules.sparkFrames; } if (tickResult.lockedOut) { this.lockedOut = true; @@ -386,32 +463,22 @@ export class OnePlayerGame { console.log(this.displayLines().join('\n')); } - // Random seed, don't leak original unless specified. - clone(preserveSeed = false) { - const result = new (this.constructor as new (...args: any[]) => this)( - undefined, - undefined, - this.colorSelection, - [] - ); - if (preserveSeed) { - if (this.jkiss === null) { - result.jkiss = null; - } else { - result.jkiss = this.jkiss.clone(); - } - } + clone() { + const result = new (this.constructor as new ( + params: OnePlayerParams + ) => this)({ + bagSeed: this.jkiss === null ? null : this.jkiss?.state, + garbageSeed: this.screen.jkiss.state, + colorSelection: this.colorSelection, + initialBag: this.bag, + rules: this.rules, + }); result.age = this.age; result.score = this.score; result.jiggleTime = this.jiggleTime; result.sparkTime = this.sparkTime; result.active = this.active; - result.screen = this.screen.clone(preserveSeed); - if (preserveSeed) { - result.bag = [...this.bag]; - } else { - result.bag = this.visibleBag; - } + result.screen = this.screen.clone(); result.lockedOut = this.lockedOut; result.hardDropLanded = this.hardDropLanded; result.consecutiveRerolls = this.consecutiveRerolls; @@ -420,44 +487,6 @@ export class OnePlayerGame { } export class SinglePlayerGame extends OnePlayerGame { - constructor( - seed?: number | null, - screenSeed?: number, - colorSelection?: number[], - initialBag?: number[] - ) { - if (seed === undefined) { - seed = randomSeed(); - if (screenSeed === undefined) { - screenSeed = randomSeed(); - } - if (colorSelection === undefined) { - colorSelection = randomColorSelection(); - } - } else if (seed === null) { - if (screenSeed === undefined || colorSelection === undefined) { - throw new Error( - 'A mirror requires an explicit screen seed and color selection' - ); - } - } else { - const jkiss = new JKISS32(seed); - if (screenSeed === undefined) { - screenSeed = jkiss.step(); - } - if (colorSelection === undefined) { - colorSelection = jkiss.subset( - [...Array(NUM_PUYO_COLORS).keys()], - COLOR_SELECTION_SIZE - ); - } - } - if (initialBag === undefined) { - initialBag = []; - } - super(seed, screenSeed, colorSelection, initialBag); - } - tick(): TickResult { const tickResult = super.tick(); this.score += tickResult.allClear ? ALL_CLEAR_BONUS : 0; @@ -469,10 +498,9 @@ const PADDING = [1, 1, 1, 0, 0, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]; export class MultiplayerGame { games: OnePlayerGame[]; - // Conversion factor from scored points to nuisance puyos generated. - targetPoints: number[]; // Buffered gargabe is sent on the next tick. // Stored on games[i].screen.bufferedGarbage. + // Pending garbage is received after the next move. At most one rock at a time. pendingGarbage: number[]; // These labels are sort of swapped. @@ -485,116 +513,37 @@ export class MultiplayerGame { allClearBonus: boolean[]; // Outgoing garbage lock is needed so that all clear bonus can be commited even if the chain is too small to send other garbage. canSend: boolean[]; - // Number of frames before nuisance conversion factor starts increasing. - marginFrames: number; - // Number of frames before pending garbage is forced onto the screen. - mercyFrames: number; // Countdown until pending garbage is forced onto the screen. - // Doubles as incoming garbage lock which is needed so that chains have time time to resolve and make room for the nuisance puyos. + // A special value doubles as incoming garbage lock which is needed so that chains have time time to resolve and make room for the nuisance puyos. mercyRemaining: number[]; - - constructor( - seeds?: number[] | null | null[], - screenSeeds?: number[], - colorSelections?: number[][], - initialBags?: number[][], - targetPoints?: number[], - marginFrames = DEFAULT_MARGIN_FRAMES, - mercyFrames = DEFAULT_MERCY_FRAMES - ) { - if (seeds === undefined) { - // Bags will eventually diverge - seeds = [randomSeed(), randomSeed()]; - if (screenSeeds === undefined) { - // Garbage will fall randomly and independently - screenSeeds = [randomSeed(), randomSeed()]; - } - if (colorSelections === undefined) { - // Colors are the same for both players - const colorSelection = randomColorSelection(); - colorSelections = [colorSelection, colorSelection]; - } - if (initialBags === undefined) { - // The initial pieces are same for both players (if possible) - const initialBag = randomBag(colorSelections[0]); - initialBags = [initialBag, initialBag]; - if (colorSelections[0].length !== colorSelections[1].length) { - initialBags[1] = randomBag(colorSelections[1]); - } else { - for (let i = 0; i < colorSelections[0].length; ++i) { - if (colorSelections[0][i] !== colorSelections[1][i]) { - initialBags[1] = randomBag(colorSelections[1]); - break; - } - } - } - } - } else if (seeds === null || seeds[0] === null) { - if (screenSeeds === undefined || colorSelections === undefined) { - throw new Error( - 'Mirrors require explicit screen seeds and color selections' - ); - } - seeds = [null, null]; - } else { - const jkiss = new JKISS32(seeds[0]); - if (screenSeeds === undefined) { - screenSeeds = [jkiss.step(), jkiss.step()]; - } - if (colorSelections === undefined) { - const colorSelection = jkiss.subset( - [...Array(NUM_PUYO_COLORS).keys()], - COLOR_SELECTION_SIZE - ); - colorSelections = [colorSelection, colorSelection]; - } - if (initialBags === undefined) { - const initialBag = randomBag(colorSelections[0], jkiss); - initialBags = [initialBag, initialBag]; - if (colorSelections[0].length !== colorSelections[1].length) { - initialBags[1] = randomBag(colorSelections[1], jkiss); - } else { - for (let i = 0; i < colorSelections[0].length; ++i) { - if (colorSelections[0][i] !== colorSelections[1][i]) { - initialBags[1] = randomBag(colorSelections[1], jkiss); - break; - } - } - } - } - } - if (initialBags === undefined) { - initialBags = [[], []]; - } - this.games = [ - new OnePlayerGame( - seeds[0], - screenSeeds[0], - colorSelections[0], - initialBags[0] - ), - new OnePlayerGame( - seeds[1], - screenSeeds[1], - colorSelections[1], - initialBags[1] - ), - ]; - - if (targetPoints === undefined) { - targetPoints = [DEFAULT_TARGET_POINTS, DEFAULT_TARGET_POINTS]; - } - this.targetPoints = [...targetPoints]; - this.marginFrames = marginFrames; - this.mercyFrames = mercyFrames; - - this.pendingGarbage = [0, 0]; - this.accumulatedGarbage = [0, 0]; - this.pointResidues = [0, 0]; - this.allClearQueued = [false, false]; - this.allClearBonus = [false, false]; - this.canSend = [false, false]; - this.mercyRemaining = [mercyFrames, mercyFrames]; + // Copy of target points from the rules that may be modified after margin time has elapsed + targetPoints: number[]; + // Most of the rules / mechanics are customizable + rules: GameRules; + + constructor(params: MultiplayerParams) { + const numPlayers = params.garbageSeeds.length; + this.games = []; + for (let i = 0; i < numPlayers; ++i) { + const playerParams: OnePlayerParams = { + bagSeed: params.bagSeeds === null ? null : params.bagSeeds[i], + garbageSeed: params.garbageSeeds[i], + colorSelection: params.colorSelections[i], + initialBag: params.initialBags[i], + rules: params.rules, + }; + this.games.push(new OnePlayerGame(playerParams)); + } + this.rules = params.rules; + + this.pendingGarbage = Array(numPlayers).fill(0); + this.accumulatedGarbage = Array(numPlayers).fill(0); + this.pointResidues = Array(numPlayers).fill(0); + this.allClearQueued = Array(numPlayers).fill(false); + this.allClearBonus = Array(numPlayers).fill(false); + this.canSend = Array(numPlayers).fill(false); + this.mercyRemaining = Array(numPlayers).fill(this.rules.mercyFrames); + this.targetPoints = [...this.rules.targetPoints]; } get age(): number { @@ -605,6 +554,7 @@ export class MultiplayerGame { return result; } + // TODO: True multiplayer get state(): GameState[] { const states = this.games.map(game => game.state); for (let i = 0; i < this.games.length; ++i) { @@ -730,8 +680,8 @@ export class MultiplayerGame { tick(): MultiplayerTickResult[] { const age = this.age; - if (age >= this.marginFrames) { - if (!((age - this.marginFrames) % MARGIN_INTERVAL)) { + if (age >= this.rules.marginFrames) { + if (!((age - this.rules.marginFrames) % MARGIN_INTERVAL)) { for (let i = 0; i < this.targetPoints.length; ++i) { this.targetPoints[i] = Math.floor( this.targetPoints[i] * MARGIN_MULTIPLIER @@ -803,7 +753,7 @@ export class MultiplayerGame { tickResults[i].busy = true; this.games[i].active = true; } - this.mercyRemaining[i] = this.mercyFrames; + this.mercyRemaining[i] = this.rules.mercyFrames; } else { this.mercyRemaining[i]--; } @@ -820,7 +770,7 @@ export class MultiplayerGame { } let lateTimeRemaining = 0; if (this.games[opponent].busy) { - const opponentScreen = this.games[opponent].screen.clone(true); + const opponentScreen = this.games[opponent].screen.clone(); let score = 0; while (true) { const tickResult = opponentScreen.tick(); @@ -844,19 +794,25 @@ export class MultiplayerGame { lateGarbage, lateTimeRemaining, this.games[player].colorSelection, - this.games[player].visibleBag + this.games[player].visibleBag, + this.rules ); } - // Random seed. Don't leak original unless specified. - clone(preserveSeed = false) { - const result = new (this.constructor as new (...args: any[]) => this)( - [0, 0], - [0, 0], - this.games.map(g => g.colorSelection), - this.games.map(g => g.bag) - ); - result.games = this.games.map(game => game.clone(preserveSeed)); + clone() { + const result = new (this.constructor as new ( + params: MultiplayerParams + ) => this)({ + bagSeeds: null, + garbageSeeds: [0, 0], + colorSelections: [[0], [0]], + initialBags: [ + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + ], + rules: this.rules, + }); + result.games = this.games.map(game => game.clone()); result.targetPoints = [...this.targetPoints]; result.pendingGarbage = [...this.pendingGarbage]; result.accumulatedGarbage = [...this.accumulatedGarbage]; @@ -864,7 +820,6 @@ export class MultiplayerGame { result.allClearQueued = [...this.allClearQueued]; result.allClearBonus = [...this.allClearBonus]; result.canSend = [...this.canSend]; - result.marginFrames = this.marginFrames; result.mercyRemaining = [...this.mercyRemaining]; return result; } @@ -903,7 +858,7 @@ export const MOVES = [ // How long a single move takes on average. // +1 added for occasional splits even when hard dropping. -const DEFAULT_MOVE_TIME = JIGGLE_TIME + 1; +const DEFAULT_MOVE_TIME = DEFAULT_JIGGLE_FRAMES + 1; // Value all-clears based on the amount of garbage they send. const SIMPLE_ALL_CLEAR_BONUS = 2100; @@ -929,6 +884,8 @@ export class SimpleGame { // The next four or six puyos to be played. bag: number[]; + rules: GameRules; + constructor( screen: SimplePuyoScreen, targetPoints: number, @@ -939,6 +896,7 @@ export class SimpleGame { lateTimeRemaining: number, colorSelection: number[], bag: number[], + rules: GameRules, moveTime = DEFAULT_MOVE_TIME ) { this.screen = screen; @@ -950,6 +908,7 @@ export class SimpleGame { this.lateTimeRemaining = lateTimeRemaining; this.colorSelection = [...colorSelection]; this.bag = [...bag]; + this.rules = rules; this.moveTime = moveTime; // Normalize @@ -1026,7 +985,7 @@ export class SimpleGame { resolve() { const tickResult = this.screen.tick(); this.lateTimeRemaining -= - tickResult.chainNumber * (JIGGLE_TIME + 2) + this.moveTime; + tickResult.chainNumber * (this.rules.jiggleFrames + 2) + this.moveTime; if (this.lateTimeRemaining <= 0) { this.pendingGarbage += this.lateGarbage; this.lateGarbage = 0; @@ -1076,7 +1035,9 @@ export class SimpleGame { this.lateGarbage, this.lateTimeRemaining, this.colorSelection, - this.bag + this.bag, + this.rules, + this.moveTime ); } diff --git a/src/realtime.ts b/src/realtime.ts index 628fa94..444ec11 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -41,7 +41,7 @@ class TimeWarpBase { // Use up temporary checkpoint. this.checkpoints.delete(game.age); } else { - game = game.clone(true); + game = game.clone(); } return game; } @@ -49,14 +49,14 @@ class TimeWarpBase { // Scheduled checkpoint postTick(game: T) { if (!(game.age % this.checkpointInterval)) { - this.checkpoints.set(game.age, game.clone(true)); + this.checkpoints.set(game.age, game.clone()); } } // Temporary checkpoint and clean-up postWarp(game: T) { if (game.age % this.checkpointInterval) { - this.checkpoints.set(game.age, game.clone(true)); + this.checkpoints.set(game.age, game.clone()); } if (this.checkpoints.size > this.maxCheckpoints) { const times = [...this.checkpoints.keys()]; @@ -175,7 +175,7 @@ export class TimeWarpingGame< _warp(time: number) { if (this.checkpoints.has(time)) { - return this.checkpoints.get(time)!.clone(true); + return this.checkpoints.get(time)!.clone(); } const game = this.closestCheckpoint(time); @@ -260,7 +260,7 @@ export class TimeWarpingMirror< warp(time: number): [T | null, MultiplayerTickResult[]] { if (this.checkpoints.has(time)) { - const game = this.checkpoints.get(time)!.clone(true); + const game = this.checkpoints.get(time)!.clone(); return [this.reconstruct(game), []]; } const game = this.closestCheckpoint(time); diff --git a/src/replay.ts b/src/replay.ts index 8e2be3e..dd28afe 100644 --- a/src/replay.ts +++ b/src/replay.ts @@ -1,6 +1,11 @@ /* eslint-disable no-case-declarations */ import {WIDTH, columnCounts, semiVisible} from './bitboard'; -import {MultiplayerGame, MultiplayerTickResult, PlayedMove} from './game'; +import { + MultiplayerGame, + MultiplayerTickResult, + PlayedMove, + ReplayParams, +} from './game'; import {AIR, GARBAGE, colorOf} from './screen'; export type ApplicationInfo = { @@ -66,13 +71,7 @@ export type ReplayResult = { /** Seed data for re-creating a game. */ export type Replay = { - gameSeeds: number[]; - screenSeeds: number[]; - colorSelections: number[][]; - initialBags: number[][]; - targetPoints: number[]; - marginFrames: number; - mercyFrames: number; + params: ReplayParams; moves: PlayedMove[]; metadata: ReplayMetadata; result: ReplayResult; @@ -158,14 +157,7 @@ export function cmpMoves(a: PlayedMove, b: PlayedMove) { } export function logReplay(replay: Replay) { - const game = new MultiplayerGame( - replay.gameSeeds, - replay.screenSeeds, - replay.colorSelections, - replay.initialBags, - replay.targetPoints, - replay.mercyFrames - ); + const game = new MultiplayerGame(replay.params); replay.moves.sort(cmpMoves); game.log(); replay.moves.forEach(move => { @@ -186,14 +178,7 @@ export function* replayToTrack( callback?: TickCallback, baseClass: T = MultiplayerGame as T ): ReplayTrack { - const game = new baseClass( - replay.gameSeeds, - replay.screenSeeds, - replay.colorSelections, - replay.initialBags, - replay.targetPoints, - replay.marginFrames - ); + const game = new baseClass(replay.params); if (Array.isArray(replay.moves)) { replay.moves.sort(cmpMoves); } @@ -455,11 +440,11 @@ export function repairReplay(unserialized: Replay): Replay { unserialized.result.winner = undefined; } // Only positive infinity makes sense here if JSON serialization failed. - if (unserialized.marginFrames === null) { - unserialized.marginFrames = Infinity; + if (unserialized.params.rules.marginFrames === null) { + unserialized.params.rules.marginFrames = Infinity; } - if (unserialized.mercyFrames === null) { - unserialized.mercyFrames = Infinity; + if (unserialized.params.rules.mercyFrames === null) { + unserialized.params.rules.mercyFrames = Infinity; } return unserialized; } diff --git a/src/screen.ts b/src/screen.ts index 34ce303..2758d2f 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -32,12 +32,15 @@ import { toppedUp, flood, puyoCount, - CLEAR_THRESHOLD, visible, GHOST_Y, } from './bitboard'; import {JKISS32} from './jkiss'; +export type ScreenRules = { + clearThreshold: number; +}; + /** * Result of advancing the screen one step. */ @@ -142,17 +145,20 @@ export class SimplePuyoScreen { // Replays and netcode benefit from deterministic randomness. // Knowing the correct garbage seed can be considered cheating on the AIs part, but that's minor enough. jkiss: JKISS32; + // Game rules + rules: ScreenRules; /** * Construct a new 6x16 screen of puyos. */ - constructor(seed?: number | Uint32Array) { + constructor(garbageSeed: number | Uint32Array, rules: ScreenRules) { this.grid = []; for (let i = 0; i < NUM_PUYO_TYPES; ++i) { this.grid.push(emptyPuyos()); } this.bufferedGarbage = 0; - this.jkiss = new JKISS32(seed); + this.jkiss = new JKISS32(garbageSeed); + this.rules = rules; } toJSON() { @@ -160,11 +166,12 @@ export class SimplePuyoScreen { grid: this.grid.map(puyos => [...puyos]), bufferedGarbage: this.bufferedGarbage, jkiss: this.jkiss, + rules: this.rules, }; } static fromJSON(obj: any) { - const result = new SimplePuyoScreen(); + const result = new SimplePuyoScreen(0, obj.rules); for (let j = 0; j < obj.grid.length; ++j) { // TODO: Hide slicing details inside bitboard.ts for (let i = 0; i < WIDTH; ++i) { @@ -181,8 +188,12 @@ export class SimplePuyoScreen { * @param lines Array of strings consisting of characters "RGYBPN", "N" stands for nuisance i.e. garbage. * @returns A 6x16 screen of puyos filled from top to bottom. */ - static fromLines(lines: string[]) { - const result = new SimplePuyoScreen(); + static fromLines( + lines: string[], + garbageSeed: number | Uint32Array, + rules: ScreenRules + ) { + const result = new SimplePuyoScreen(garbageSeed, rules); result.grid = gridFromLines(lines); return result; } @@ -215,7 +226,7 @@ export class SimplePuyoScreen { merge(connetivityGrid[i], connectivity) ); - const {sparks} = sparkGroups(supported); + const {sparks} = sparkGroups(supported, this.rules.clearThreshold); merge(ignitionMask, sparks); }); @@ -378,7 +389,10 @@ export class SimplePuyoScreen { const totalCleared = emptyPuyos(); for (let i = 0; i < NUM_PUYO_COLORS; ++i) { - const {numCleared, groupBonus, sparks} = sparkGroups(this.grid[i]); + const {numCleared, groupBonus, sparks} = sparkGroups( + this.grid[i], + this.rules.clearThreshold + ); if (numCleared) { totalNumCleared += numCleared; totalGroupBonus += groupBonus; @@ -460,7 +474,7 @@ export class SimplePuyoScreen { * @returns Copy of the screen with simplified mechanics. */ toSimpleScreen() { - const result = new SimplePuyoScreen(this.jkiss.state); + const result = new SimplePuyoScreen(this.jkiss.state, this.rules); result.bufferedGarbage = this.bufferedGarbage; result.grid = this.grid.map(clone); return result; @@ -513,13 +527,14 @@ export class SimplePuyoScreen { if (y2 <= GHOST_Y) { y2 = 0; } + const clearThreshold = this.rules.clearThreshold; if (color1 === color2 && (x1 === x2 || y1 === y2)) { const puyos = clone(this.grid[color1]); const group = singlePuyo(x1, y1); merge(group, singlePuyo(x2, y2)); merge(puyos, group); flood(group, visible(puyos)); - if (puyoCount(group) >= CLEAR_THRESHOLD) { + if (puyoCount(group) >= clearThreshold) { return toArray(group); } } else { @@ -529,7 +544,7 @@ export class SimplePuyoScreen { const group1 = singlePuyo(x1, y1); merge(puyos1, group1); flood(group1, visible(puyos1)); - if (puyoCount(group1) >= CLEAR_THRESHOLD) { + if (puyoCount(group1) >= clearThreshold) { merge(result, group1); } @@ -537,7 +552,7 @@ export class SimplePuyoScreen { const group2 = singlePuyo(x2, y2); merge(puyos2, group2); flood(group2, visible(puyos2)); - if (puyoCount(group2) >= CLEAR_THRESHOLD) { + if (puyoCount(group2) >= clearThreshold) { merge(result, group2); } return toArray(result); @@ -561,10 +576,10 @@ export class PuyoScreen extends SimplePuyoScreen { /** * Construct a new 6x16 screen of puyos. - * @param seed Seed for the pseudo random number generator. + * @param garbageSeed Seed for the pseudo random number generator. */ - constructor(seed?: number | Uint32Array) { - super(seed); + constructor(garbageSeed: number | Uint32Array, rules: ScreenRules) { + super(garbageSeed, rules); this.chainNumber = 0; this.doJiggles = false; this.jiggles = emptyPuyos(); @@ -576,8 +591,12 @@ export class PuyoScreen extends SimplePuyoScreen { * @param lines Array of strings consisting of characters "RGYBPN", "N" stands for nuisance i.e. garbage. * @returns A 6x16 screen of puyos filled from top to bottom. */ - static fromLines(lines: string[]) { - const result = new PuyoScreen(); + static fromLines( + lines: string[], + garbageSeed: number | Uint32Array, + rules: ScreenRules + ) { + const result = new PuyoScreen(garbageSeed, rules); result.grid = gridFromLines(lines); return result; } @@ -729,7 +748,10 @@ export class PuyoScreen extends SimplePuyoScreen { clear(this.sparks); for (let i = 0; i < NUM_PUYO_COLORS; ++i) { - const {numCleared, groupBonus, sparks} = sparkGroups(this.grid[i]); + const {numCleared, groupBonus, sparks} = sparkGroups( + this.grid[i], + this.rules.clearThreshold + ); if (numCleared) { totalNumCleared += numCleared; totalGroupBonus += groupBonus; @@ -789,9 +811,10 @@ export class PuyoScreen extends SimplePuyoScreen { return true; } - clone(preserveSeed = false) { + clone() { const result = new (this.constructor as new (...args: any[]) => this)( - preserveSeed ? this.jkiss.state : undefined + this.jkiss.state, + this.rules ); result.grid = this.grid.map(clone); result.bufferedGarbage = this.bufferedGarbage;