Skip to content

Commit

Permalink
Add puzzle mode (#126)
Browse files Browse the repository at this point in the history
  • Loading branch information
eltoder authored Feb 9, 2025
1 parent c508e68 commit 0b9efc7
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 37 deletions.
2 changes: 1 addition & 1 deletion database.rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"mode": {
".write": "auth != null && auth.uid == data.parent().child('host').val() && newData.exists()",
".validate": "newData.isString() && newData.val().matches(/^normal|junior|setchain|ultraset|ultrachain|ultra9|megaset|ghostset|memory$/)"
".validate": "newData.isString() && newData.val().matches(/^normal|junior|setchain|ultraset|ultrachain|ultra9|megaset|ghostset|puzzle|memory$/)"
},
"enableHint": {
".write": "auth != null && auth.uid == data.parent().child('host').val() && newData.exists()",
Expand Down
161 changes: 147 additions & 14 deletions functions/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,23 @@ interface GameModeInfo {
setType: SetType;
traits: number;
chain: number;
puzzle: boolean;
minBoardSize: number;
}

export interface FindState {
lastSet?: string[];
foundSets?: Set<string>;
}

interface InternalGameState {
current: Set<string>;
gameMode: GameMode;
minBoardSize: number;
chain: number;
puzzle: boolean;
findState: FindState;
board?: string[];
}

/** Generates a random seed. */
Expand All @@ -34,15 +47,52 @@ export function generateSeed() {
return s;
}

// xoshiro128** (Vigna & Blackman, 2018) PRNG implementaion taken from
// https://stackoverflow.com/a/47593316/5190601
function makeRandom(seed: string) {
if (seed === "local") {
return Math.random;
}
if (!seed.startsWith("v1:")) {
throw new Error(`Unknown seed version: ${seed}`);
}
let a = parseInt(seed.slice(3, 11), 16) >>> 0;
let b = parseInt(seed.slice(11, 19), 16) >>> 0;
let c = parseInt(seed.slice(19, 27), 16) >>> 0;
let d = parseInt(seed.slice(27, 35), 16) >>> 0;
return () => {
let t = b << 9,
r = b * 5;
r = ((r << 7) | (r >>> 25)) * 9;
c ^= a;
d ^= b;
b ^= c;
a ^= d;
c ^= t;
d = (d << 11) | (d >>> 21);
return (r >>> 0) / 4294967296.0;
};
}

function makeCards(symbols: string[], traits: number): string[] {
if (traits === 1) return symbols;
return makeCards(symbols, traits - 1).flatMap((lhs) =>
symbols.map((s) => lhs + s)
);
}

function generateDeck(gameMode: GameMode) {
function generateDeck(gameMode: GameMode, seed: string | null) {
const deck = makeCards(["0", "1", "2"], modes[gameMode].traits);
if (seed) {
// Fisher-Yates
const random = makeRandom(seed);
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
const temp = deck[i];
deck[i] = deck[j];
deck[j] = temp;
}
}
return new Set(deck);
}

Expand Down Expand Up @@ -102,11 +152,15 @@ function findSetNormal(deck: string[], gameMode: GameMode, state: FindState) {
const deckSet = new Set(deck);
const first =
modes[gameMode].chain && state.lastSet!.length > 0 ? state.lastSet! : deck;
const foundSets = modes[gameMode].puzzle && state.foundSets!;
for (let i = 0; i < first.length; i++) {
for (let j = first === deck ? i + 1 : 0; j < deck.length; j++) {
const c = conjugateCard(first[i], deck[j]);
if (deckSet.has(c)) {
return [first[i], deck[j], c];
const set = [first[i], deck[j], c];
if (!(foundSets && foundSets.has(set.sort().join("|")))) {
return set;
}
}
}
}
Expand Down Expand Up @@ -178,6 +232,30 @@ function cardsFromEvent(event: GameEvent) {
return cards;
}

function copyFrom<T>(src: Iterator<T>, dest: T[], count: number) {
for (let i = 0; i < count; i++) {
const r = src.next();
if (r.done) break;
dest.push(r.value);
}
}

/** Return the board form the deck that has at least one set */
function findBoard(
deck: Set<string>,
gameMode: GameMode,
minBoardSize: number,
state: FindState
) {
const deckIter = deck.values();
let board: string[] = [];
copyFrom(deckIter, board, minBoardSize);
while (board.length < deck.size && !findSet(board, gameMode, state)) {
copyFrom(deckIter, board, 3 - (board.length % 3));
}
return board;
}

/** Check if all cards are distinct */
function hasDuplicates(cards: string[]) {
for (let i = 0; i < cards.length; i++) {
Expand All @@ -199,12 +277,8 @@ function deleteCards(deck: Set<string>, cards: string[]) {
}

/** Replay game event */
function replayEvent(
deck: Set<string>,
event: GameEvent,
chain: number,
findState: FindState
) {
function replayEvent(internalGameState: InternalGameState, event: GameEvent) {
const { current, chain, puzzle, findState } = internalGameState;
const allCards = cardsFromEvent(event);
let cards;
if (chain && findState.lastSet!.length > 0) {
Expand All @@ -213,11 +287,27 @@ function replayEvent(
} else {
cards = allCards;
}
if (hasDuplicates(allCards) || !validCards(deck, cards)) return false;
if (hasDuplicates(allCards) || !validCards(current, cards)) return false;
if (puzzle) {
const prevFound = findState.foundSets!.size;
findState.foundSets!.add(allCards.slice().sort().join("|"));
if (prevFound === findState.foundSets!.size) return false;
}
if (chain) {
findState.lastSet = allCards;
}
deleteCards(deck, cards);
if (!puzzle) {
deleteCards(current, cards);
} else {
// in puzzle modes only advance after all sets were found
const { board, gameMode, minBoardSize } = internalGameState;
if (!findSet(board!, gameMode, findState)) {
findState.foundSets!.clear();
deleteCards(current, board!);
const newBoard = findBoard(current, gameMode, minBoardSize, findState);
internalGameState.board = newBoard;
}
}
return true;
}

Expand All @@ -244,46 +334,71 @@ export const modes = {
setType: "Set",
traits: 4,
chain: 0,
puzzle: false,
minBoardSize: 12,
},
junior: {
setType: "Set",
traits: 3,
chain: 0,
puzzle: false,
minBoardSize: 9,
},
setchain: {
setType: "Set",
traits: 4,
chain: 1,
puzzle: false,
minBoardSize: 12,
},
ultraset: {
setType: "UltraSet",
traits: 4,
chain: 0,
puzzle: false,
minBoardSize: 12,
},
ultrachain: {
setType: "UltraSet",
traits: 4,
chain: 2,
puzzle: false,
minBoardSize: 12,
},
ultra9: {
setType: "UltraSet",
traits: 4,
chain: 0,
puzzle: false,
minBoardSize: 9,
},
megaset: {
setType: "Set",
traits: 5,
chain: 0,
puzzle: false,
minBoardSize: 16,
},
ghostset: {
setType: "GhostSet",
traits: 4,
chain: 0,
puzzle: false,
minBoardSize: 10,
},
puzzle: {
setType: "Set",
traits: 4,
chain: 0,
puzzle: true,
minBoardSize: 12,
},
memory: {
setType: "Set",
traits: 4,
chain: 0,
puzzle: false,
minBoardSize: 21,
},
} satisfies Record<string, GameModeInfo>;

Expand All @@ -305,17 +420,35 @@ export function replayEvents(
// Array.sort() is guaranteed to be stable in Node.js, and the latest ES spec
events.sort((e1, e2) => e1.time - e2.time);

const deck = generateDeck(gameMode);
const chain = modes[gameMode].chain;
const findState: FindState = { lastSet: chain ? [] : undefined };
const puzzle = modes[gameMode].puzzle;
const minBoardSize = modes[gameMode].minBoardSize;
// no need to shuffle the deck in non-puzzle modes
const seed = puzzle ? gameData.child("seed").val() : null;
const current = generateDeck(gameMode, seed);
const findState: FindState = {
lastSet: chain ? [] : undefined,
foundSets: puzzle ? new Set() : undefined,
};
const scores: Record<string, number> = {};
const internalGameState = {
current,
gameMode,
minBoardSize,
chain,
puzzle,
findState,
board: puzzle
? findBoard(current, gameMode, minBoardSize, findState)
: undefined,
};
let finalTime = 0;
for (const event of events) {
if (replayEvent(deck, event, chain, findState)) {
if (replayEvent(internalGameState, event)) {
scores[event.user] = (scores[event.user] ?? 0) + 1;
finalTime = event.time;
}
}

return { findState, deck, finalTime, scores };
return { findState, current, finalTime, scores };
}
4 changes: 2 additions & 2 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ export const finishGame = functions.https.onCall(async (data, context) => {
}

const gameMode = (gameSnap.child("mode").val() as GameMode) || "normal";
const { findState, deck, finalTime, scores } = replayEvents(
const { findState, current, finalTime, scores } = replayEvents(
gameData,
gameMode
);

if (findSet(Array.from(deck), gameMode, findState)) {
if (findSet(Array.from(current), gameMode, findState)) {
throw new functions.https.HttpsError(
"failed-precondition",
"The requested game has not yet ended."
Expand Down
Loading

0 comments on commit 0b9efc7

Please sign in to comment.