Skip to content

Commit

Permalink
Add set junior mode (#79)
Browse files Browse the repository at this point in the history
  • Loading branch information
eltoder authored Dec 26, 2024
1 parent ade1dbc commit 9d7b53c
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 90 deletions.
10 changes: 5 additions & 5 deletions 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|setchain|ultraset|ultra9$/)"
".validate": "newData.isString() && newData.val().matches(/^normal|junior|setchain|ultraset|ultra9$/)"
},
"enableHint": {
".write": "auth != null && auth.uid == data.parent().child('host').val() && newData.exists()",
Expand All @@ -41,16 +41,16 @@
".validate": "newData.isNumber() && newData.val() == now"
},
"c1": {
".validate": "newData.isString() && newData.val().matches(/^[0-2]{4}$/)"
".validate": "newData.isString() && newData.val().matches(/^[0-2]{3,5}$/)"
},
"c2": {
".validate": "newData.isString() && newData.val().matches(/^[0-2]{4}$/)"
".validate": "newData.isString() && newData.val().matches(/^[0-2]{3,5}$/)"
},
"c3": {
".validate": "newData.isString() && newData.val().matches(/^[0-2]{4}$/)"
".validate": "newData.isString() && newData.val().matches(/^[0-2]{3,5}$/)"
},
"c4": {
".validate": "newData.isString() && newData.val().matches(/^[0-2]{4}$/)"
".validate": "newData.isString() && newData.val().matches(/^[0-2]{3,5}$/)"
}
}
}
Expand Down
60 changes: 34 additions & 26 deletions functions/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,27 @@ interface GameEvent {
c4?: string;
}

export type GameMode = "normal" | "setchain" | "ultraset" | "ultra9";

/** Generates a random 81-card deck using a Fisher-Yates shuffle. */
export function generateDeck() {
const deck: Array<string> = [];
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
for (let k = 0; k < 3; k++) {
for (let l = 0; l < 3; l++) {
deck.push(`${i}${j}${k}${l}`);
}
}
}
}
// Fisher-Yates
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = deck[i];
deck[i] = deck[j];
deck[j] = temp;
export type GameMode = "normal" | "junior" | "setchain" | "ultraset" | "ultra9";

/** Generates a random seed. */
export function generateSeed() {
let s = "v1:";
for (let i = 0; i < 4; i++) {
s += ((Math.random() * 2 ** 32) >>> 0).toString(16).padStart(8, "0");
}
return deck;
return s;
}

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) {
const deck = makeCards(["0", "1", "2"], modes[gameMode].traits);
return new Set(deck);
}

/** Check if three cards form a set. */
Expand Down Expand Up @@ -72,6 +71,7 @@ export function findSet(deck: string[], gameMode: GameMode, old?: string[]) {
const c = conjugateCard(deck[i], deck[j]);
if (
gameMode === "normal" ||
gameMode === "junior" ||
(gameMode === "setchain" && old!.length === 0)
) {
if (deckSet.has(c)) {
Expand Down Expand Up @@ -146,15 +146,23 @@ function replayEventChain(

const modes = {
normal: {
traits: 4,
replayFn: replayEventCommon,
},
junior: {
traits: 3,
replayFn: replayEventCommon,
},
setchain: {
traits: 4,
replayFn: replayEventChain,
},
ultraset: {
traits: 4,
replayFn: replayEventCommon,
},
ultra9: {
traits: 4,
replayFn: replayEventCommon,
},
};
Expand All @@ -167,20 +175,20 @@ export function replayEvents(
gameData: admin.database.DataSnapshot,
gameMode: GameMode
) {
if (!modes.hasOwnProperty(gameMode)) {
throw new Error(`invalid gameMode: ${gameMode}`);
}
const events: GameEvent[] = [];
gameData.child("events").forEach((e) => {
events.push(e.val());
});
// 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: Set<string> = new Set(gameData.child("deck").val());
const deck = generateDeck(gameMode);
const history: GameEvent[] = [];
const scores: Record<string, number> = {};
const replayFn = modes[gameMode]?.replayFn;
if (!replayFn) {
throw new Error(`invalid gameMode ${gameMode}`);
}
const replayFn = modes[gameMode].replayFn;
let finalTime = 0;
for (const event of events) {
if (replayFn(deck, event, history)) {
Expand Down
59 changes: 29 additions & 30 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const stripe = !functions.config().stripe
apiVersion: "2020-08-27",
});

import { generateDeck, replayEvents, findSet, GameMode } from "./game";
import { generateSeed, replayEvents, findSet, GameMode } from "./game";

const MAX_GAME_ID_LENGTH = 64;
const MAX_UNFINISHED_GAMES_PER_HOUR = 4;
Expand Down Expand Up @@ -55,7 +55,6 @@ export const finishGame = functions.https.onCall(async (data, context) => {
"The function must be called while authenticated."
);
}

const gameData = await admin
.database()
.ref(`gameData/${gameId}`)
Expand All @@ -69,7 +68,6 @@ export const finishGame = functions.https.onCall(async (data, context) => {
}

const gameMode = (gameSnap.child("mode").val() as GameMode) || "normal";

const { lastSet, deck, finalTime, scores } = replayEvents(gameData, gameMode);

if (findSet(Array.from(deck), gameMode, lastSet)) {
Expand Down Expand Up @@ -242,7 +240,6 @@ export const createGame = functions.https.onCall(async (data, context) => {
}

const userId = context.auth.uid;

const oneHourAgo = Date.now() - 3600000;
const recentGameIds = await admin
.database()
Expand All @@ -268,30 +265,32 @@ export const createGame = functions.https.onCall(async (data, context) => {
}
}

const gameRef = admin.database().ref(`games/${gameId}`);
const { committed, snapshot } = await gameRef.transaction((currentData) => {
if (currentData === null) {
if (
unfinishedGames >= MAX_UNFINISHED_GAMES_PER_HOUR &&
access === "public"
) {
throw new functions.https.HttpsError(
"resource-exhausted",
"Too many unfinished public games were recently created."
);
const { committed, snapshot } = await admin
.database()
.ref(`games/${gameId}`)
.transaction((currentData) => {
if (currentData === null) {
if (
unfinishedGames >= MAX_UNFINISHED_GAMES_PER_HOUR &&
access === "public"
) {
throw new functions.https.HttpsError(
"resource-exhausted",
"Too many unfinished public games were recently created."
);
}
return {
host: userId,
createdAt: admin.database.ServerValue.TIMESTAMP,
status: "waiting",
access,
mode,
enableHint,
};
} else {
return;
}
return {
host: userId,
createdAt: admin.database.ServerValue.TIMESTAMP,
status: "waiting",
access,
mode,
enableHint,
};
} else {
return;
}
});
});
if (!committed) {
throw new functions.https.HttpsError(
"already-exists",
Expand All @@ -300,12 +299,12 @@ export const createGame = functions.https.onCall(async (data, context) => {
}

// After this point, the game has successfully been created.
// We update the database asynchronously in three different places:
// We update the database atomically in different places:
// 1. /gameData/:gameId
// 2. /stats/gameCount
// 3. /publicGames/:gameId (if access is public)
const updates: { [key: string]: any } = {};
updates[`gameData/${gameId}`] = { deck: generateDeck() };
const updates: Record<string, any> = {};
updates[`gameData/${gameId}`] = { seed: generateSeed() };
updates["stats/gameCount"] = admin.database.ServerValue.increment(1);
if (access === "public") {
updates[`publicGames/${gameId}`] = snapshot.child("createdAt").val();
Expand Down
8 changes: 2 additions & 6 deletions src/components/ResponsiveSetCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { memo } from "react";

import { makeStyles, useTheme } from "@material-ui/core/styles";
import clsx from "clsx";
import { cardTraits } from "../game";

const useStyles = makeStyles((theme) => ({
symbol: {
Expand Down Expand Up @@ -77,12 +78,7 @@ function ResponsiveSetCard(props) {
const margin = Math.round(width * 0.035);
const contentWidth = width - 2 * margin;
const contentHeight = height - 2 * margin;

// 4-character string of 0..2
const color = value.charCodeAt(0) - 48;
const shape = value.charCodeAt(1) - 48;
const shade = value.charCodeAt(2) - 48;
const number = value.charCodeAt(3) - 48;
const { color, shape, shade, number } = cardTraits(value);

return (
<div
Expand Down
8 changes: 2 additions & 6 deletions src/components/SetCard.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo } from "react";

import { makeStyles, useTheme } from "@material-ui/core/styles";
import { cardTraits } from "../game";

const useStyles = makeStyles((theme) => ({
card: {
Expand Down Expand Up @@ -80,12 +81,7 @@ function Symbol(props) {

function SetCard(props) {
const classes = useStyles();

// 4-character string of 0..2
const color = props.value.charCodeAt(0) - 48;
const shape = props.value.charCodeAt(1) - 48;
const shade = props.value.charCodeAt(2) - 48;
const number = props.value.charCodeAt(3) - 48;
const { color, shape, shade, number } = cardTraits(props.value);

let className = classes.card;
if (props.selected) className += " " + classes.selected;
Expand Down
Loading

0 comments on commit 9d7b53c

Please sign in to comment.